mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-17 22:57:07 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3abd355004 | |||
| 7b2a90b967 | |||
| 1d633601f0 | |||
| 94148702e8 | |||
| e5548065ba |
@@ -0,0 +1,146 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { YamlFieldSchema } from "../resources/yaml_field_schema";
|
||||
|
||||
/**
|
||||
* Tooltip element rendered inside a CodeMirror hoverTooltip for YAML field
|
||||
* keys in the automation / script / card YAML editors.
|
||||
*
|
||||
* Shows:
|
||||
* - Field name (monospace)
|
||||
* - "required" badge when applicable
|
||||
* - Description paragraph
|
||||
* - Selector type hint
|
||||
* - Example value
|
||||
* - Default value
|
||||
*/
|
||||
@customElement("ha-code-editor-yaml-hover")
|
||||
export class HaCodeEditorYamlHover extends LitElement {
|
||||
@property({ attribute: false }) public fieldName = "";
|
||||
|
||||
@property({ attribute: false }) public fieldSchema!: YamlFieldSchema;
|
||||
|
||||
/**
|
||||
* Optional localize callback forwarded from the editor so translated
|
||||
* descriptions can be rendered. When absent, strings are shown verbatim.
|
||||
*/
|
||||
@property({ attribute: false }) public localize?: (
|
||||
key: string,
|
||||
...args: unknown[]
|
||||
) => string;
|
||||
|
||||
render() {
|
||||
const schema = this.fieldSchema;
|
||||
if (!schema) return nothing;
|
||||
|
||||
const description = schema.description
|
||||
? (this.localize ? this.localize(schema.description) : "") ||
|
||||
schema.description
|
||||
: undefined;
|
||||
|
||||
const selectorType = schema.selector
|
||||
? Object.keys(schema.selector)[0]
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<code class="key">${this.fieldName}</code>
|
||||
${schema.required
|
||||
? html`<span class="badge required">required</span>`
|
||||
: nothing}
|
||||
${selectorType
|
||||
? html`<span class="badge type">${selectorType}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
${description ? html`<div class="desc">${description}</div>` : nothing}
|
||||
${schema.example != null
|
||||
? html`<div class="meta">
|
||||
<span class="meta-label">Example:</span>
|
||||
<code>${String(schema.example)}</code>
|
||||
</div>`
|
||||
: nothing}
|
||||
${schema.default != null
|
||||
? html`<div class="meta">
|
||||
<span class="meta-label">Default:</span>
|
||||
<code>${String(schema.default)}</code>
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
max-width: 320px;
|
||||
line-height: 1.5;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
code.key {
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
padding: 0 5px;
|
||||
font-size: 0.78em;
|
||||
line-height: 1.6;
|
||||
font-family: var(--ha-font-family-body);
|
||||
}
|
||||
|
||||
.badge.required {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--error-color, #db4437) 15%,
|
||||
transparent
|
||||
);
|
||||
color: var(--error-color, #db4437);
|
||||
}
|
||||
|
||||
.badge.type {
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.desc {
|
||||
color: var(--secondary-text-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.75;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta code {
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-code-editor-yaml-hover": HaCodeEditorYamlHover;
|
||||
}
|
||||
}
|
||||
@@ -31,15 +31,21 @@ import { consume } from "@lit/context";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import { getEntityContext } from "../common/entity/context/get_entity_context";
|
||||
import { computeDeviceName } from "../common/entity/compute_device_name";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import {
|
||||
buildEntityCompletions,
|
||||
buildDeviceCompletions,
|
||||
buildAreaCompletions,
|
||||
buildFloorCompletions,
|
||||
buildLabelCompletions,
|
||||
} from "../resources/ha_completion_items";
|
||||
import type {
|
||||
JinjaArgType,
|
||||
HassArgHoverContext,
|
||||
} from "../resources/jinja_ha_completions";
|
||||
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
|
||||
import "./ha-code-editor-yaml-hover";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { showToast } from "../util/toast";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
@@ -80,6 +86,14 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
/**
|
||||
* Optional field schema for YAML mode. When set, the editor will provide
|
||||
* field-aware key/value completions, hover tooltips, and linting for the
|
||||
* known fields described by this map.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public yamlFieldSchema?: YamlFieldSchemaMap;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@@ -136,6 +150,12 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _completionInfoDestroy?: () => void;
|
||||
|
||||
// Stored YAML syntax error set by setYamlError(); consumed by _yamlSyntaxLinter.
|
||||
private _yamlSyntaxError: {
|
||||
mark?: { position: number; line: number; column: number };
|
||||
reason?: string;
|
||||
} | null = null;
|
||||
|
||||
private _completionInfoRequest = 0;
|
||||
|
||||
private _completionInfoKey?: string;
|
||||
@@ -169,6 +189,10 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
* Push a YAML parse error (or null to clear) into the lint gutter as a
|
||||
* diagnostic. Avoids re-parsing the document — the caller (ha-yaml-editor)
|
||||
* already has the error from its own js-yaml load() call.
|
||||
*
|
||||
* Stores the error and triggers forceLinting() so the yamlLintCompartment
|
||||
* linter re-runs and returns it as a diagnostic — rather than calling
|
||||
* setDiagnostics() which would wipe diagnostics from other linters.
|
||||
*/
|
||||
public setYamlError(
|
||||
err: {
|
||||
@@ -176,27 +200,10 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
reason?: string;
|
||||
} | null
|
||||
): void {
|
||||
if (!this.codemirror || !this._loadedCodeMirror) return;
|
||||
let diagnostics: {
|
||||
from: number;
|
||||
to: number;
|
||||
severity: "error";
|
||||
message: string;
|
||||
}[] = [];
|
||||
if (err) {
|
||||
const doc = this.codemirror.state.doc;
|
||||
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
|
||||
const line = doc.lineAt(pos);
|
||||
const message = `${
|
||||
err.reason ||
|
||||
this.hass?.localize("ui.components.yaml-editor.error") ||
|
||||
"YAML syntax error"
|
||||
}${err.mark ? ` (${this.hass?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
|
||||
this._yamlSyntaxError = err;
|
||||
if (this.codemirror && this._loadedCodeMirror) {
|
||||
this._loadedCodeMirror.forceLinting(this.codemirror);
|
||||
}
|
||||
this.codemirror.dispatch(
|
||||
this._loadedCodeMirror.setDiagnostics(this.codemirror.state, diagnostics)
|
||||
);
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
@@ -257,9 +264,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
effects: [
|
||||
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
|
||||
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
this._buildYamlSyntaxLinter()
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -271,20 +276,23 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
|
||||
),
|
||||
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
this._buildYamlSyntaxLinter()
|
||||
),
|
||||
],
|
||||
});
|
||||
this._updateToolbarButtons();
|
||||
}
|
||||
if (changedProps.has("lint")) {
|
||||
if (changedProps.has("lint") || changedProps.has("yamlFieldSchema")) {
|
||||
transactions.push({
|
||||
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
this._buildYamlSyntaxLinter()
|
||||
),
|
||||
});
|
||||
}
|
||||
if (changedProps.has("yamlFieldSchema") || changedProps.has("readOnly")) {
|
||||
transactions.push({
|
||||
effects: this._loadedCodeMirror!.yamlSchemaCompartment!.reconfigure(
|
||||
this._buildSchemaLinter()
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -337,6 +345,60 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
return this._loadedCodeMirror!.langs[this.mode];
|
||||
}
|
||||
|
||||
private _buildSchemaLinter() {
|
||||
if (!this._loadedCodeMirror || !this.yamlFieldSchema || this.readOnly) {
|
||||
return [];
|
||||
}
|
||||
const schema = this.yamlFieldSchema;
|
||||
return [
|
||||
this._loadedCodeMirror.linter(
|
||||
(view) => this._loadedCodeMirror!.haYamlLintSource(view, schema),
|
||||
{ delay: 500 }
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the yamlLintCompartment extensions: a linter that surfaces the
|
||||
* stored _yamlSyntaxError (set by setYamlError), plus the lint gutter when
|
||||
* either syntax linting or schema linting is active.
|
||||
*
|
||||
* Using a linter() instead of setDiagnostics() means this linter's
|
||||
* diagnostics are managed independently of the schema linter's diagnostics —
|
||||
* they don't overwrite each other.
|
||||
*/
|
||||
private _buildYamlSyntaxLinter() {
|
||||
if (this.readOnly) return [];
|
||||
const showGutter = this.lint || !!this.yamlFieldSchema;
|
||||
const extensions: Extension[] = [];
|
||||
if (showGutter) {
|
||||
extensions.push(this._loadedCodeMirror!.lintGutter());
|
||||
}
|
||||
if (this.lint) {
|
||||
extensions.push(
|
||||
this._loadedCodeMirror!.linter(
|
||||
(view) => {
|
||||
const err = this._yamlSyntaxError;
|
||||
if (!err) return [];
|
||||
const doc = view.state.doc;
|
||||
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
|
||||
const line = doc.lineAt(pos);
|
||||
const message = `${
|
||||
err.reason ||
|
||||
this.hass?.localize("ui.components.yaml-editor.error") ||
|
||||
"YAML syntax error"
|
||||
}${err.mark ? ` (${this.hass?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
return [
|
||||
{ from: pos, to: line.to, severity: "error" as const, message },
|
||||
];
|
||||
},
|
||||
{ delay: 0 }
|
||||
)
|
||||
);
|
||||
}
|
||||
return extensions;
|
||||
}
|
||||
|
||||
private _createCodeMirror() {
|
||||
if (!this._loadedCodeMirror) {
|
||||
throw new Error("Cannot create editor before CodeMirror is loaded");
|
||||
@@ -385,7 +447,10 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
|
||||
),
|
||||
this._loadedCodeMirror.yamlLintCompartment.of(
|
||||
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
|
||||
this._buildYamlSyntaxLinter()
|
||||
),
|
||||
this._loadedCodeMirror.yamlSchemaCompartment.of(
|
||||
this._buildSchemaLinter()
|
||||
),
|
||||
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
|
||||
this._loadedCodeMirror.tooltips({
|
||||
@@ -401,6 +466,23 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
),
|
||||
{ hoverTime: 300 }
|
||||
),
|
||||
...(this.mode === "yaml" && this.yamlFieldSchema
|
||||
? [
|
||||
this._loadedCodeMirror.hoverTooltip(
|
||||
(view, pos) =>
|
||||
this._loadedCodeMirror!.haYamlHoverSource(view, pos, {
|
||||
schema: this.yamlFieldSchema!,
|
||||
localize: this.hass?.localize.bind(this.hass) as
|
||||
| ((key: string, ...args: unknown[]) => string)
|
||||
| undefined,
|
||||
hassContext: this.hass
|
||||
? this._hassArgHoverContext()
|
||||
: undefined,
|
||||
}),
|
||||
{ hoverTime: 300 }
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(this.placeholder ? [placeholder(this.placeholder)] : []),
|
||||
];
|
||||
|
||||
@@ -408,6 +490,18 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const completionSources: CompletionSource[] = [
|
||||
this._loadedCodeMirror.haJinjaCompletionSource,
|
||||
];
|
||||
if (this.mode === "yaml" && this.yamlFieldSchema) {
|
||||
completionSources.push(
|
||||
this._loadedCodeMirror.haYamlCompletionSource({
|
||||
schema: this.yamlFieldSchema,
|
||||
states: this.hass?.states,
|
||||
devices: this.hass?.devices,
|
||||
areas: this.hass?.areas,
|
||||
floors: this.hass?.floors,
|
||||
labels: this._labels,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (this.autocompleteEntities && this.hass) {
|
||||
completionSources.push(this._entityCompletions.bind(this));
|
||||
}
|
||||
@@ -418,6 +512,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._loadedCodeMirror.autocompletion({
|
||||
override: completionSources,
|
||||
maxRenderedOptions: 10,
|
||||
activateOnCompletion: (completion) => completion.type === "yaml-key",
|
||||
}),
|
||||
this._loadedCodeMirror.closeBrackets(),
|
||||
this._loadedCodeMirror.closeBracketsOverride,
|
||||
@@ -965,23 +1060,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
});
|
||||
};
|
||||
|
||||
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
|
||||
if (!states) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const options = Object.keys(states).map((key) => ({
|
||||
type: "variable",
|
||||
label: states[key].attributes.friendly_name
|
||||
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
|
||||
: key,
|
||||
displayLabel: key,
|
||||
detail: states[key].attributes.friendly_name,
|
||||
apply: key,
|
||||
}));
|
||||
|
||||
return options;
|
||||
});
|
||||
private _getStates = memoizeOne((states: HassEntities): Completion[] =>
|
||||
buildEntityCompletions(states)
|
||||
);
|
||||
|
||||
// Map of HA Jinja function name → (arg index → JinjaArgType).
|
||||
// Derived from the snippet definitions in jinja_ha_completions.ts.
|
||||
@@ -1378,18 +1459,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _getDevices = memoizeOne(
|
||||
(devices: HomeAssistant["devices"]): Completion[] =>
|
||||
Object.values(devices)
|
||||
.filter((device) => !device.disabled_by)
|
||||
.map((device) => {
|
||||
const name = computeDeviceName(device);
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${device.id}`,
|
||||
displayLabel: name ?? device.id,
|
||||
detail: device.id,
|
||||
apply: device.id,
|
||||
};
|
||||
})
|
||||
buildDeviceCompletions(devices)
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
|
||||
@@ -1408,17 +1478,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
|
||||
private _getAreas = memoizeOne(
|
||||
(areas: HomeAssistant["areas"]): Completion[] =>
|
||||
Object.values(areas).map((area) => {
|
||||
const name = computeAreaName(area) ?? area.area_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: area.area_id,
|
||||
apply: area.area_id,
|
||||
};
|
||||
})
|
||||
(areas: HomeAssistant["areas"]): Completion[] => buildAreaCompletions(areas)
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
|
||||
@@ -1438,16 +1498,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _getFloors = memoizeOne(
|
||||
(floors: HomeAssistant["floors"]): Completion[] =>
|
||||
Object.values(floors).map((floor) => {
|
||||
const name = computeFloorName(floor) ?? floor.floor_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: floor.floor_id,
|
||||
apply: floor.floor_id,
|
||||
};
|
||||
})
|
||||
buildFloorCompletions(floors)
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
|
||||
@@ -1467,16 +1518,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
private _getLabels = memoizeOne(
|
||||
(labels: LabelRegistryEntry[]): Completion[] =>
|
||||
labels.map((label) => {
|
||||
const name = label.name.trim() || label.label_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: label.label_id,
|
||||
apply: label.label_id,
|
||||
};
|
||||
})
|
||||
buildLabelCompletions(labels)
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
|
||||
|
||||
@@ -13,13 +13,16 @@ import "../ha-input-helper-text";
|
||||
import type { SelectBoxOption } from "../ha-select-box";
|
||||
import "../ha-select-box";
|
||||
|
||||
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
|
||||
export const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
|
||||
"any",
|
||||
"first",
|
||||
"last",
|
||||
];
|
||||
|
||||
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
|
||||
export const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = [
|
||||
"any",
|
||||
"all",
|
||||
];
|
||||
|
||||
@customElement("ha-selector-automation_behavior")
|
||||
export class HaSelectorAutomationBehavior extends LitElement {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fireEvent } from "../common/dom/fire_event";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
|
||||
import { showToast } from "../util/toast";
|
||||
import "./ha-button";
|
||||
import "./ha-code-editor";
|
||||
@@ -32,6 +33,14 @@ export class HaYamlEditor extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
|
||||
|
||||
/**
|
||||
* Optional field schema for YAML mode. When provided, the code editor will
|
||||
* offer field-aware key/value completions, hover tooltips, and linting.
|
||||
* This is forwarded directly to ha-code-editor.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public yamlFieldSchema?: YamlFieldSchemaMap;
|
||||
|
||||
@property({ attribute: false }) public defaultValue?: any;
|
||||
|
||||
@property({ attribute: "is-valid", type: Boolean }) public isValid = true;
|
||||
@@ -119,8 +128,9 @@ export class HaYamlEditor extends LitElement {
|
||||
.inDialog=${this.inDialog}
|
||||
mode="yaml"
|
||||
lint
|
||||
autocomplete-entities
|
||||
.autocompleteEntities=${!this.yamlFieldSchema}
|
||||
autocomplete-icons
|
||||
.yamlFieldSchema=${this.yamlFieldSchema}
|
||||
.error=${this.isValid === false}
|
||||
@value-changed=${this._onChange}
|
||||
@editor-save=${this._onEditorSave}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
@@ -8,6 +9,7 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
|
||||
import { migrateAutomationAction, type Action } from "../../../../data/script";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { actionToYamlSchema } from "../yaml_schema_helpers";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { editorStyles, indentStyle } from "../styles";
|
||||
import {
|
||||
@@ -41,6 +43,14 @@ export default class HaAutomationActionEditor extends LitElement {
|
||||
@query(COLLAPSIBLE_ACTION_ELEMENTS.join(", "))
|
||||
private _collapsibleElement?: ActionElement;
|
||||
|
||||
private _actionYamlSchema = memoizeOne(
|
||||
(
|
||||
action: Action,
|
||||
services: HomeAssistant["services"],
|
||||
localize: HomeAssistant["localize"]
|
||||
) => actionToYamlSchema(action, services, localize)
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const yamlMode = this.yamlMode || !this.uiSupported;
|
||||
const type = getAutomationActionType(this.action);
|
||||
@@ -75,6 +85,11 @@ export default class HaAutomationActionEditor extends LitElement {
|
||||
.defaultValue=${this.action}
|
||||
@value-changed=${this._onYamlChange}
|
||||
.readOnly=${this.disabled}
|
||||
.yamlFieldSchema=${this._actionYamlSchema(
|
||||
this.action,
|
||||
this.hass.services,
|
||||
this.hass.localize
|
||||
)}
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
: html`
|
||||
|
||||
@@ -11,6 +11,10 @@ import { expandConditionWithShorthand } from "../../../../data/automation";
|
||||
import type { ConditionDescription } from "../../../../data/condition";
|
||||
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
builtInConditionSchema,
|
||||
conditionDescriptionToSchema,
|
||||
} from "../yaml_schema_helpers";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { editorStyles, indentStyle } from "../styles";
|
||||
import type { ConditionElement } from "./ha-automation-condition-row";
|
||||
@@ -44,6 +48,23 @@ export default class HaAutomationConditionEditor extends LitElement {
|
||||
@query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", "))
|
||||
private _collapsibleElement?: ConditionElement;
|
||||
|
||||
private _conditionYamlSchema = memoizeOne(
|
||||
(
|
||||
condition: Condition,
|
||||
description: ConditionDescription | undefined,
|
||||
localize: HomeAssistant["localize"]
|
||||
) => {
|
||||
if (!description) {
|
||||
return builtInConditionSchema(condition.condition, localize);
|
||||
}
|
||||
return conditionDescriptionToSchema(
|
||||
condition.condition,
|
||||
description,
|
||||
localize
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _processedCondition = memoizeOne((condition) =>
|
||||
expandConditionWithShorthand(condition)
|
||||
);
|
||||
@@ -83,6 +104,11 @@ export default class HaAutomationConditionEditor extends LitElement {
|
||||
.defaultValue=${this.condition}
|
||||
@value-changed=${this._onYamlChange}
|
||||
.readOnly=${this.disabled}
|
||||
.yamlFieldSchema=${this._conditionYamlSchema(
|
||||
condition,
|
||||
this.description,
|
||||
this.hass.localize
|
||||
)}
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
: html`
|
||||
|
||||
+49
-14
@@ -31,6 +31,54 @@ const numericStateConditionStruct = object({
|
||||
enabled: optional(boolean()),
|
||||
});
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "entity_id", required: true, selector: { entity: {} } },
|
||||
{
|
||||
name: "attribute",
|
||||
selector: { attribute: { hide_attributes: NON_NUMERIC_ATTRIBUTES } },
|
||||
context: { filter_entity: "entity_id" },
|
||||
},
|
||||
{
|
||||
name: "above",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "below",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "value_template", selector: { template: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string => {
|
||||
switch (fieldName) {
|
||||
case "entity_id":
|
||||
return localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return localize("ui.components.entity.entity-attribute-picker.attribute");
|
||||
default:
|
||||
return localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.numeric_state.${fieldName}` as any
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@customElement("ha-automation-condition-numeric_state")
|
||||
export default class HaNumericStateCondition extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -241,20 +289,7 @@ export default class HaNumericStateCondition extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "entity_id":
|
||||
return this.hass.localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return this.hass.localize(
|
||||
"ui.components.entity.entity-attribute-picker.attribute"
|
||||
);
|
||||
default:
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
|
||||
);
|
||||
}
|
||||
};
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -19,6 +19,7 @@ import "../../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { StateCondition } from "../../../../../data/automation";
|
||||
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { forDictStruct } from "../../structs";
|
||||
import type { ConditionElement } from "../ha-automation-condition-row";
|
||||
@@ -33,7 +34,7 @@ const stateConditionStruct = object({
|
||||
enabled: optional(boolean()),
|
||||
});
|
||||
|
||||
const SCHEMA = [
|
||||
export const SCHEMA = [
|
||||
{ name: "entity_id", required: true, selector: { entity: {} } },
|
||||
{
|
||||
name: "attribute",
|
||||
@@ -60,6 +61,26 @@ const SCHEMA = [
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string => {
|
||||
switch (fieldName) {
|
||||
case "entity_id":
|
||||
return localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return localize("ui.components.entity.entity-attribute-picker.attribute");
|
||||
case "for":
|
||||
return localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.state.for"
|
||||
);
|
||||
default:
|
||||
return localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.state.${fieldName}` as any
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@customElement("ha-automation-condition-state")
|
||||
export class HaStateCondition extends LitElement implements ConditionElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -124,24 +145,7 @@ export class HaStateCondition extends LitElement implements ConditionElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "entity_id":
|
||||
return this.hass.localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return this.hass.localize(
|
||||
"ui.components.entity.entity-attribute-picker.attribute"
|
||||
);
|
||||
case "for":
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.state.for`
|
||||
);
|
||||
default:
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.state.${schema.name}`
|
||||
);
|
||||
}
|
||||
};
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -14,6 +14,29 @@ type FormType = "before" | "after" | "between";
|
||||
const BEFORE_DEFAULT = "sunrise";
|
||||
const AFTER_DEFAULT = "sunset";
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "before",
|
||||
type: "select" as const,
|
||||
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
|
||||
},
|
||||
{ name: "before_offset", selector: { duration: { allow_negative: true } } },
|
||||
{
|
||||
name: "after",
|
||||
type: "select" as const,
|
||||
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
|
||||
},
|
||||
{ name: "after_offset", selector: { duration: { allow_negative: true } } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.sun.${fieldName}` as any
|
||||
);
|
||||
|
||||
@customElement("ha-automation-condition-sun")
|
||||
export class HaSunCondition extends LitElement implements ConditionElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -154,10 +177,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
|
||||
|
||||
private _computeLabelCallback = (schema: {
|
||||
name: "before" | "after";
|
||||
}): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.sun.${schema.name}`
|
||||
);
|
||||
}): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
private _typeSelected(ev: HaSelectSelectEvent): void {
|
||||
const value = ev.detail.value as FormType;
|
||||
|
||||
@@ -5,11 +5,20 @@ import type { HomeAssistant } from "../../../../../types";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
|
||||
const SCHEMA = [
|
||||
export const SCHEMA = [
|
||||
{ name: "value_template", required: true, selector: { template: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.template.${fieldName}` as any
|
||||
);
|
||||
|
||||
@customElement("ha-automation-condition-template")
|
||||
export class HaTemplateCondition extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -43,10 +52,7 @@ export class HaTemplateCondition extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.template.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -13,6 +13,24 @@ import type { ConditionElement } from "../ha-automation-condition-row";
|
||||
|
||||
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "after", selector: { time: {} } },
|
||||
{ name: "before", selector: { time: {} } },
|
||||
{
|
||||
name: "weekday",
|
||||
type: "multi_select" as const,
|
||||
options: DAYS.map((d) => [d, d] as const),
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.time.${fieldName}` as any
|
||||
);
|
||||
|
||||
@customElement("ha-automation-condition-time")
|
||||
export class HaTimeCondition extends LitElement implements ConditionElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -184,10 +202,7 @@ export class HaTimeCondition extends LitElement implements ConditionElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.time.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type Trigger,
|
||||
type TriggerCondition,
|
||||
} from "../../../../../data/automation";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
const getTriggersIds = (triggers: Trigger[]): string[] => {
|
||||
@@ -22,6 +23,20 @@ const getTriggersIds = (triggers: Trigger[]): string[] => {
|
||||
return Array.from(new Set(triggerIds));
|
||||
};
|
||||
|
||||
// Static YAML schema — trigger IDs are dynamic at runtime, so we use a
|
||||
// plain text selector here to at least provide key completion.
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "id", required: true, selector: { text: { multiple: true } } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.trigger.${fieldName}` as any
|
||||
);
|
||||
|
||||
@customElement("ha-automation-condition-trigger")
|
||||
export class HaTriggerCondition extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -94,10 +109,7 @@ export class HaTriggerCondition extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.conditions.type.trigger.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
private _automationUpdated(config?: AutomationConfig) {
|
||||
this._triggerIds = config?.triggers
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
@@ -13,6 +14,10 @@ import type { TriggerDescription } from "../../../../data/trigger";
|
||||
import { isTriggerList } from "../../../../data/trigger";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
builtInTriggerSchema,
|
||||
triggerDescriptionToSchema,
|
||||
} from "../yaml_schema_helpers";
|
||||
import "../ha-automation-editor-warning";
|
||||
import "./types/ha-automation-trigger-platform";
|
||||
|
||||
@@ -37,6 +42,20 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
|
||||
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
|
||||
|
||||
private _triggerYamlSchema = memoizeOne(
|
||||
(
|
||||
trigger: Trigger,
|
||||
description: TriggerDescription | undefined,
|
||||
localize: HomeAssistant["localize"]
|
||||
) => {
|
||||
if (isTriggerList(trigger)) return undefined;
|
||||
if (!description) {
|
||||
return builtInTriggerSchema(trigger.trigger, localize);
|
||||
}
|
||||
return triggerDescriptionToSchema(trigger.trigger, description, localize);
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const type = isTriggerList(this.trigger) ? "list" : this.trigger.trigger;
|
||||
|
||||
@@ -74,6 +93,11 @@ export default class HaAutomationTriggerEditor extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.defaultValue=${this.trigger}
|
||||
.readOnly=${this.disabled}
|
||||
.yamlFieldSchema=${this._triggerYamlSchema(
|
||||
this.trigger,
|
||||
this.description,
|
||||
this.hass.localize
|
||||
)}
|
||||
@value-changed=${this._onYamlChange}
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
|
||||
@@ -11,6 +11,37 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string => {
|
||||
switch (fieldName) {
|
||||
case "entity_id":
|
||||
return localize("ui.components.entity.entity-picker.entity");
|
||||
case "event":
|
||||
return localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.event"
|
||||
);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "entity_id",
|
||||
required: true,
|
||||
selector: { entity: { domain: "calendar" } },
|
||||
},
|
||||
{
|
||||
name: "event",
|
||||
type: "select" as const,
|
||||
required: true,
|
||||
options: [["start", "start"] as const, ["end", "end"] as const],
|
||||
},
|
||||
{ name: "offset", selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-calendar")
|
||||
export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -125,17 +156,7 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "entity_id":
|
||||
return this.hass.localize("ui.components.entity.entity-picker.entity");
|
||||
case "event":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.event"
|
||||
);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -8,6 +8,25 @@ import type { HomeAssistant } from "../../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.geo_location.${fieldName}` as any
|
||||
);
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "source", selector: { text: {} } },
|
||||
{ name: "zone", selector: { entity: { domain: "zone" } } },
|
||||
{
|
||||
name: "event",
|
||||
type: "select" as const,
|
||||
required: true,
|
||||
options: [["enter", "enter"] as const, ["leave", "leave"] as const],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-geo_location")
|
||||
export class HaGeolocationTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -73,10 +92,7 @@ export class HaGeolocationTrigger extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.geo_location.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -8,6 +8,23 @@ import type { HomeAssistant } from "../../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.homeassistant.${fieldName}` as any
|
||||
);
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "event",
|
||||
type: "select" as const,
|
||||
required: true,
|
||||
options: [["start", "start"] as const, ["shutdown", "shutdown"] as const],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-homeassistant")
|
||||
export class HaHassTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -69,10 +86,7 @@ export class HaHassTrigger extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.homeassistant.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
static styles = css`
|
||||
label {
|
||||
|
||||
@@ -12,6 +12,63 @@ import type { NumericStateTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string => {
|
||||
switch (fieldName) {
|
||||
case "entity_id":
|
||||
return localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return localize("ui.components.entity.entity-attribute-picker.attribute");
|
||||
case "for":
|
||||
return localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.state.for"
|
||||
);
|
||||
default:
|
||||
return localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.numeric_state.${fieldName}` as any
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "entity_id",
|
||||
required: true,
|
||||
selector: { entity: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "attribute",
|
||||
selector: { attribute: {} },
|
||||
context: { filter_entity: "entity_id" },
|
||||
},
|
||||
{
|
||||
name: "above",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "below",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "value_template", selector: { template: {} } },
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-numeric_state")
|
||||
export class HaNumericStateTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -315,24 +372,7 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "entity_id":
|
||||
return this.hass.localize("ui.components.entity.entity-picker.entity");
|
||||
case "attribute":
|
||||
return this.hass.localize(
|
||||
"ui.components.entity.entity-attribute-picker.attribute"
|
||||
);
|
||||
case "for":
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.state.for`
|
||||
);
|
||||
default:
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
|
||||
);
|
||||
}
|
||||
};
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
+23
-4
@@ -12,9 +12,31 @@ import type { PersistentNotificationTrigger } from "../../../../../data/automati
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { TriggerElement } from "../ha-automation-trigger-row";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.persistent_notification.${fieldName}` as any
|
||||
);
|
||||
|
||||
const DEFAULT_UPDATE_TYPES = ["added", "removed"];
|
||||
const DEFAULT_NOTIFICATION_ID = "";
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "notification_id", selector: { text: {} } },
|
||||
{
|
||||
name: "update_type",
|
||||
type: "multi_select" as const,
|
||||
options: [
|
||||
["added", "added"] as const,
|
||||
["removed", "removed"] as const,
|
||||
["current", "current"] as const,
|
||||
["updated", "updated"] as const,
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-persistent_notification")
|
||||
export class HaPersistentNotificationTrigger
|
||||
extends LitElement
|
||||
@@ -99,10 +121,7 @@ export class HaPersistentNotificationTrigger
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.persistent_notification.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -43,6 +43,40 @@ const stateTriggerStruct = assign(
|
||||
})
|
||||
);
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "entity_id",
|
||||
required: true,
|
||||
selector: { entity: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "attribute",
|
||||
selector: { attribute: {} },
|
||||
context: { filter_entity: "entity_id" },
|
||||
},
|
||||
{
|
||||
name: "from",
|
||||
selector: { state: { multiple: true } },
|
||||
context: { filter_entity: "entity_id" },
|
||||
},
|
||||
{
|
||||
name: "to",
|
||||
selector: { state: { multiple: true } },
|
||||
context: { filter_entity: "entity_id" },
|
||||
},
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
fieldName === "entity_id"
|
||||
? "ui.components.entity.entity-picker.entity"
|
||||
: (`ui.panel.config.automation.editor.triggers.type.state.${fieldName}` as any)
|
||||
);
|
||||
|
||||
@customElement("ha-automation-trigger-state")
|
||||
export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -297,12 +331,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
schema.name === "entity_id"
|
||||
? "ui.components.entity.entity-picker.entity"
|
||||
: `ui.panel.config.automation.editor.triggers.type.state.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -9,6 +9,24 @@ import "../../../../../components/ha-form/ha-form";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.sun.${fieldName}` as any
|
||||
);
|
||||
|
||||
export const YAML_SCHEMA = [
|
||||
{
|
||||
name: "event",
|
||||
type: "select" as const,
|
||||
required: true,
|
||||
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
|
||||
},
|
||||
{ name: "offset", selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-sun")
|
||||
export class HaSunTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -73,10 +91,7 @@ export class HaSunTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.sun.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -8,12 +8,21 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { hasTemplate } from "../../../../../common/string/has-template";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
|
||||
const SCHEMA = [
|
||||
export const SCHEMA = [
|
||||
{ name: "value_template", required: true, selector: { template: {} } },
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.template.${fieldName}` as any
|
||||
);
|
||||
|
||||
@customElement("ha-automation-trigger-template")
|
||||
export class HaTemplateTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -76,10 +85,7 @@ export class HaTemplateTrigger extends LitElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.template.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -18,6 +18,37 @@ const MODE_ENTITY = "entity";
|
||||
const VALID_DOMAINS = ["sensor", "input_datetime"];
|
||||
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
|
||||
|
||||
// Real YAML keys for the time trigger (the UI uses time/entity/mode/offset
|
||||
// as an abstraction, but the actual YAML uses `at` and `weekday`).
|
||||
export const YAML_SCHEMA = [
|
||||
{ name: "at", required: true, selector: { text: {} } },
|
||||
{
|
||||
name: "weekday",
|
||||
type: "multi_select" as const,
|
||||
options: DAYS.map((d) => [d, d] as const),
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string => {
|
||||
switch (fieldName) {
|
||||
case "time":
|
||||
return localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.time.at"
|
||||
);
|
||||
case "weekday":
|
||||
return localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.time.weekday"
|
||||
);
|
||||
default:
|
||||
return localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time.${fieldName}` as any
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@customElement("ha-automation-trigger-time")
|
||||
export class HaTimeTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -210,21 +241,7 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "time":
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time.at`
|
||||
);
|
||||
case "weekday":
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time.weekday`
|
||||
);
|
||||
}
|
||||
return this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time.${schema.name}`
|
||||
);
|
||||
};
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -6,13 +6,28 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { TimePatternTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { TriggerElement } from "../ha-automation-trigger-row";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
|
||||
const SCHEMA = [
|
||||
export const SCHEMA = [
|
||||
{ name: "hours", selector: { text: {} } },
|
||||
{ name: "minutes", selector: { text: {} } },
|
||||
{ name: "seconds", selector: { text: {} } },
|
||||
] as const;
|
||||
|
||||
export const computeLabel = (
|
||||
fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time_pattern.${fieldName}` as any
|
||||
);
|
||||
|
||||
export const computeHelper = (
|
||||
_fieldName: string,
|
||||
localize: LocalizeFunc
|
||||
): string =>
|
||||
localize("ui.panel.config.automation.editor.triggers.type.time_pattern.help");
|
||||
|
||||
@customElement("ha-automation-trigger-time_pattern")
|
||||
export class HaTimePatternTrigger extends LitElement implements TriggerElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -47,17 +62,11 @@ export class HaTimePatternTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time_pattern.${schema.name}`
|
||||
);
|
||||
): string => computeLabel(schema.name, this.hass.localize);
|
||||
|
||||
private _computeHelperCallback = (
|
||||
_schema: SchemaUnion<typeof SCHEMA>
|
||||
): string =>
|
||||
this.hass.localize(
|
||||
`ui.panel.config.automation.editor.triggers.type.time_pattern.help`
|
||||
);
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string => computeHelper(schema.name, this.hass.localize);
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,7 @@ import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { resolveMediaSource } from "../../../../data/media_source";
|
||||
import { MatchMinHeightMixin } from "../../../../mixins/match-min-height-mixin";
|
||||
import { withViewTransition } from "../../../../common/util/view-transition";
|
||||
import { serviceActionSchema } from "../../automation/yaml_schema_helpers";
|
||||
|
||||
@customElement("developer-tools-action")
|
||||
class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
@@ -196,6 +197,11 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
id="yaml-editor"
|
||||
.hass=${this.hass}
|
||||
.defaultValue=${this._serviceData}
|
||||
.yamlFieldSchema=${this._yamlFieldSchema(
|
||||
this._serviceData?.action,
|
||||
this.hass.services,
|
||||
this.hass.localize
|
||||
)}
|
||||
@value-changed=${this._yamlChanged}
|
||||
></ha-yaml-editor>
|
||||
</div>`
|
||||
@@ -375,6 +381,20 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
|
||||
fields.filter((field) => !field.selector)
|
||||
);
|
||||
|
||||
private _yamlFieldSchema = memoizeOne(
|
||||
(
|
||||
action: string | undefined,
|
||||
services: HomeAssistant["services"],
|
||||
localize: HomeAssistant["localize"]
|
||||
) => {
|
||||
if (!action) return undefined;
|
||||
const domain = computeDomain(action);
|
||||
const service = computeObjectId(action);
|
||||
if (!domain || !service) return undefined;
|
||||
return serviceActionSchema(domain, service, services, localize);
|
||||
}
|
||||
);
|
||||
|
||||
private _validateServiceData = (
|
||||
serviceData: ServiceAction | undefined,
|
||||
fields,
|
||||
|
||||
@@ -37,7 +37,13 @@ export {
|
||||
search,
|
||||
searchKeymap,
|
||||
} from "@codemirror/search";
|
||||
export { lintGutter, lintKeymap, setDiagnostics } from "@codemirror/lint";
|
||||
export {
|
||||
lintGutter,
|
||||
lintKeymap,
|
||||
setDiagnostics,
|
||||
linter,
|
||||
forceLinting,
|
||||
} from "@codemirror/lint";
|
||||
export { EditorState } from "@codemirror/state";
|
||||
export {
|
||||
crosshairCursor,
|
||||
@@ -76,12 +82,23 @@ export {
|
||||
JINJA_FUNCTION_ARG_TYPES,
|
||||
} from "./jinja_ha_completions";
|
||||
export type { HassArgHoverContext, JinjaArgType } from "./jinja_ha_completions";
|
||||
export {
|
||||
haYamlCompletionSource,
|
||||
haYamlHoverSource,
|
||||
haYamlLintSource,
|
||||
} from "./yaml_ha_completions";
|
||||
export type {
|
||||
HaYamlCompletionContext,
|
||||
HaYamlHoverContext,
|
||||
} from "./yaml_ha_completions";
|
||||
export type { YamlFieldSchemaMap, YamlFieldSchema } from "./yaml_field_schema";
|
||||
export { closePercentBrace };
|
||||
|
||||
export const langCompartment = new Compartment();
|
||||
export const readonlyCompartment = new Compartment();
|
||||
export const linewrapCompartment = new Compartment();
|
||||
export const yamlLintCompartment = new Compartment();
|
||||
export const yamlSchemaCompartment = new Compartment();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML scalar type highlighter
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Shared CodeMirror completion-item builders for HA entity / device / area
|
||||
* selectors.
|
||||
*
|
||||
* Used by both the Jinja template editor (`ha-code-editor.ts`) and the YAML
|
||||
* field-schema editor (`yaml_ha_completions.ts`) so the two always produce
|
||||
* identical completion items for the same HA registry data.
|
||||
*
|
||||
* Each builder follows the same convention:
|
||||
* label — "friendly name + ID" concatenated so filtering works on both
|
||||
* displayLabel — only the friendly name (what the user actually sees)
|
||||
* detail — the raw ID (shown as secondary text)
|
||||
* apply — the raw ID (what gets inserted)
|
||||
*/
|
||||
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../common/entity/compute_device_name";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import type { AreaRegistryEntry } from "../data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../data/device/device_registry";
|
||||
import type { FloorRegistryEntry } from "../data/floor_registry";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
|
||||
/**
|
||||
* Build completion items for entity IDs.
|
||||
* `label` is "friendly name + entity_id" for search; `displayLabel` shows only
|
||||
* the entity_id; `detail` shows the friendly name.
|
||||
*/
|
||||
export function buildEntityCompletions(states: HassEntities): Completion[] {
|
||||
return Object.keys(states).map((entityId) => {
|
||||
const friendlyName = states[entityId].attributes.friendly_name as
|
||||
| string
|
||||
| undefined;
|
||||
return {
|
||||
type: "variable",
|
||||
label: friendlyName ? `${friendlyName} ${entityId}` : entityId,
|
||||
displayLabel: entityId,
|
||||
detail: friendlyName,
|
||||
apply: entityId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build completion items for device IDs.
|
||||
* `label` is "name + id" for search; `displayLabel` shows only the name;
|
||||
* `detail` shows the device ID.
|
||||
*/
|
||||
export function buildDeviceCompletions(
|
||||
devices: Record<string, DeviceRegistryEntry>
|
||||
): Completion[] {
|
||||
return Object.values(devices)
|
||||
.filter((device) => !device.disabled_by)
|
||||
.map((device) => {
|
||||
const name = computeDeviceName(device) ?? device.id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${device.id}`,
|
||||
displayLabel: name,
|
||||
detail: device.id,
|
||||
apply: device.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build completion items for area IDs.
|
||||
* `label` is "name + area_id" for search; `displayLabel` shows only the name;
|
||||
* `detail` shows the area ID.
|
||||
*/
|
||||
export function buildAreaCompletions(
|
||||
areas: Record<string, AreaRegistryEntry>
|
||||
): Completion[] {
|
||||
return Object.values(areas).map((area) => {
|
||||
const name = computeAreaName(area) ?? area.area_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${area.area_id}`,
|
||||
displayLabel: name,
|
||||
detail: area.area_id,
|
||||
apply: area.area_id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build completion items for floor IDs.
|
||||
* `label` is "name + floor_id" for search; `displayLabel` shows only the name;
|
||||
* `detail` shows the floor ID.
|
||||
*/
|
||||
export function buildFloorCompletions(
|
||||
floors: Record<string, FloorRegistryEntry>
|
||||
): Completion[] {
|
||||
return Object.values(floors).map((floor) => {
|
||||
const name = computeFloorName(floor) ?? floor.floor_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${floor.floor_id}`,
|
||||
displayLabel: name,
|
||||
detail: floor.floor_id,
|
||||
apply: floor.floor_id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build completion items for label IDs.
|
||||
* `label` is "name + label_id" for search; `displayLabel` shows only the name;
|
||||
* `detail` shows the label ID.
|
||||
*/
|
||||
export function buildLabelCompletions(
|
||||
labels: LabelRegistryEntry[]
|
||||
): Completion[] {
|
||||
return labels.map((label) => ({
|
||||
type: "variable",
|
||||
label: `${label.name} ${label.label_id}`,
|
||||
displayLabel: label.name,
|
||||
detail: label.label_id,
|
||||
apply: label.label_id,
|
||||
}));
|
||||
}
|
||||
@@ -1952,7 +1952,7 @@ function buildTooltipDom(
|
||||
* Returns null when no hass context is available or the value can't be resolved.
|
||||
* `siblingEntityId` is only used for `attribute` arg types.
|
||||
*/
|
||||
function buildArgTooltipDom(
|
||||
export function buildArgTooltipDom(
|
||||
argType: JinjaArgType,
|
||||
value: string,
|
||||
ctx: HassArgHoverContext,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Selector } from "../data/selector";
|
||||
|
||||
/**
|
||||
* Describes a single field in a YAML schema used for editor assistance
|
||||
* (completions, hover tooltips, and linting) in ha-yaml-editor / ha-code-editor.
|
||||
*
|
||||
* This is intentionally kept separate from superstruct structs (which are
|
||||
* runtime-validation only) and from ha-form schemas (which drive form UI).
|
||||
* It maps closely to the shape of TriggerDescription.fields,
|
||||
* ConditionDescription.fields, and HassService.fields so the automation editor
|
||||
* can forward those descriptions into the YAML editor with minimal conversion.
|
||||
*/
|
||||
export interface YamlFieldSchema {
|
||||
/** Human-readable description shown in the hover tooltip. */
|
||||
description?: string;
|
||||
/**
|
||||
* Selector driving value completions and type hints. When present,
|
||||
* the completion source will offer relevant value suggestions based on
|
||||
* the selector type (boolean → true/false, select → option list, etc.).
|
||||
*/
|
||||
selector?: Selector;
|
||||
/** Whether the field is required (shown in the hover tooltip). */
|
||||
required?: boolean;
|
||||
/** Example value shown in the hover tooltip. */
|
||||
example?: unknown;
|
||||
/** Default value shown in the hover tooltip. */
|
||||
default?: unknown;
|
||||
/**
|
||||
* Nested field schema for object/mapping values. When set, drilling
|
||||
* into this key's value will offer the nested fields as completions.
|
||||
*/
|
||||
fields?: YamlFieldSchemaMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* A map of YAML key → field schema. Passed to ha-yaml-editor / ha-code-editor.
|
||||
*
|
||||
* The optional `__allowUnknownFields` marker suppresses "unknown field"
|
||||
* lint warnings for this mapping level. Use it for schemas where the full
|
||||
* set of valid keys is not statically known (e.g. device triggers/conditions/
|
||||
* actions that accept integration-specific fields).
|
||||
*
|
||||
* Note: `__allowUnknownFields` is set as a non-enumerable property so it does
|
||||
* not appear in Object.keys() / Object.entries() iteration used for completions
|
||||
* and required-field checks.
|
||||
*/
|
||||
export type YamlFieldSchemaMap = Record<string, YamlFieldSchema>;
|
||||
|
||||
/**
|
||||
* Mark a `YamlFieldSchemaMap` so that the linter does not warn about unknown
|
||||
* keys at this mapping level. Returns the same object for convenience.
|
||||
*/
|
||||
export function allowUnknownFields(
|
||||
map: YamlFieldSchemaMap
|
||||
): YamlFieldSchemaMap {
|
||||
Object.defineProperty(map, "__allowUnknownFields", {
|
||||
value: true,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
configurable: false,
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Return true if the map was marked via `allowUnknownFields()`. */
|
||||
export function hasAllowUnknownFields(map: YamlFieldSchemaMap): boolean {
|
||||
return (map as any).__allowUnknownFields === true;
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
/**
|
||||
* CodeMirror completion source and hover tooltip source for field-aware YAML
|
||||
* editing in the automation/script/card YAML editors.
|
||||
*
|
||||
* Given a `YamlFieldSchemaMap` describing the valid keys (and their selectors,
|
||||
* descriptions, etc.), this module provides:
|
||||
*
|
||||
* - `haYamlCompletionSource` — key completions at the current indent level
|
||||
* plus value completions driven by the selector.
|
||||
* - `haYamlHoverSource` — a `hoverTooltip` callback that shows field
|
||||
* description, required status and example on hover.
|
||||
*
|
||||
* The module is intentionally free of Lit/HA runtime imports so it can be
|
||||
* consumed in the same lazy code-split chunk as ha-code-editor.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Completion,
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { EditorView, Tooltip } from "@codemirror/view";
|
||||
import type { SyntaxNode } from "@lezer/common";
|
||||
import { NodeProp } from "@lezer/common";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { AreaRegistryEntry } from "../data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../data/device/device_registry";
|
||||
import type { FloorRegistryEntry } from "../data/floor_registry";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import {
|
||||
buildAreaCompletions,
|
||||
buildDeviceCompletions,
|
||||
buildEntityCompletions,
|
||||
buildFloorCompletions,
|
||||
buildLabelCompletions,
|
||||
} from "./ha_completion_items";
|
||||
import {
|
||||
buildArgTooltipDom,
|
||||
type HassArgHoverContext,
|
||||
} from "./jinja_ha_completions";
|
||||
import "../components/ha-code-editor-jinja-arg-hover";
|
||||
import type { YamlFieldSchema, YamlFieldSchemaMap } from "./yaml_field_schema";
|
||||
import { hasAllowUnknownFields } from "./yaml_field_schema";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers – YAML syntax tree traversal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns the text content of a syntax node.
|
||||
*/
|
||||
function nodeText(node: SyntaxNode, doc: string): string {
|
||||
return doc.slice(node.from, node.to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the field schema for a given key path through a nested schema map.
|
||||
* Returns `undefined` if the path is not found.
|
||||
*/
|
||||
function resolveFieldSchema(
|
||||
schema: YamlFieldSchemaMap,
|
||||
path: string[]
|
||||
): YamlFieldSchema | undefined {
|
||||
if (path.length === 0) return undefined;
|
||||
const [head, ...rest] = path;
|
||||
const field = schema[head];
|
||||
if (!field) return undefined;
|
||||
if (rest.length === 0) return field;
|
||||
if (field.fields) return resolveFieldSchema(field.fields, rest);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Value completions driven by selector type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** validFor pattern for value completions — matches any typed text. */
|
||||
const VALUE_VALID_FOR = /^.*$/;
|
||||
|
||||
function valueCompletionsForSelector(
|
||||
field: YamlFieldSchema,
|
||||
ctx: HaYamlCompletionContext
|
||||
): Completion[] | null {
|
||||
const { selector } = field;
|
||||
if (!selector) return null;
|
||||
const type = Object.keys(selector)[0] as string;
|
||||
|
||||
if (type === "boolean") {
|
||||
return [
|
||||
{ label: "true", type: "keyword" },
|
||||
{ label: "false", type: "keyword" },
|
||||
];
|
||||
}
|
||||
|
||||
if (type === "select") {
|
||||
const opts = (selector as any).select?.options;
|
||||
if (Array.isArray(opts)) {
|
||||
return opts.map((o: any) => ({
|
||||
label: typeof o === "object" ? String(o.value ?? o) : String(o),
|
||||
type: "enum",
|
||||
detail: typeof o === "object" && o.label ? o.label : undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "entity" && ctx.states) {
|
||||
return buildEntityCompletions(ctx.states);
|
||||
}
|
||||
|
||||
if (type === "device" && ctx.devices) {
|
||||
return buildDeviceCompletions(ctx.devices);
|
||||
}
|
||||
|
||||
if (type === "area" && ctx.areas) {
|
||||
return buildAreaCompletions(ctx.areas);
|
||||
}
|
||||
|
||||
if (type === "floor" && ctx.floors) {
|
||||
return buildFloorCompletions(ctx.floors);
|
||||
}
|
||||
|
||||
if (type === "label" && ctx.labels) {
|
||||
return buildLabelCompletions(ctx.labels);
|
||||
}
|
||||
|
||||
if (type === "template") {
|
||||
return [
|
||||
{ label: "{{ }}", type: "text", detail: "Jinja2 template" },
|
||||
{ label: "{% %}", type: "text", detail: "Jinja2 block" },
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Completion source
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HaYamlCompletionContext {
|
||||
/** The current field schema map. */
|
||||
schema: YamlFieldSchemaMap;
|
||||
/** Optional entity states for EntitySelector completions. */
|
||||
states?: HassEntities;
|
||||
/** Optional device registry for DeviceSelector completions. */
|
||||
devices?: Record<string, DeviceRegistryEntry>;
|
||||
/** Optional area registry for AreaSelector completions. */
|
||||
areas?: Record<string, AreaRegistryEntry>;
|
||||
/** Optional floor registry for FloorSelector completions. */
|
||||
floors?: Record<string, FloorRegistryEntry>;
|
||||
/** Optional label registry for LabelSelector completions. */
|
||||
labels?: LabelRegistryEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a CodeMirror `CompletionSource` bound to the given field schema.
|
||||
*
|
||||
* Call this once per editor instance / schema change and pass the result to
|
||||
* `autocompletion({ override: [..., haYamlCompletionSource(ctx)] })`.
|
||||
*/
|
||||
export function haYamlCompletionSource(
|
||||
ctx: HaYamlCompletionContext
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
return (context: CompletionContext): CompletionResult | null => {
|
||||
const { state, pos } = context;
|
||||
const doc = state.doc.toString();
|
||||
|
||||
const tree = syntaxTree(state);
|
||||
const node = tree.resolveInner(pos, -1);
|
||||
|
||||
// ---- SEQUENCE ITEM position: cursor is inside a list item ---------------
|
||||
// Lezer YAML: Pair → BlockSequence → Item → Literal
|
||||
if (
|
||||
node.name === "Literal" &&
|
||||
node.parent?.name === "Item" &&
|
||||
node.parent?.parent?.name === "BlockSequence"
|
||||
) {
|
||||
const seqNode = node.parent.parent; // BlockSequence
|
||||
const pairNode = seqNode.parent; // Pair
|
||||
if (pairNode?.name === "Pair") {
|
||||
const keyNode = pairNode.getChild("Key");
|
||||
const keyLit = keyNode?.firstChild ?? keyNode;
|
||||
if (keyLit) {
|
||||
const keyText = nodeText(keyLit, doc);
|
||||
const ancestorPath = getAncestorKeyPath(pairNode.parent, doc);
|
||||
const fullPath = [...ancestorPath, keyText];
|
||||
const field = resolveFieldSchema(ctx.schema, fullPath);
|
||||
if (field) {
|
||||
const completions = valueCompletionsForSelector(field, ctx);
|
||||
if (completions) {
|
||||
return {
|
||||
options: completions,
|
||||
from: node.from,
|
||||
validFor: VALUE_VALID_FOR,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- EMPTY SEQUENCE ITEM: cursor after "- " with no Literal yet ----------
|
||||
if (node.name === "-" && node.parent?.name === "BlockSequence") {
|
||||
// Only fire when cursor is strictly past the dash (space has been typed).
|
||||
if (pos <= node.to) return null;
|
||||
const seqNode = node.parent;
|
||||
const pairNode = seqNode.parent;
|
||||
if (pairNode?.name === "Pair") {
|
||||
const keyNode = pairNode.getChild("Key");
|
||||
const keyLit = keyNode?.firstChild ?? keyNode;
|
||||
if (keyLit) {
|
||||
const keyText = nodeText(keyLit, doc);
|
||||
const ancestorPath = getAncestorKeyPath(pairNode.parent, doc);
|
||||
const fullPath = [...ancestorPath, keyText];
|
||||
const field = resolveFieldSchema(ctx.schema, fullPath);
|
||||
if (field) {
|
||||
const completions = valueCompletionsForSelector(field, ctx);
|
||||
if (completions) {
|
||||
return {
|
||||
options: completions,
|
||||
from: pos,
|
||||
validFor: VALUE_VALID_FOR,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- VALUE position: cursor is in a Literal value of a Pair --------------
|
||||
// Lezer YAML: Pair → Key, ":", Literal(value)
|
||||
if (node.name === "Literal" && node.parent?.name === "Pair") {
|
||||
const pair = node.parent;
|
||||
const keyNode = pair.getChild("Key");
|
||||
if (keyNode) {
|
||||
const keyLit = keyNode.firstChild ?? keyNode;
|
||||
const keyText = nodeText(keyLit, doc);
|
||||
const ancestorPath = getAncestorKeyPath(pair.parent, doc);
|
||||
const fullPath = [...ancestorPath, keyText];
|
||||
const field = resolveFieldSchema(ctx.schema, fullPath);
|
||||
if (field) {
|
||||
// If this field has sub-fields (nested mapping), the Literal is
|
||||
// actually the first key being typed — offer key completions from
|
||||
// the sub-schema rather than value completions.
|
||||
if (field.fields && Object.keys(field.fields).length > 0) {
|
||||
const word = context.matchBefore(/[\w_-]*/);
|
||||
const fromPos = word ? word.from : pos;
|
||||
const completions: Completion[] = Object.entries(field.fields).map(
|
||||
([key, subField]) => ({
|
||||
label: key,
|
||||
type: "yaml-key",
|
||||
detail: subField.required ? "required" : undefined,
|
||||
info: subField.description,
|
||||
apply: buildKeyApply(key, subField),
|
||||
boost: subField.required ? 10 : 0,
|
||||
})
|
||||
);
|
||||
if (completions.length === 0) return null;
|
||||
return {
|
||||
options: completions,
|
||||
from: fromPos,
|
||||
validFor: /^[\w_-]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
const completions = valueCompletionsForSelector(field, ctx);
|
||||
if (completions) {
|
||||
const valueLiteral =
|
||||
pair
|
||||
.getChildren("Literal")
|
||||
.find((n) => n !== keyNode.firstChild && n !== keyNode) ?? null;
|
||||
const from = valueLiteral ? valueLiteral.from : pos;
|
||||
return { options: completions, from, validFor: VALUE_VALID_FOR };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- EMPTY VALUE: cursor is right after "key: " with no value yet --------
|
||||
// When there's no value Literal, lezer puts the cursor on the ":" node
|
||||
// inside the Pair, or on the Pair itself just after the colon.
|
||||
if (node.name === ":" && node.parent?.name === "Pair") {
|
||||
const pair = node.parent;
|
||||
// Only fire when cursor is strictly past the colon (i.e. at least one
|
||||
// space has been typed), so we don't insert right after "key:".
|
||||
if (pos > node.to) {
|
||||
const keyNode = pair.getChild("Key");
|
||||
if (keyNode) {
|
||||
const keyLit = keyNode.firstChild ?? keyNode;
|
||||
const keyText = nodeText(keyLit, doc);
|
||||
const ancestorPath = getAncestorKeyPath(pair.parent, doc);
|
||||
const fullPath = [...ancestorPath, keyText];
|
||||
const field = resolveFieldSchema(ctx.schema, fullPath);
|
||||
if (field) {
|
||||
// Nested mapping field — offer key completions from sub-schema.
|
||||
if (field.fields && Object.keys(field.fields).length > 0) {
|
||||
const completions: Completion[] = Object.entries(
|
||||
field.fields
|
||||
).map(([key, subField]) => ({
|
||||
label: key,
|
||||
type: "yaml-key",
|
||||
detail: subField.required ? "required" : undefined,
|
||||
info: subField.description,
|
||||
apply: buildKeyApply(key, subField),
|
||||
boost: subField.required ? 10 : 0,
|
||||
}));
|
||||
if (completions.length > 0) {
|
||||
return { options: completions, from: pos };
|
||||
}
|
||||
}
|
||||
const completions = valueCompletionsForSelector(field, ctx);
|
||||
if (completions) {
|
||||
return {
|
||||
options: completions,
|
||||
from: pos,
|
||||
validFor: VALUE_VALID_FOR,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- KEY position: cursor is on a Key Literal or start of a new Pair ----
|
||||
// Determine which schema level to offer completions at.
|
||||
let schemaLevel: YamlFieldSchemaMap = ctx.schema;
|
||||
// Find the BlockMapping we are inside.
|
||||
let keyLiteralNode: SyntaxNode | null = null;
|
||||
|
||||
// Are we inside a Key node?
|
||||
if (node.name === "Literal" && node.parent?.name === "Key") {
|
||||
keyLiteralNode = node;
|
||||
} else if (node.name === "Key") {
|
||||
// cursor is directly on a Key node, no literal node to pin
|
||||
}
|
||||
|
||||
// Guard: walk up from the cursor node. If we pass through a node that is
|
||||
// a non-BlockMapping value child of a Pair (scalar Literal, FlowSequence,
|
||||
// FlowMapping, or a "," / "[" / "]" inside a flow node), the cursor is in
|
||||
// a value position — do not offer key completions.
|
||||
{
|
||||
let n: SyntaxNode | null = keyLiteralNode ?? node;
|
||||
while (n) {
|
||||
const p = n.parent;
|
||||
if (p?.name === "Pair") {
|
||||
// n is a direct child of a Pair. If it is NOT a Key, it is value-side.
|
||||
if (n.name !== "Key" && n.name !== ":") {
|
||||
// BlockMapping / BlockSequence as value means nested keys — OK.
|
||||
if (n.name !== "BlockMapping" && n.name !== "BlockSequence") {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inside any flow node (FlowSequence, FlowMapping) → value position.
|
||||
if (
|
||||
n.name === "FlowSequence" ||
|
||||
n.name === "FlowMapping" ||
|
||||
n.name === "," ||
|
||||
n.name === "[" ||
|
||||
n.name === "]" ||
|
||||
n.name === "{" ||
|
||||
n.name === "}"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
n = p;
|
||||
}
|
||||
}
|
||||
|
||||
// Also guard: cursor is past end of an inner BlockMapping (e.g. "brightness: |"
|
||||
// where the inner BM ends at the colon). Find the deepest Pair whose ":"
|
||||
// is on the cursor line and which has no block value — that means cursor is
|
||||
// in a scalar value gap.
|
||||
// Also handles: empty sequence item "- |" where cursor is past the BlockSequence
|
||||
// end — find the "-" on the cursor line by text-scanning, then locate it in
|
||||
// the syntax tree to determine the field and return value completions.
|
||||
{
|
||||
const curLine = state.doc.lineAt(pos);
|
||||
const lineText = state.doc.sliceString(curLine.from, curLine.to);
|
||||
// Only proceed if the line looks like a sequence item (optional spaces + "- ")
|
||||
const dashMatch = /^(\s*)-(\s*)$/.exec(lineText);
|
||||
if (dashMatch && pos > curLine.from + dashMatch[1].length) {
|
||||
// Find the "-" node in the tree by resolving at its text position.
|
||||
const dashPos = curLine.from + dashMatch[1].length;
|
||||
const dashNode = syntaxTree(state).resolveInner(dashPos, 1);
|
||||
// Walk up to find the BlockSequence → Pair → field schema.
|
||||
let n: SyntaxNode | null = dashNode;
|
||||
while (n) {
|
||||
if (n.name === "BlockSequence") {
|
||||
const pairNode2 = n.parent;
|
||||
if (pairNode2?.name === "Pair") {
|
||||
const keyNode2 = pairNode2.getChild("Key");
|
||||
const keyLit2 = keyNode2?.firstChild ?? keyNode2;
|
||||
if (keyLit2) {
|
||||
const keyText2 = nodeText(keyLit2, doc);
|
||||
const ancestorPath2 = getAncestorKeyPath(pairNode2.parent, doc);
|
||||
const field2 = resolveFieldSchema(ctx.schema, [
|
||||
...ancestorPath2,
|
||||
keyText2,
|
||||
]);
|
||||
if (field2) {
|
||||
const completions = valueCompletionsForSelector(field2, ctx);
|
||||
if (completions) {
|
||||
return {
|
||||
options: completions,
|
||||
from: pos,
|
||||
validFor: VALUE_VALID_FOR,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
n = n.parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- SCALAR VALUE GAP: cursor is after "key: " but lezer has no Literal ----
|
||||
// Resolve from the start of the cursor line to find the Pair whose key
|
||||
// is on this line. Walking up from `pos` can land inside a sibling node's
|
||||
// BlockSequence when the previous field has list items, causing us to
|
||||
// miss the current Pair entirely.
|
||||
{
|
||||
const curLine2 = state.doc.lineAt(pos);
|
||||
const lineText2 = state.doc.sliceString(curLine2.from, curLine2.to);
|
||||
// Only proceed if line looks like " key: " (optional spaces, a key, colon, optional space)
|
||||
// and NOT a sequence item ("- ").
|
||||
const keyColonMatch = /^(\s*)[\w_-]+\s*:\s*$/.test(lineText2);
|
||||
if (keyColonMatch) {
|
||||
// Resolve a node at the line start to find the Pair for this key.
|
||||
const lineStartNode = syntaxTree(state).resolveInner(
|
||||
curLine2.from + lineText2.search(/\S/),
|
||||
1
|
||||
);
|
||||
let n: SyntaxNode | null = lineStartNode;
|
||||
while (n) {
|
||||
if (n.name === "Pair") {
|
||||
const colon = n.getChild(":");
|
||||
if (
|
||||
colon &&
|
||||
state.doc.lineAt(colon.from).number === curLine2.number &&
|
||||
pos > colon.to
|
||||
) {
|
||||
const hasBlockValue =
|
||||
n.getChild("BlockMapping") !== null ||
|
||||
n.getChild("BlockSequence") !== null ||
|
||||
n.getChild("FlowSequence") !== null ||
|
||||
n.getChild("FlowMapping") !== null;
|
||||
const hasScalarValue = n.getChildren("Literal").length > 1;
|
||||
if (!hasBlockValue && !hasScalarValue) {
|
||||
// Cursor is in scalar value gap (e.g. "area_id: |").
|
||||
const keyNode2 = n.getChild("Key");
|
||||
const keyLit2 = keyNode2?.firstChild ?? keyNode2;
|
||||
if (keyLit2) {
|
||||
const keyText2 = nodeText(keyLit2, doc);
|
||||
const ancestorPath2 = getAncestorKeyPath(n.parent, doc);
|
||||
const field2 = resolveFieldSchema(ctx.schema, [
|
||||
...ancestorPath2,
|
||||
keyText2,
|
||||
]);
|
||||
if (field2) {
|
||||
const completions2 = valueCompletionsForSelector(
|
||||
field2,
|
||||
ctx
|
||||
);
|
||||
if (completions2) {
|
||||
return {
|
||||
options: completions2,
|
||||
from: pos,
|
||||
validFor: VALUE_VALID_FOR,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
break; // found the Pair for this line, stop searching
|
||||
}
|
||||
n = n.parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Walk up to find which BlockMapping this key belongs to.
|
||||
// When the cursor is on an empty/blank line, resolveInner returns a parent
|
||||
// BlockMapping rather than the inner one we're actually inside.
|
||||
// Strategy: find the indentation of the cursor line, then look backwards
|
||||
// for the nearest non-empty line at a *greater* indent — that line's first
|
||||
// char resolves into the inner BlockMapping we want.
|
||||
let bmNode: SyntaxNode | null = null;
|
||||
let pairNode: SyntaxNode | null = null;
|
||||
{
|
||||
const curLine = state.doc.lineAt(pos);
|
||||
const lineText = state.doc.sliceString(curLine.from, curLine.to);
|
||||
const curIndent = lineText.search(/\S/);
|
||||
|
||||
let resolvePos: number;
|
||||
if (curIndent >= 0) {
|
||||
// Line has content — resolve from its first non-space char.
|
||||
resolvePos = curLine.from + curIndent;
|
||||
} else {
|
||||
// Empty/blank line — scan backwards for a line with greater indent
|
||||
// (i.e. a sibling or child line that's already inside the same block).
|
||||
resolvePos = pos; // fallback
|
||||
for (let ln = curLine.number - 1; ln >= 1; ln--) {
|
||||
const prevLine = state.doc.line(ln);
|
||||
const prevText = state.doc.sliceString(prevLine.from, prevLine.to);
|
||||
const prevIndent = prevText.search(/\S/);
|
||||
if (prevIndent < 0) continue; // skip blank lines
|
||||
// A line with more indentation is inside the same or deeper block.
|
||||
if (prevIndent > 0) {
|
||||
resolvePos = prevLine.from + prevIndent;
|
||||
break;
|
||||
}
|
||||
// Hit a root-level line — we're at root level.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const resolveNode = syntaxTree(state).resolveInner(resolvePos, -1);
|
||||
let n: SyntaxNode | null = keyLiteralNode ?? resolveNode;
|
||||
while (n) {
|
||||
if (n.name === "Pair") pairNode = n;
|
||||
if (n.name === "BlockMapping") {
|
||||
bmNode = n;
|
||||
break;
|
||||
}
|
||||
n = n.parent;
|
||||
}
|
||||
}
|
||||
|
||||
if (bmNode) {
|
||||
// Build the path of ancestor keys above this BlockMapping.
|
||||
const ancestorPath = getAncestorKeyPath(bmNode, doc);
|
||||
if (ancestorPath.length > 0) {
|
||||
const parentField = resolveFieldSchema(ctx.schema, ancestorPath);
|
||||
schemaLevel = parentField?.fields ?? {};
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(schemaLevel).length === 0) return null;
|
||||
|
||||
// Determine what has already been typed for this key.
|
||||
const word = context.matchBefore(/[\w_-]*/);
|
||||
if (!word && !context.explicit) return null;
|
||||
const fromPos = word ? word.from : pos;
|
||||
|
||||
// Exclude keys already present in the current mapping.
|
||||
const alreadyUsed = new Set<string>();
|
||||
if (bmNode) {
|
||||
let c = bmNode.firstChild;
|
||||
while (c) {
|
||||
if (c.name === "Pair" && c !== pairNode) {
|
||||
const k = c.getChild("Key");
|
||||
const lit = k?.firstChild ?? k;
|
||||
if (lit) alreadyUsed.add(nodeText(lit, doc));
|
||||
}
|
||||
c = c.nextSibling;
|
||||
}
|
||||
}
|
||||
|
||||
const completions: Completion[] = Object.entries(schemaLevel)
|
||||
.filter(([key]) => !alreadyUsed.has(key))
|
||||
.map(([key, field]) => ({
|
||||
label: key,
|
||||
type: "yaml-key",
|
||||
detail: field.required ? "required" : undefined,
|
||||
info: field.description,
|
||||
// Insert "key: " or "key:\n " depending on selector type
|
||||
apply: buildKeyApply(key, field),
|
||||
boost: field.required ? 10 : 0,
|
||||
}));
|
||||
|
||||
if (completions.length === 0) return null;
|
||||
return { options: completions, from: fromPos, validFor: /^[\w_-]*$/ };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the text to insert when a key completion is accepted.
|
||||
* For object/sequence selectors we add a newline; for simple values "key: ".
|
||||
*/
|
||||
function buildKeyApply(key: string, field: YamlFieldSchema): string {
|
||||
const type = field.selector ? Object.keys(field.selector)[0] : null;
|
||||
if (type === "object" || type === "action" || type === "condition") {
|
||||
return `${key}:\n `;
|
||||
}
|
||||
if (field.fields && Object.keys(field.fields).length > 0) {
|
||||
return `${key}:\n `;
|
||||
}
|
||||
return `${key}: `;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk up from a node (expected to be a BlockMapping) and collect the
|
||||
* sequence of Pair keys that enclose it.
|
||||
*/
|
||||
function getAncestorKeyPath(bm: SyntaxNode | null, doc: string): string[] {
|
||||
const path: string[] = [];
|
||||
let cur: SyntaxNode | null = bm;
|
||||
while (cur) {
|
||||
if (cur.name === "Pair") {
|
||||
const keyNode = cur.getChild("Key");
|
||||
const lit = keyNode?.firstChild ?? keyNode;
|
||||
if (lit) path.unshift(nodeText(lit, doc));
|
||||
}
|
||||
cur = cur.parent;
|
||||
}
|
||||
return path.filter(Boolean);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hover tooltip source
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HaYamlHoverContext {
|
||||
/** The current field schema map. */
|
||||
schema: YamlFieldSchemaMap;
|
||||
/**
|
||||
* Optional localise callback used to translate field names/descriptions
|
||||
* that are i18n keys. When absent, the raw string is displayed.
|
||||
*/
|
||||
localize?: (key: string, ...args: unknown[]) => string;
|
||||
/** Optional HA context for rich entity/device/area value tooltips. */
|
||||
hassContext?: HassArgHoverContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a `hoverTooltip` callback. Register it via:
|
||||
*
|
||||
* hoverTooltip((view, pos) => haYamlHoverSource(view, pos, ctx))
|
||||
*/
|
||||
export function haYamlHoverSource(
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
ctx: HaYamlHoverContext
|
||||
): Tooltip | null {
|
||||
const doc = view.state.doc.toString();
|
||||
const tree = syntaxTree(view.state);
|
||||
const node = tree.resolveInner(pos, -1);
|
||||
|
||||
// ---- Value hover: entity/device/area Literal in a Pair value or list item --
|
||||
if (ctx.hassContext && node.name === "Literal") {
|
||||
// Resolve the Pair that owns this value — either directly (scalar value)
|
||||
// or via BlockSequence → Item (list item).
|
||||
let pair: SyntaxNode | null = null;
|
||||
if (node.parent?.name === "Pair") {
|
||||
// scalar value: Literal is a direct child of Pair (not the Key)
|
||||
const keyNode = node.parent.getChild("Key");
|
||||
const keyLit2 = keyNode?.firstChild ?? keyNode;
|
||||
if (keyLit2 && node !== keyLit2 && node.from !== keyLit2.from) {
|
||||
pair = node.parent;
|
||||
}
|
||||
} else if (
|
||||
node.parent?.name === "Item" &&
|
||||
node.parent.parent?.name === "BlockSequence" &&
|
||||
node.parent.parent.parent?.name === "Pair"
|
||||
) {
|
||||
// list item: Literal → Item → BlockSequence → Pair
|
||||
pair = node.parent.parent.parent;
|
||||
}
|
||||
|
||||
if (pair) {
|
||||
const keyNode = pair.getChild("Key");
|
||||
const keyLit2 = keyNode?.firstChild ?? keyNode;
|
||||
if (keyLit2) {
|
||||
const keyText2 = nodeText(keyLit2, doc);
|
||||
const ancestorPath2 = getAncestorKeyPath(pair.parent, doc);
|
||||
const field2 = resolveFieldSchema(ctx.schema, [
|
||||
...ancestorPath2,
|
||||
keyText2,
|
||||
]);
|
||||
if (field2?.selector) {
|
||||
const selectorType = Object.keys(field2.selector)[0];
|
||||
const argType =
|
||||
selectorType === "entity"
|
||||
? "entity_id"
|
||||
: selectorType === "device"
|
||||
? "device_id"
|
||||
: selectorType === "area"
|
||||
? "area_id"
|
||||
: selectorType === "floor"
|
||||
? "floor_id"
|
||||
: selectorType === "label"
|
||||
? "label_id"
|
||||
: null;
|
||||
if (argType) {
|
||||
const value = nodeText(node, doc);
|
||||
const dom = buildArgTooltipDom(
|
||||
argType as any,
|
||||
value,
|
||||
ctx.hassContext
|
||||
);
|
||||
if (dom) {
|
||||
return {
|
||||
pos: node.from,
|
||||
end: node.to,
|
||||
above: true,
|
||||
create: () => ({ dom }),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Key hover: show field name, description, type, example, default ----
|
||||
let keyLit: SyntaxNode | null = null;
|
||||
if (node.name === "Literal" && node.parent?.name === "Key") {
|
||||
keyLit = node;
|
||||
} else if (node.name === "Key") {
|
||||
keyLit = node.firstChild;
|
||||
}
|
||||
if (!keyLit) return null;
|
||||
|
||||
const keyText = nodeText(keyLit, doc);
|
||||
if (!keyText) return null;
|
||||
|
||||
// Build the path from ancestor BlockMappings.
|
||||
const pairNode = keyLit.parent?.parent; // Literal → Key → Pair
|
||||
const bmNode = pairNode?.parent; // Pair → BlockMapping
|
||||
const ancestorPath = getAncestorKeyPath(bmNode ?? null, doc);
|
||||
const fullPath = [...ancestorPath, keyText];
|
||||
|
||||
const field = resolveFieldSchema(ctx.schema, fullPath);
|
||||
if (!field) return null;
|
||||
|
||||
return {
|
||||
pos: keyLit.from,
|
||||
end: keyLit.to,
|
||||
above: true,
|
||||
create() {
|
||||
const dom = document.createElement("ha-code-editor-yaml-hover");
|
||||
(dom as any).fieldName = keyText;
|
||||
(dom as any).fieldSchema = field;
|
||||
if (ctx.localize) (dom as any).localize = ctx.localize;
|
||||
return { dom };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Linting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Diagnostic {
|
||||
from: number;
|
||||
to: number;
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces lint diagnostics for a YAML document given a field schema.
|
||||
*
|
||||
* Currently checks:
|
||||
* - Unknown keys (warning)
|
||||
* - Required keys that are missing (error — at document level only for now)
|
||||
*/
|
||||
export function haYamlLintSource(
|
||||
view: EditorView,
|
||||
schema: YamlFieldSchemaMap
|
||||
): Diagnostic[] {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const doc = view.state.doc.toString();
|
||||
function lintMapping(
|
||||
bmNode: SyntaxNode,
|
||||
schemaLevel: YamlFieldSchemaMap
|
||||
): void {
|
||||
const presentKeys = new Set<string>();
|
||||
|
||||
let child = bmNode.firstChild;
|
||||
while (child) {
|
||||
if (child.name === "Pair") {
|
||||
const keyNode = child.getChild("Key");
|
||||
const lit = keyNode?.firstChild ?? keyNode;
|
||||
if (lit) {
|
||||
const key = nodeText(lit, doc);
|
||||
presentKeys.add(key);
|
||||
|
||||
if (!(key in schemaLevel)) {
|
||||
if (!hasAllowUnknownFields(schemaLevel)) {
|
||||
diagnostics.push({
|
||||
from: lit.from,
|
||||
to: lit.to,
|
||||
severity: "warning",
|
||||
message: `Unknown field: "${key}"`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Recurse into nested mappings.
|
||||
const fieldDef = schemaLevel[key];
|
||||
if (fieldDef.fields) {
|
||||
const valueNode = child.getChildren("BlockMapping").find(Boolean);
|
||||
if (valueNode) lintMapping(valueNode, fieldDef.fields);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
|
||||
// Check for missing required fields (top-level only to avoid noise).
|
||||
for (const [key, fieldDef] of Object.entries(schemaLevel)) {
|
||||
if (fieldDef.required && !presentKeys.has(key)) {
|
||||
// Point to start of document if we have no better location.
|
||||
const docNode = bmNode.parent;
|
||||
const from = docNode?.from ?? 0;
|
||||
diagnostics.push({
|
||||
from,
|
||||
to: from + 1,
|
||||
severity: "error",
|
||||
message: `Required field missing: "${key}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the root BlockMapping.
|
||||
// With jinja({ base: yaml() }) the outer tree is Template → Text, and the
|
||||
// YAML parse is mounted on the Text node via NodeProp.mounted. With plain
|
||||
// yaml() the tree is Stream → Document → BlockMapping directly.
|
||||
let bm: SyntaxNode | null = null;
|
||||
|
||||
const outerTree = syntaxTree(view.state);
|
||||
// Try plain yaml() path first: walk down until BlockMapping.
|
||||
let cur: SyntaxNode | null = outerTree.topNode;
|
||||
while (cur && cur.name !== "BlockMapping") {
|
||||
// If this node has a mounted subtree (jinja wrapper), use that tree instead.
|
||||
const mounted = cur.node?.tree
|
||||
? cur.node.tree.prop(NodeProp.mounted)
|
||||
: null;
|
||||
if (mounted) {
|
||||
// The mounted tree root — walk down into it.
|
||||
cur = mounted.tree.topNode;
|
||||
continue;
|
||||
}
|
||||
cur = cur.firstChild;
|
||||
}
|
||||
if (cur?.name === "BlockMapping") {
|
||||
bm = cur;
|
||||
}
|
||||
|
||||
if (bm) {
|
||||
lintMapping(bm, schema);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
Reference in New Issue
Block a user