Compare commits

...
Author SHA1 Message Date
Bram KragtenandClaude Opus 5 2f4e5279a5 Localize built-in YAML field descriptions
The built-in trigger/condition/action schemas carried English descriptions
inline, which surfaced untranslated in the hover tooltip and completion info.

Store translation keys in `description` instead — the hover element and the
completion source already localize it and fall back to the raw string, which
keeps backend-supplied descriptions (services, integration triggers and
conditions) working unchanged. Adds the strings under
`ui.panel.config.automation.editor.yaml_schema`, plus a test asserting every
built-in description key resolves in en.json.

Also drops the remaining `any` casts in serviceActionSchema in favour of a
declared ServiceField shape.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 15:56:12 +02:00
Bram KragtenandClaude Opus 5 848496b09e Review fixes: live schema context, memoization, localized editor chrome
- ha-code-editor: adapt the YAML schema wiring to the context-based API
  (states/registries/i18n) instead of the removed `hass` property.
- haYamlCompletionSource now takes a context getter instead of a captured
  snapshot, so a schema or registry that changes after the editor was created
  is picked up. Previously the completion source kept the schema and registry
  it was built with for the editor's lifetime.
- Memoize the trigger/condition/action YAML schema on the type discriminator
  rather than the whole config object, which changes identity on every
  keystroke and made both lint compartments reconfigure per character.
- Memoize the registry-derived completion lists; they were rebuilt for every
  completion request.
- Localize the editor chrome: "Required" badge, example/default labels, lint
  diagnostics, and Jinja value hints.
- buildTargetSchema: forward a target's entity/device filters as selector
  `filter`, instead of nesting the whole target definition under `entity`.
- Replace the `__allowUnknownFields` string marker with a symbol, drop `any`
  casts in the hover source and selector helpers, fix stale comments.
- Add unit tests for the schema converters and the lint source.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-13 15:42:42 +02:00
Bram Kragten 2c2eef7942 add floor_id and label_id to YAML hover tooltip selector type mapping 2026-08-13 15:27:15 +02:00
Bram Kragten 4e3cc4c705 Add to developer tools action 2026-08-13 15:27:15 +02:00
Bram Kragten 8f96d2c6e4 add built-in triggers and conditions, add floor and label 2026-08-13 15:26:55 +02:00
Bram Kragten 2b9347fd72 Add support for numeric threshold and behavior selectors 2026-08-13 15:25:27 +02:00
Bram Kragten 12f54835f0 Add field-aware YAML completions, hover tooltips, and linting to automation editor
- New files: yaml_field_schema.ts (types), yaml_ha_completions.ts (completion/hover/lint
  sources), ha_completion_items.ts (entity/device/area completion builders),
  ha-code-editor-yaml-hover.ts (key hover tooltip element),
  yaml_schema_helpers.ts (action/trigger/condition schema converters)
- ha-code-editor: wire schema completions, hover tooltips, and schema linter;
  replace setDiagnostics-based syntax error reporting with a linter() so syntax
  and schema diagnostics no longer overwrite each other; add forceLinting export
- ha-yaml-editor: suppress generic entity autocomplete when schema is present
- Action/trigger/condition editors: pass yamlFieldSchema to ha-yaml-editor;
  memoize on (services, localize) not full hass to avoid constant re-renders
- codemirror.ts: export forceLinting; mount-aware BlockMapping traversal in lint
  source to handle jinja({ base: yaml() }) tree structure (Template → Text with
  NodeProp.mounted subtree)
2026-08-13 15:25:11 +02:00
33 changed files with 3880 additions and 232 deletions
+172
View File
@@ -0,0 +1,172 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../common/translations/localize";
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?: LocalizeFunc;
render() {
const schema = this.fieldSchema;
if (!schema) return nothing;
// Built-in field descriptions are translation keys; descriptions coming
// from the backend are already translated and fall through unchanged.
const description = schema.description
? this.localize?.(schema.description as LocalizeKeys) ||
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"
>${this._label("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"
>${this._label("example", "Example:")}</span
>
<code>${String(schema.example)}</code>
</div>`
: nothing
}
${
schema.default != null
? html`<div class="meta">
<span class="meta-label"
>${this._label("default", "Default:")}</span
>
<code>${String(schema.default)}</code>
</div>`
: nothing
}
`;
}
private _label(
key: "required" | "example" | "default",
fallback: string
): string {
return (
this.localize?.(`ui.components.yaml-editor.schema.${key}`) || fallback
);
}
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;
}
}
+140 -94
View File
@@ -32,15 +32,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";
@@ -89,6 +95,14 @@ export class HaCodeEditor extends ReactiveElement {
@property() public mode = "yaml";
/**
* 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;
@@ -168,6 +182,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;
@@ -202,6 +222,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: {
@@ -209,27 +233,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._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.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() {
@@ -290,9 +297,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()
),
],
});
@@ -304,20 +309,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()
),
});
}
@@ -371,6 +379,65 @@ 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,
this._i18n?.localize
),
{ 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._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.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");
@@ -419,7 +486,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({
@@ -435,6 +505,21 @@ export class HaCodeEditor extends ReactiveElement {
),
{ hoverTime: 300 }
),
...(this.mode === "yaml"
? [
this._loadedCodeMirror.hoverTooltip(
(view, pos) =>
this.yamlFieldSchema
? this._loadedCodeMirror!.haYamlHoverSource(view, pos, {
schema: this.yamlFieldSchema,
localize: this._i18n?.localize,
hassContext: this._hassArgHoverContext(),
})
: null,
{ hoverTime: 300 }
),
]
: []),
...(this.placeholder ? [placeholder(this.placeholder)] : []),
];
@@ -442,6 +527,19 @@ export class HaCodeEditor extends ReactiveElement {
const completionSources: CompletionSource[] = [
this._loadedCodeMirror.haJinjaCompletionSource,
];
if (this.mode === "yaml") {
completionSources.push(
this._loadedCodeMirror.haYamlCompletionSource(() => ({
schema: this.yamlFieldSchema,
localize: this._i18n?.localize,
states: this._states,
devices: this._registries?.devices,
areas: this._registries?.areas,
floors: this._registries?.floors,
labels: this._labels,
}))
);
}
if (this.autocompleteEntities) {
completionSources.push(this._entityCompletions.bind(this));
}
@@ -452,6 +550,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,
@@ -1007,23 +1106,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.
@@ -1418,18 +1503,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. */
@@ -1448,17 +1522,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. */
@@ -1478,16 +1542,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. */
@@ -1507,16 +1562,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. */
@@ -16,13 +16,16 @@ import "../ha-input-helper-text";
import "../ha-select-box";
import type { SelectBoxOption } from "../ha-select-box";
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
export const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"each",
"first",
"all",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
export const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = [
"any",
"all",
];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
+11 -1
View File
@@ -8,6 +8,7 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
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 = YAML11_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;
@@ -123,8 +132,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 { actionSchemaKey, 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(
(
actionKey: string | undefined,
services: HomeAssistant["services"],
localize: HomeAssistant["localize"]
) => actionToYamlSchema(actionKey, services, localize)
);
protected render() {
const yamlMode = this.yamlMode || !this.uiSupported;
const type = getAutomationActionType(this.action);
@@ -74,6 +84,11 @@ export default class HaAutomationActionEditor extends LitElement {
.defaultValue=${this.action}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._actionYamlSchema(
actionSchemaKey(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,22 @@ export default class HaAutomationConditionEditor extends LitElement {
@query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", "))
private _collapsibleElement?: ConditionElement;
// Memoized on the condition type rather than the condition itself: the
// condition object gets a new identity on every keystroke, and the schema
// only depends on its type.
private _conditionYamlSchema = memoizeOne(
(
conditionType: string,
description: ConditionDescription | undefined,
localize: HomeAssistant["localize"]
) => {
if (!description) {
return builtInConditionSchema(conditionType, localize);
}
return conditionDescriptionToSchema(conditionType, description, localize);
}
);
private _processedCondition = memoizeOne((condition) =>
expandConditionWithShorthand(condition)
);
@@ -82,6 +102,11 @@ export default class HaAutomationConditionEditor extends LitElement {
.defaultValue=${this.condition}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._conditionYamlSchema(
condition.condition,
this.description,
this.hass.localize
)}
></ha-yaml-editor>
`
: html`
@@ -32,6 +32,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.triggers.type.numeric_state.${fieldName}` as any
);
}
};
@customElement("ha-automation-condition-numeric_state")
export default class HaNumericStateCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -242,20 +290,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);
static styles = css`
:host {
@@ -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";
@@ -34,7 +35,7 @@ const stateConditionStruct = object({
enabled: optional(boolean()),
});
const SCHEMA = [
export const SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
@@ -61,6 +62,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;
@@ -125,24 +146,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);
static styles = css`
:host {
@@ -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,23 @@ export default class HaAutomationTriggerEditor extends LitElement {
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
// Memoized on the trigger type rather than the trigger itself: the trigger
// object gets a new identity on every keystroke, and the schema only depends
// on its type.
private _triggerYamlSchema = memoizeOne(
(
triggerType: string | undefined,
description: TriggerDescription | undefined,
localize: HomeAssistant["localize"]
) => {
if (triggerType === undefined) return undefined;
if (!description) {
return builtInTriggerSchema(triggerType, localize);
}
return triggerDescriptionToSchema(triggerType, description, localize);
}
);
protected render() {
const type = isTriggerList(this.trigger) ? "list" : this.trigger.trigger;
@@ -72,6 +94,13 @@ export default class HaAutomationTriggerEditor extends LitElement {
<ha-yaml-editor
.defaultValue=${this.trigger}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._triggerYamlSchema(
isTriggerList(this.trigger)
? undefined
: this.trigger.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 {
@@ -8,6 +8,7 @@ import { hasTemplate } from "../../../../../common/string/has-template";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
const SCHEMA = [
@@ -183,6 +184,63 @@ const SCHEMA = [
},
] 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.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;
@@ -276,24 +334,7 @@ export class HaNumericStateTrigger extends LitElement {
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.triggers.type.numeric_state.${schema.name}`
);
}
};
): string => computeLabel(schema.name, this.hass.localize);
static styles = css`
:host {
@@ -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;
@@ -338,12 +372,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);
static styles = css`
:host {
@@ -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 {
@@ -7,8 +7,9 @@ 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",
@@ -25,6 +26,14 @@ const SCHEMA = [
},
] 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;
@@ -102,10 +111,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;
@@ -207,21 +238,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);
static styles = css`
:host {
@@ -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
@@ -50,6 +50,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("tools-action")
class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
@@ -197,6 +198,11 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
<ha-yaml-editor
id="yaml-editor"
.defaultValue=${this._serviceData}
.yamlFieldSchema=${this._yamlFieldSchema(
this._serviceData?.action,
this.hass.services,
this.hass.localize
)}
@value-changed=${this._yamlChanged}
></ha-yaml-editor>
</div>`
@@ -397,6 +403,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,
+18 -1
View File
@@ -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,
@@ -86,12 +92,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
+122
View File
@@ -0,0 +1,122 @@
/**
* 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,
}));
}
+1 -1
View File
@@ -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,
+67
View File
@@ -0,0 +1,67 @@
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.
*
* Keys are the YAML keys valid at this mapping level; see
* `allowUnknownFields()` for levels where that set is not statically known.
*/
export type YamlFieldSchemaMap = Record<string, YamlFieldSchema>;
/**
* Marks a map as accepting keys beyond the ones it lists. Kept on a symbol so
* it can never collide with a real YAML key, and so it stays out of the
* Object.keys() / Object.entries() iteration used for completions and
* required-field checks.
*/
const ALLOW_UNKNOWN_FIELDS = Symbol("allowUnknownFields");
/**
* Mark a `YamlFieldSchemaMap` so that the linter does not warn about unknown
* keys at 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). Returns the same object for convenience.
*/
export function allowUnknownFields(
map: YamlFieldSchemaMap
): YamlFieldSchemaMap {
(map as Record<symbol, boolean>)[ALLOW_UNKNOWN_FIELDS] = true;
return map;
}
/** Return true if the map was marked via `allowUnknownFields()`. */
export function hasAllowUnknownFields(map: YamlFieldSchemaMap): boolean {
return (map as Record<symbol, boolean>)[ALLOW_UNKNOWN_FIELDS] === true;
}
+925
View File
@@ -0,0 +1,925 @@
/**
* 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 memoizeOne from "memoize-one";
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 type { SelectSelector } from "../data/selector";
import type { LocalizeFunc } from "../common/translations/localize";
import {
buildAreaCompletions,
buildDeviceCompletions,
buildEntityCompletions,
buildFloorCompletions,
buildLabelCompletions,
} from "./ha_completion_items";
import {
buildArgTooltipDom,
type HassArgHoverContext,
type JinjaArgType,
} from "./jinja_ha_completions";
import "../components/ha-code-editor-jinja-arg-hover";
import "../components/ha-code-editor-yaml-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 = /^.*$/;
const BOOLEAN_COMPLETIONS: Completion[] = [
{ label: "true", type: "keyword" },
{ label: "false", type: "keyword" },
];
/** "Required" marker shown as the `detail` of a required key completion. */
function requiredLabel(ctx: HaYamlCompletionContext): string | undefined {
return ctx.localize?.("ui.components.yaml-editor.schema.required");
}
/**
* A field's description, translated when it is a translation key. Descriptions
* coming from the backend (service/trigger/condition descriptions) are already
* translated plain text and pass through unchanged.
*/
function describe(
field: YamlFieldSchema,
ctx: { localize?: LocalizeFunc }
): string | undefined {
if (!field.description) return undefined;
return (
ctx.localize?.(field.description as Parameters<LocalizeFunc>[0]) ||
field.description
);
}
// Registry-derived completion lists are rebuilt for every completion request,
// and there can be thousands of entities — memoize on the registry identity so
// typing doesn't remap the whole registry on each keystroke.
const memoEntityCompletions = memoizeOne(buildEntityCompletions);
const memoDeviceCompletions = memoizeOne(buildDeviceCompletions);
const memoAreaCompletions = memoizeOne(buildAreaCompletions);
const memoFloorCompletions = memoizeOne(buildFloorCompletions);
const memoLabelCompletions = memoizeOne(buildLabelCompletions);
function valueCompletionsForSelector(
field: YamlFieldSchema,
ctx: HaYamlCompletionContext
): Completion[] | null {
const { selector } = field;
if (!selector) return null;
const type = Object.keys(selector)[0];
if (type === "boolean") {
return BOOLEAN_COMPLETIONS;
}
if (type === "select") {
const opts = (selector as SelectSelector).select?.options;
if (Array.isArray(opts)) {
return opts.map((option) =>
typeof option === "object"
? { label: option.value, type: "enum", detail: option.label }
: { label: option, type: "enum" }
);
}
}
if (type === "entity" && ctx.states) {
return memoEntityCompletions(ctx.states);
}
if (type === "device" && ctx.devices) {
return memoDeviceCompletions(ctx.devices);
}
if (type === "area" && ctx.areas) {
return memoAreaCompletions(ctx.areas);
}
if (type === "floor" && ctx.floors) {
return memoFloorCompletions(ctx.floors);
}
if (type === "label" && ctx.labels) {
return memoLabelCompletions(ctx.labels);
}
if (type === "template") {
return ctx.localize
? [
{
label: "{{ }}",
type: "text",
detail: ctx.localize(
"ui.components.yaml-editor.schema.jinja_template"
),
},
{
label: "{% %}",
type: "text",
detail: ctx.localize(
"ui.components.yaml-editor.schema.jinja_block"
),
},
]
: [
{ label: "{{ }}", type: "text" },
{ label: "{% %}", type: "text" },
];
}
return null;
}
// ---------------------------------------------------------------------------
// Completion source
// ---------------------------------------------------------------------------
export interface HaYamlCompletionContext {
/** The current field schema map, or undefined when no schema is active. */
schema?: YamlFieldSchemaMap;
/** Localize callback for the completion chrome (badges, type hints). */
localize?: LocalizeFunc;
/** 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` for field-aware YAML completions.
*
* `getContext` is called on every completion request rather than captured once,
* so a schema or registry that changes after the editor was created is picked
* up without recreating the editor. Register the result once per editor via
* `autocompletion({ override: [..., haYamlCompletionSource(getContext)] })`.
*/
export function haYamlCompletionSource(
getContext: () => HaYamlCompletionContext
): (context: CompletionContext) => CompletionResult | null {
return (context: CompletionContext): CompletionResult | null => {
const ctx = getContext();
const rootSchema = ctx.schema;
if (!rootSchema) return 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(rootSchema, 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(rootSchema, 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(rootSchema, 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 ? requiredLabel(ctx) : undefined,
info: describe(subField, ctx),
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(rootSchema, 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 ? requiredLabel(ctx) : undefined,
info: describe(subField, ctx),
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 = rootSchema;
// 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(rootSchema, [
...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(rootSchema, [
...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(rootSchema, 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 ? requiredLabel(ctx) : undefined,
info: describe(field, ctx),
// 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
// ---------------------------------------------------------------------------
/**
* Selector types whose values are HA IDs, mapped to the Jinja arg type that
* knows how to render a rich tooltip for them.
*/
const SELECTOR_ARG_TYPES: Record<string, JinjaArgType | undefined> = {
entity: "entity_id",
device: "device_id",
area: "area_id",
floor: "floor_id",
label: "label_id",
};
export interface HaYamlHoverContext {
/** The current field schema map. */
schema: YamlFieldSchemaMap;
/**
* Optional localize callback used to translate field descriptions that are
* i18n keys, and the tooltip's own labels. When absent, the raw string is
* displayed and the labels fall back to English.
*/
localize?: LocalizeFunc;
/** 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 = SELECTOR_ARG_TYPES[selectorType];
if (argType) {
const value = nodeText(node, doc);
const dom = buildArgTooltipDom(argType, 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.fieldName = keyText;
dom.fieldSchema = field;
dom.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.
*
* Checks, at every mapping level the schema describes:
* - Keys the schema doesn't know (warning), unless the level was marked with
* `allowUnknownFields()`
* - Required keys that are missing (error)
*/
export function haYamlLintSource(
view: EditorView,
schema: YamlFieldSchemaMap,
localize?: LocalizeFunc
): 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:
localize?.("ui.components.yaml-editor.schema.unknown_field", {
field: key,
}) || `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;
}
// Report required fields that are missing from this mapping. Anchored to
// the start of the mapping, since there is no key to point at.
for (const [key, fieldDef] of Object.entries(schemaLevel)) {
if (fieldDef.required && !presentKeys.has(key)) {
const from = bmNode.parent?.from ?? 0;
diagnostics.push({
from,
to: from + 1,
severity: "error",
message:
localize?.("ui.components.yaml-editor.schema.missing_field", {
field: key,
}) || `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;
}
+154 -2
View File
@@ -1435,7 +1435,16 @@
"exit_fullscreen": "Exit fullscreen",
"find_and_replace": "Find and replace",
"test_on": "Turn on testing",
"test_off": "Turn off testing"
"test_off": "Turn off testing",
"schema": {
"required": "Required",
"example": "Example:",
"default": "Default:",
"unknown_field": "Unknown field: \"{field}\"",
"missing_field": "Required field missing: \"{field}\"",
"jinja_template": "Jinja2 template",
"jinja_block": "Jinja2 block"
}
},
"state-content-picker": {
"state": "State",
@@ -6041,7 +6050,150 @@
},
"paste_toast_message": "Pasted automation from clipboard",
"paste_invalid_yaml": "Pasted value is not valid YAML",
"paste_invalid_config": "Pasted automation is not editable in the visual editor"
"paste_invalid_config": "Pasted automation is not editable in the visual editor",
"yaml_schema": {
"trigger_base": {
"trigger": "The trigger type (platform).",
"id": "An optional ID for the trigger, used to identify it in conditions or templates.",
"alias": "A friendly name for this trigger.",
"enabled": "Whether this trigger is enabled. Defaults to true.",
"variables": "Variables to set when this trigger fires."
},
"condition_base": {
"condition": "The condition type.",
"alias": "A friendly name for this condition.",
"enabled": "Whether this condition is enabled. Defaults to true."
},
"action_base": {
"alias": "A friendly name for this action.",
"enabled": "Whether this action is enabled. Defaults to true.",
"continue_on_error": "If true, the automation continues even if this action fails."
},
"actions": {
"delay": {
"delay": "Duration to wait. Can be a number (seconds), a time string (HH:MM:SS), or a mapping with hours/minutes/seconds/milliseconds."
},
"wait_template": {
"wait_template": "A Jinja2 template that must evaluate to true before continuing.",
"timeout": "Maximum time to wait. After this the action continues (or stops if continue_on_timeout is false).",
"continue_on_timeout": "Whether to continue when the timeout is reached. Defaults to true."
},
"wait_for_trigger": {
"wait_for_trigger": "One or more triggers to wait for before continuing.",
"timeout": "Maximum time to wait.",
"continue_on_timeout": "[%key:ui::panel::config::automation::editor::yaml_schema::actions::wait_template::continue_on_timeout%]"
},
"event": {
"event": "The event type to fire.",
"event_data": "Data to include with the event.",
"event_data_template": "Templated data to include with the event."
},
"condition": {
"condition": "The condition type to check. The automation stops if the condition is false."
},
"stop": {
"stop": "Message to log when stopping the automation.",
"error": "If true, this is logged as an error. Defaults to false.",
"response_variable": "Variable name to store when stopping and returning a response."
},
"repeat": {
"repeat": "Repeat configuration — use count, while, until, or for_each.",
"count": "Number of times to repeat.",
"while": "Repeat while these conditions are true.",
"until": "Repeat until these conditions are true.",
"for_each": "List of items to iterate over.",
"sequence": "Actions to perform on each iteration."
},
"choose": {
"choose": "List of options; the first matching one is executed.",
"conditions": "Conditions that must be met for this option to run.",
"sequence": "Actions to run if the conditions match.",
"alias": "A friendly name for this option.",
"default": "Actions to run when no option matched."
},
"if": {
"if": "Conditions to check.",
"then": "Actions to run when the condition is true.",
"else": "Actions to run when the condition is false."
},
"sequence": {
"sequence": "A list of actions to run in order."
},
"parallel": {
"parallel": "A list of actions (or scripts) to run in parallel."
},
"variables": {
"variables": "Key/value pairs to set as variables in the automation context."
},
"set_conversation_response": {
"set_conversation_response": "The text response to return to the conversation agent."
},
"service": {
"data": "Service call data (field values).",
"response_variable": "Variable name to store the action response in.",
"action": "The action to call."
}
},
"conditions": {
"zone": {
"zone": "The zone the entity must be in.",
"entity_id": "The person or device_tracker entity to check."
},
"and": {
"conditions": "All of these conditions must be true."
},
"or": {
"conditions": "At least one of these conditions must be true."
},
"not": {
"conditions": "None of these conditions must be true."
},
"options": "Condition options."
},
"triggers": {
"event": {
"event_type": "The event type to listen for.",
"event_data": "Optional event data to match.",
"context": "Optional context to match (e.g. user_id)."
},
"zone": {
"entity_id": "The person or device_tracker entity to watch.",
"zone": "The zone to watch.",
"event": "Whether to trigger on zone entry or exit."
},
"tag": {
"tag_id": "The NFC/QR tag ID(s) to watch."
},
"webhook": {
"webhook_id": "The webhook ID. Will be part of the webhook URL.",
"allowed_methods": "HTTP methods that are accepted (default: POST, PUT).",
"local_only": "Only allow requests from the local network. Defaults to true."
},
"conversation": {
"command": "The voice command phrase(s) to match."
},
"options": "Trigger options."
},
"target": {
"target": "The target entities, devices, areas, floors, or labels.",
"entity_id": "One or more entity IDs to target.",
"device_id": "One or more device IDs to target.",
"area_id": "One or more area IDs to target.",
"floor_id": "One or more floor IDs to target.",
"label_id": "One or more label IDs to target."
},
"numeric_threshold": {
"active_choice": "Whether to use a fixed number or an entity state.",
"number": "Fixed numeric value.",
"entity": "Entity whose state provides the value.",
"unit_of_measurement": "Unit of measurement.",
"value_entry": "Threshold value entry.",
"type": "Comparison type.",
"value": "Threshold value (for above / below types).",
"value_min": "Lower bound (for between / outside types).",
"value_max": "Upper bound (for between / outside types)."
}
}
},
"trace": {
"refresh": "[%key:ui::common::refresh%]",
@@ -0,0 +1,364 @@
import { describe, expect, test } from "vitest";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { Action } from "../../../../src/data/script";
import type { HomeAssistant } from "../../../../src/types";
import {
actionSchemaKey,
actionToYamlSchema,
builtInActionSchema,
builtInConditionSchema,
builtInTriggerSchema,
haFormSchemaToYamlFieldSchemaMap,
serviceActionSchema,
} from "../../../../src/panels/config/automation/yaml_schema_helpers";
import type { YamlFieldSchemaMap } from "../../../../src/resources/yaml_field_schema";
import { hasAllowUnknownFields } from "../../../../src/resources/yaml_field_schema";
import en from "../../../../src/translations/en.json";
const localize = ((key: string) => key) as HomeAssistant["localize"];
const BUILT_IN_ACTION_TYPES = [
"delay",
"wait_template",
"wait_for_trigger",
"event",
"condition",
"stop",
"repeat",
"choose",
"if",
"sequence",
"parallel",
"variables",
"set_conversation_response",
];
const BUILT_IN_TRIGGER_TYPES = [
"state",
"numeric_state",
"event",
"homeassistant",
"template",
"time",
"time_pattern",
"sun",
"zone",
"tag",
"webhook",
"geo_location",
"calendar",
"persistent_notification",
"conversation",
"device",
];
const BUILT_IN_CONDITION_TYPES = [
"state",
"numeric_state",
"template",
"time",
"sun",
"zone",
"trigger",
"and",
"or",
"not",
"device",
];
describe("haFormSchemaToYamlFieldSchemaMap", () => {
test("maps selector entries straight through", () => {
const schema = [
{ name: "entity_id", required: true, selector: { entity: {} } },
] as const satisfies readonly HaFormSchema[];
expect(haFormSchemaToYamlFieldSchemaMap(schema)).toEqual({
entity_id: {
required: true,
default: undefined,
description: undefined,
selector: { entity: {} },
},
});
});
test("converts select entries into a select selector", () => {
const schema = [
{
name: "behavior",
type: "select",
options: [
["first", "First"],
["all", "All"],
],
},
] as const satisfies readonly HaFormSchema[];
expect(haFormSchemaToYamlFieldSchemaMap(schema).behavior.selector).toEqual({
select: {
options: [
{ value: "first", label: "First" },
{ value: "all", label: "All" },
],
},
});
});
test("flattens grid and expandable containers into the parent map", () => {
const schema = [
{
name: "grid",
type: "grid",
schema: [{ name: "above", selector: { number: {} } }],
},
{
name: "advanced",
type: "expandable",
schema: [{ name: "for", selector: { duration: {} } }],
},
] as const satisfies readonly HaFormSchema[];
expect(Object.keys(haFormSchemaToYamlFieldSchemaMap(schema))).toEqual([
"above",
"for",
]);
});
test("uses the description callback when given", () => {
const schema = [
{ name: "entity_id", selector: { entity: {} } },
] as const satisfies readonly HaFormSchema[];
expect(
haFormSchemaToYamlFieldSchemaMap(schema, (field) => `desc:${field}`)
.entity_id.description
).toBe("desc:entity_id");
});
test("skips entries without a name and unsupported types", () => {
const schema = [
{ type: "constant", name: "info", value: "x" },
] as const satisfies readonly HaFormSchema[];
expect(haFormSchemaToYamlFieldSchemaMap(schema)).toEqual({});
});
});
describe("actionSchemaKey", () => {
test("returns the service name for service calls", () => {
expect(
actionSchemaKey({ action: "light.turn_on" } as unknown as Action)
).toBe("light.turn_on");
});
test("returns the built-in key for built-in actions", () => {
expect(actionSchemaKey({ delay: "00:00:05" } as unknown as Action)).toBe(
"delay"
);
expect(
actionSchemaKey({ choose: [], default: [] } as unknown as Action)
).toBe("choose");
});
test("is stable while the action's values change", () => {
expect(
actionSchemaKey({
action: "light.turn_on",
target: { entity_id: "light.kitchen" },
} as unknown as Action)
).toBe(
actionSchemaKey({
action: "light.turn_on",
target: { entity_id: "light.bedroom" },
data: { brightness: 5 },
} as unknown as Action)
);
});
test("returns undefined for an unrecognized action", () => {
expect(actionSchemaKey({ unknown_thing: 1 } as unknown as Action)).toBe(
undefined
);
});
});
describe("actionToYamlSchema", () => {
const services = {
light: {
turn_on: {
name: "Turn on",
description: "",
fields: {
brightness: {
selector: { number: { min: 0, max: 255 } },
description: "Brightness",
},
},
target: { entity: [{ domain: ["light"] }] },
},
},
} as unknown as HomeAssistant["services"];
test("builds a service schema for service calls", () => {
const schema = actionToYamlSchema("light.turn_on", services, localize)!;
expect(schema.action.required).toBe(true);
expect(schema.data.fields?.brightness.selector).toEqual({
number: { min: 0, max: 255 },
});
// The service's target filter is forwarded into the entity_id selector.
expect(schema.target.fields?.entity_id.selector).toEqual({
entity: { multiple: true, filter: [{ domain: ["light"] }] },
});
});
test("builds a built-in schema for built-in actions", () => {
expect(actionToYamlSchema("delay", services, localize)).toBe(
builtInActionSchema("delay")
);
});
test("falls back to base fields that allow unknown keys", () => {
const schema = actionToYamlSchema(undefined, services, localize)!;
expect(Object.keys(schema)).toEqual([
"alias",
"enabled",
"continue_on_error",
]);
expect(hasAllowUnknownFields(schema)).toBe(true);
});
test("does not leak the allow-unknown marker into other schemas", () => {
actionToYamlSchema(undefined, services, localize);
expect(hasAllowUnknownFields(builtInActionSchema("delay")!)).toBe(false);
});
});
describe("serviceActionSchema", () => {
test("hoists grouped advanced fields to the top level of data", () => {
const services = {
light: {
turn_on: {
name: "Turn on",
description: "",
fields: {
brightness: { selector: { number: {} } },
advanced_fields: {
collapsed: true,
fields: { transition: { selector: { number: {} } } },
},
},
},
},
} as unknown as HomeAssistant["services"];
const schema = serviceActionSchema("light", "turn_on", services, localize);
expect(Object.keys(schema.data.fields!)).toEqual([
"brightness",
"transition",
]);
});
test("does not mark a field required when it has a default", () => {
const services = {
light: {
turn_on: {
name: "Turn on",
description: "",
fields: {
brightness: { required: true, default: 255, selector: {} },
color_name: { required: true, selector: {} },
},
},
},
} as unknown as HomeAssistant["services"];
const fields = serviceActionSchema("light", "turn_on", services, localize)
.data.fields!;
expect(fields.brightness.required).toBe(false);
expect(fields.color_name.required).toBe(true);
});
});
describe("builtInTriggerSchema / builtInConditionSchema", () => {
test("always includes the shared base fields", () => {
expect(Object.keys(builtInTriggerSchema("state", localize)!)).toEqual(
expect.arrayContaining(["trigger", "id", "alias", "enabled", "variables"])
);
expect(Object.keys(builtInConditionSchema("state", localize)!)).toEqual(
expect.arrayContaining(["condition", "alias", "enabled"])
);
});
test("device triggers and conditions accept integration-specific keys", () => {
expect(
hasAllowUnknownFields(builtInTriggerSchema("device", localize)!)
).toBe(true);
expect(
hasAllowUnknownFields(builtInConditionSchema("device", localize)!)
).toBe(true);
});
test("known trigger types do not accept unknown keys", () => {
expect(
hasAllowUnknownFields(builtInTriggerSchema("state", localize)!)
).toBe(false);
});
});
describe("built-in field descriptions", () => {
// Built-in schemas carry translation keys as their description; the hover
// tooltip and completion info resolve them through localize(). A key that
// isn't in en.json would silently render as the raw key.
const resolve = (key: string): unknown =>
key
.split(".")
.reduce<any>((node, part) => (node ? node[part] : undefined), en);
const collect = (map: YamlFieldSchemaMap | undefined, into: string[]) => {
for (const field of Object.values(map ?? {})) {
if (field.description) into.push(field.description);
collect(field.fields, into);
}
return into;
};
const descriptionsOf = (map: YamlFieldSchemaMap | undefined) =>
collect(map, []);
test.each(BUILT_IN_ACTION_TYPES)("action %s", (type) => {
const keys = descriptionsOf(builtInActionSchema(type));
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
test.each(BUILT_IN_TRIGGER_TYPES)("trigger %s", (type) => {
const keys = descriptionsOf(builtInTriggerSchema(type, localize));
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
test.each(BUILT_IN_CONDITION_TYPES)("condition %s", (type) => {
const keys = descriptionsOf(builtInConditionSchema(type, localize));
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
test("service action schema", () => {
const keys = descriptionsOf(
serviceActionSchema(
"light",
"turn_on",
{} as HomeAssistant["services"],
localize
)
);
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
});
+119
View File
@@ -0,0 +1,119 @@
import { jinja } from "@codemirror/lang-jinja";
import { yaml } from "@codemirror/lang-yaml";
import { EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vitest";
import { haYamlLintSource } from "../../src/resources/yaml_ha_completions";
import type { YamlFieldSchemaMap } from "../../src/resources/yaml_field_schema";
import { allowUnknownFields } from "../../src/resources/yaml_field_schema";
// haYamlLintSource only reads `view.state`, so a state-only stub is enough.
const viewFor = (doc: string, language = jinja({ base: yaml() })) =>
({
state: EditorState.create({ doc, extensions: [language] }),
}) as EditorView;
const SCHEMA: YamlFieldSchemaMap = {
trigger: { required: true, selector: { text: null } },
entity_id: { selector: { entity: null } },
options: {
selector: { object: null },
fields: {
above: { required: true, selector: { number: {} } },
below: { selector: { number: {} } },
},
},
};
describe("haYamlLintSource", () => {
test("accepts a document that matches the schema", () => {
expect(
haYamlLintSource(
viewFor("trigger: state\nentity_id: light.kitchen\n"),
SCHEMA
)
).toEqual([]);
});
test("warns about a key the schema doesn't know", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: state\nnot_a_field: 1\n"),
SCHEMA
);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].severity).toBe("warning");
expect(diagnostics[0].message).toContain("not_a_field");
});
test("errors when a required key is missing", () => {
const diagnostics = haYamlLintSource(
viewFor("entity_id: light.kitchen\n"),
SCHEMA
);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].severity).toBe("error");
expect(diagnostics[0].message).toContain("trigger");
});
test("checks nested mappings against the nested schema", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: numeric_state\noptions:\n bogus: 1\n"),
SCHEMA
);
expect(diagnostics.map((d) => d.severity).sort()).toEqual([
"error",
"warning",
]);
expect(
diagnostics.find((d) => d.severity === "warning")!.message
).toContain("bogus");
// "above" is required inside options.
expect(diagnostics.find((d) => d.severity === "error")!.message).toContain(
"above"
);
});
test("stays quiet about unknown keys on a map marked allowUnknownFields", () => {
const schema = allowUnknownFields({
trigger: { required: true, selector: { text: null } },
});
expect(
haYamlLintSource(
viewFor("trigger: device\ndomain: zha\nintegration_specific: 1\n"),
schema
)
).toEqual([]);
});
test("localizes diagnostics when a localize callback is given", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: state\nnot_a_field: 1\n"),
SCHEMA,
((key: string, values?: Record<string, string>) =>
`${key}|${values?.field}`) as any
);
expect(diagnostics[0].message).toBe(
"ui.components.yaml-editor.schema.unknown_field|not_a_field"
);
});
test("finds the mapping through a plain yaml() tree too", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: state\nnot_a_field: 1\n", yaml()),
SCHEMA
);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].message).toContain("not_a_field");
});
test("reports nothing for an empty document", () => {
expect(haYamlLintSource(viewFor(""), SCHEMA)).toEqual([]);
});
});