Compare commits

...
Author SHA1 Message Date
WendelinandClaude Opus 5.5 5e96fac4dc Keep errors on duplicate rows and rows changed while saving
Identical rows keep their own errors after the YAML is parsed again, and
a save whose config changed meanwhile checks the current config in full,
so rows renamed and moved during the save stay marked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 08:53:41 +01:00
WendelinandClaude Opus 5.5 1fa476ab67 Follow error rows through moves and saves
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 17:13:08 +01:00
WendelinandClaude Opus 5.5 0a4a354e22 Show automation validation errors on the rows they belong to
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 16:27:05 +01:00
39 changed files with 1918 additions and 181 deletions

No files matched your search

+16
View File
@@ -5,3 +5,19 @@ export const stringifyPath = (path: ConfigPath): string => path.join("/");
export const pathEquals = (a: ConfigPath, b: ConfigPath): boolean =>
a.length === b.length && a.every((segment, index) => segment === b[index]);
export const pathStartsWith = (path: ConfigPath, prefix: ConfigPath): boolean =>
prefix.length <= path.length &&
prefix.every((segment, index) => segment === path[index]);
/** Formats a path the way it reads in YAML, e.g. `actions[1].then[0].delay`. */
export const formatPath = (path: ConfigPath): string =>
path
.map((segment, index) =>
typeof segment === "number"
? `[${segment}]`
: index === 0
? segment
: `.${segment}`
)
.join("");
@@ -30,6 +30,9 @@ export class HaAutomationRow extends LitElement {
@property({ type: Boolean, reflect: true })
public dim = false;
@property({ type: Boolean, reflect: true })
public error = false;
@query(".row")
private _rowElement?: HTMLDivElement;
@@ -232,6 +235,15 @@ export class HaAutomationRow extends LitElement {
:host([highlight]) .row:hover {
background-color: rgba(var(--rgb-primary-color), 0.16);
}
:host([error]) .row {
background-color: var(--ha-color-fill-danger-quiet-resting);
}
:host([error][selected]) .row,
:host([error][selected]) .row:focus {
outline: 2px solid var(--ha-color-border-danger-loud);
outline-offset: -2px;
background-color: var(--ha-color-fill-danger-normal-resting);
}
.icons,
.leading-icon-wrapper,
@@ -14,6 +14,12 @@ class HaDeviceActionPicker extends HaDeviceAutomationPicker<DeviceAction> {
);
}
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.not_supported"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
@@ -49,6 +49,10 @@ export abstract class HaDeviceAutomationPicker<
@property({ type: Object }) public value?: T;
@property({ type: Boolean }) public invalid = false;
@property({ attribute: "error-message" }) public errorMessage?: string;
@state() private _automations?: T[];
// Trigger an empty render so we start with a clean DOM.
@@ -106,6 +110,13 @@ export abstract class HaDeviceAutomationPicker<
);
}
/** Error shown when the picked device has nothing to choose from. */
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.actions.not_supported"
);
}
private _localizeDeviceAutomation: (
localize: LocalizeFunc,
states: HassEntities,
@@ -163,6 +174,12 @@ export abstract class HaDeviceAutomationPicker<
.value=${value}
.disabled=${this._automations?.length === 0}
.loading=${this._automations === undefined}
.invalid=${this.invalid}
.errorMessage=${
this._automations?.length === 0
? this.NOT_SUPPORTED_TEXT
: this.errorMessage
}
.getItems=${this._getItems(
value,
this._automations,
@@ -14,6 +14,12 @@ class HaDeviceConditionPicker extends HaDeviceAutomationPicker<DeviceCondition>
);
}
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.conditions.not_supported"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.conditions.unknown_condition"
@@ -107,6 +107,10 @@ export class HaDevicePicker extends LitElement {
@property() public helper?: string;
@property({ type: Boolean }) public invalid = false;
@property({ attribute: "error-message" }) public errorMessage?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
@@ -521,6 +525,8 @@ export class HaDevicePicker extends LitElement {
.autofocus=${this.autofocus}
.disabled=${this.disabled}
.helper=${this.helper}
.invalid=${this.invalid}
.errorMessage=${this.errorMessage}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel}
@@ -14,6 +14,12 @@ class HaDeviceTriggerPicker extends HaDeviceAutomationPicker<DeviceTrigger> {
);
}
protected get NOT_SUPPORTED_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.triggers.not_supported"
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this._i18n.localize(
"ui.panel.config.devices.automation.triggers.unknown_trigger"
+98 -29
View File
@@ -41,6 +41,7 @@ import "./ha-selector/ha-selector";
import type { HaSelector } from "./ha-selector/ha-selector";
import "./ha-service-picker";
import "./ha-service-section-icon";
import "./ha-input-helper-text";
import "./ha-settings-row";
import "./ha-yaml-editor";
import type { HaYamlEditor } from "./ha-yaml-editor";
@@ -119,6 +120,15 @@ export class HaServiceControl extends LitElement {
@property({ attribute: "hide-description", type: Boolean })
public hideDescription = false;
/** Error shown below the target picker. */
@property({ attribute: false }) public targetError?: string;
/** Errors shown below the data fields, keyed by field. */
@property({ attribute: false }) public dataErrors?: Record<string, string>;
/** Error of the data as a whole. */
@property({ attribute: false }) public dataError?: string;
@state() private _value!: this["value"];
@state() private _checkedKeys = new Set();
@@ -542,13 +552,14 @@ export class HaServiceControl extends LitElement {
${
serviceData && "target" in serviceData
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector>`
class="target-selector"
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector>
${this._renderError(this.targetError)}`
: entityId
? html`<ha-entity-picker
.disabled=${this.disabled}
@@ -594,7 +605,10 @@ export class HaServiceControl extends LitElement {
this._hasFilteredFields(fields, targetEntities)
? html`<ha-expansion-panel
left-chevron
.expanded=${!dataField.collapsed}
.expanded=${
!dataField.collapsed ||
fields.some((field) => this.dataErrors?.[field.key])
}
.header=${
this.hass.localize(
`component.${domain}.services.${serviceName}.sections.${dataField.key}.name`,
@@ -626,7 +640,12 @@ export class HaServiceControl extends LitElement {
</ha-expansion-panel>`
: nothing;
})
} `;
}
${this._unshownErrors(
serviceData,
targetEntities,
Boolean(shouldRenderServiceDataYaml)
).map((error) => this._renderError(error))}`;
}
private _getSectionDescription(
@@ -690,6 +709,25 @@ export class HaServiceControl extends LitElement {
? this.hass.services[domain][serviceName].description_placeholders
: undefined;
const fieldError = this.dataErrors?.[dataField.key];
const selectorTemplate = html`<ha-selector
.context=${this._selectorContext(targetEntities)}
.disabled=${
this.disabled ||
(showOptional &&
!this._checkedKeys.has(dataField.key) &&
(!this._value?.data || this._value.data[dataField.key] === undefined))
}
.hass=${this.hass}
.selector=${selector}
.key=${dataField.key}
@value-changed=${this._serviceDataChanged}
.value=${this._value?.data ? this._value.data[dataField.key] : undefined}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
.required=${dataField.required}
></ha-selector>`;
return dataField.selector
? html`<ha-settings-row
.narrow=${
@@ -742,30 +780,50 @@ export class HaServiceControl extends LitElement {
}
></ha-markdown>
</span>
<ha-selector
.context=${this._selectorContext(targetEntities)}
.disabled=${
this.disabled ||
(showOptional &&
!this._checkedKeys.has(dataField.key) &&
(!this._value?.data ||
this._value.data[dataField.key] === undefined))
}
.hass=${this.hass}
.selector=${selector}
.key=${dataField.key}
@value-changed=${this._serviceDataChanged}
.value=${
this._value?.data ? this._value.data[dataField.key] : undefined
}
.placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback}
.required=${dataField.required}
></ha-selector>
<div class="field">
${selectorTemplate}${this._renderError(fieldError)}
</div>
</ha-settings-row>`
: "";
};
// Errors without a field to show them on, e.g. of a field no target
// supports or of a target the action doesn't have a picker for
private _unshownErrors(
serviceData: ExtHassService | undefined,
targetEntities: string[] | null,
dataYaml: boolean
): string[] {
const targetShown = Boolean(serviceData && "target" in serviceData);
const shownFields = new Set(
dataYaml
? []
: (serviceData?.flatFields ?? [])
.filter(
(field) =>
field.selector &&
(!field.filter ||
this._filterField(field.filter, targetEntities))
)
.map((field) => field.key)
);
return [
...(this.targetError && !targetShown ? [this.targetError] : []),
...(this.dataError ? [this.dataError] : []),
...Object.entries(this.dataErrors ?? {})
.filter(([key]) => !shownFields.has(key))
.map(([key, error]) => `${key}: ${error}`),
];
}
private _renderError(error: string | undefined) {
return error
? html`<ha-input-helper-text class="error"
>${error}</ha-input-helper-text
>`
: nothing;
}
private _selectorContext = memoizeOne((targetEntities: string[] | null) => ({
filter_entity: targetEntities || undefined,
}));
@@ -991,6 +1049,17 @@ export class HaServiceControl extends LitElement {
ha-settings-row {
padding: var(--service-control-padding, 0 var(--ha-space-4));
}
/* Every field has this wrapper, so the selector isn't rebuilt when its
error comes or goes */
.field {
display: flex;
flex-direction: column;
}
ha-input-helper-text.error {
color: var(--ha-color-on-danger-quiet);
padding-top: var(--ha-space-1);
padding-bottom: var(--ha-space-2);
}
ha-settings-row[narrow] {
padding-bottom: var(--ha-space-2);
}
+70
View File
@@ -1,5 +1,13 @@
import type { ConfigPath } from "../common/util/config-path";
import type { HomeAssistant } from "../types";
export interface ConfigValidationError {
message: string;
/** Where the problem is, `[]` when core does not know. */
path: ConfigPath;
code?: string | null;
}
export interface ValidConfig {
valid: true;
error: null;
@@ -8,10 +16,14 @@ export interface ValidConfig {
export interface InvalidConfig {
valid: false;
error: string;
/** Paths start at the item index of the section. */
errors: ConfigValidationError[];
}
type ValidKeys = "triggers" | "actions" | "conditions";
export type ValidationSectionKeys = Partial<Record<ValidKeys, string>>;
export const validateConfig = <T extends Partial<Record<ValidKeys, unknown>>>(
hass: HomeAssistant,
config: T
@@ -20,3 +32,61 @@ export const validateConfig = <T extends Partial<Record<ValidKeys, unknown>>>(
type: "validate_config",
...config,
});
/**
* Flattens a `validate_config` result into errors with paths from the config
* root. `sectionKeys` names the config key of a section when it differs from
* the key it is validated under, e.g. `{ actions: "sequence" }` for scripts.
*/
export const validationResultErrors = (
result: Partial<Record<ValidKeys, ValidConfig | InvalidConfig>>,
sectionKeys: ValidationSectionKeys = {}
): ConfigValidationError[] =>
(Object.keys(result) as ValidKeys[]).flatMap((key) => {
const section = result[key];
if (!section || section.valid) {
return [];
}
const sectionKey = sectionKeys[key] ?? key;
return section.errors.map((error) => ({
...error,
path: [sectionKey, ...error.path],
}));
});
export const validateConfigErrors = async (
hass: HomeAssistant,
config: Partial<Record<ValidKeys, unknown>>,
sectionKeys?: ValidationSectionKeys
): Promise<ConfigValidationError[]> =>
validationResultErrors(await validateConfig(hass, config), sectionKeys);
export interface ConfigSaveError {
message: string;
errors: ConfigValidationError[];
}
/**
* Reads the errors of a failed config save. Errors that are not about the
* config, like a network error, only have a message.
*/
export const parseConfigSaveError = (err: unknown): ConfigSaveError => {
const { body, error } = (err ?? {}) as { body?: unknown; error?: unknown };
const bodyObject =
body && typeof body === "object"
? (body as { message?: unknown; errors?: unknown })
: undefined;
const message = String(
bodyObject?.message ||
error ||
(typeof body === "string" && body) ||
(err instanceof Error && err.message) ||
err
);
return {
message,
errors: Array.isArray(bodyObject?.errors)
? (bodyObject.errors as ConfigValidationError[])
: [],
};
};
@@ -129,6 +129,7 @@ import "./types/ha-automation-action-wait_template";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
export interface ActionElement extends LitElement {
action: Action;
@@ -498,6 +499,11 @@ export default class HaAutomationActionRow extends LitElement {
${this._runResult?.title}
</ha-automation-row-event-chip>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
<ha-dropdown
@@ -798,6 +804,7 @@ export default class HaAutomationActionRow extends LitElement {
...ACTION_COMBINED_BLOCKS,
].includes(blockType!)}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
.dim=${this._running}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
@@ -27,9 +27,18 @@ import {
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
import {
deviceAutomationErrorText,
deviceErrorText,
} from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
@customElement("ha-automation-action-device_id")
export class HaDeviceAction extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["device_id", "domain"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@@ -131,12 +140,20 @@ export class HaDeviceAction extends LitElement {
}
protected render() {
const deviceError = this._fieldErrors.fieldError("device_id");
const automationError = this._fieldErrors.fieldError("domain");
const deviceId = this._deviceId || this.action.device_id;
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.invalid=${!!deviceError}
.errorMessage=${
deviceError
? deviceErrorText(this.hass.localize, deviceError)
: undefined
}
.disabled=${this.disabled}
@value-changed=${this._devicePicked}
label=${this.hass.localize(
@@ -144,6 +161,16 @@ export class HaDeviceAction extends LitElement {
)}
></ha-device-picker>
<ha-device-action-picker
.invalid=${!!automationError}
.errorMessage=${
automationError
? deviceAutomationErrorText(
this.hass.localize,
"actions",
automationError
)
: undefined
}
.value=${this.action}
.deviceId=${deviceId}
.disabled=${this.disabled}
@@ -12,6 +12,8 @@ import type { ServiceAction } from "../../../../../data/script";
import { serviceActionStruct } from "../../../../../data/script";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
import { targetErrorText } from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-service")
@@ -28,6 +30,10 @@ export class HaServiceAction extends LitElement implements ActionElement {
@state() private _responseChecked = false;
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["target", "data"],
});
public static get defaultConfig(): ServiceAction {
return { action: "", data: {} };
}
@@ -80,6 +86,8 @@ export class HaServiceAction extends LitElement implements ActionElement {
? this._action.action.split(".", 2)
: [undefined, undefined];
const targetError = this._fieldErrors.fieldError("target");
const optionalResponse =
domain && service
? !!this.hass.services[domain]?.[service]?.response?.optional
@@ -92,6 +100,13 @@ export class HaServiceAction extends LitElement implements ActionElement {
.value=${this._action}
.disabled=${this.disabled}
.hidePicker=${!!this._action.metadata}
.targetError=${
targetError
? targetErrorText(this.hass.localize, targetError)
: undefined
}
.dataErrors=${this._fieldErrors.nestedFieldErrors("data")}
.dataError=${this._fieldErrors.ownFieldError("data")?.message}
@value-changed=${this._actionChanged}
></ha-service-control>
${
@@ -7,6 +7,7 @@ import type { WaitAction } from "../../../../../data/script";
import type { HomeAssistant } from "../../../../../types";
import type { ActionElement } from "../ha-automation-action-row";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type {
HaFormSchema,
SchemaUnion,
@@ -15,6 +16,10 @@ import type { TimeoutType } from "../../types";
@customElement("ha-automation-action-wait_template")
export class HaWaitAction extends LitElement implements ActionElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["wait_template", "timeout", "continue_on_timeout"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public action!: WaitAction;
@@ -65,6 +70,7 @@ export class HaWaitAction extends LitElement implements ActionElement {
: "duration";
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${this.action}
.schema=${this._schema(timeoutType, timeout !== undefined)}
@@ -0,0 +1,350 @@
import { createContext } from "@lit/context";
import { ensureArray } from "../../../common/array/ensure-array";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
formatPath,
pathEquals,
pathStartsWith,
type ConfigPath,
} from "../../../common/util/config-path";
import type { ConfigValidationError } from "../../../data/config";
import type {
AutomationPathRow,
AutomationRowRegistry,
} from "./automation-path";
/** The validation errors of the config being edited, paths from its root. */
export const automationErrorsContext =
createContext<ConfigValidationError[]>("automation-errors");
export const NO_ERRORS: ConfigValidationError[] = [];
/**
* The row an error belongs to: the deepest rendered row on its path. Rows
* above it only contain the error, they are expanded to show it.
*/
export const errorOwner = (
registry: AutomationRowRegistry,
error: ConfigValidationError
): AutomationPathRow | undefined => {
const rows = registry.rowsAlong(error.path);
return rows[rows.length - 1];
};
export const ownedErrors = (
registry: AutomationRowRegistry,
errors: ConfigValidationError[],
row: AutomationPathRow
): ConfigValidationError[] => {
const owned = errors.filter((error) => errorOwner(registry, error) === row);
return owned.length ? owned : NO_ERRORS;
};
export const fieldErrors = (
errors: ConfigValidationError[],
path: ConfigPath
): Record<string, string> => {
const fields: Record<string, string> = {};
errors.forEach((error) => {
const field = errorField(error, path);
if (field !== undefined && !(field in fields)) {
fields[field] = error.message;
}
});
return fields;
};
/** The field of the config at `path` an error is about, if any. */
export const errorField = (
error: ConfigValidationError,
path: ConfigPath
): string | undefined =>
error.path.length > path.length && pathStartsWith(error.path, path)
? String(error.path[path.length])
: undefined;
export const errorText = (
error: ConfigValidationError,
path: ConfigPath = []
): string => {
const relative = pathStartsWith(error.path, path)
? error.path.slice(path.length)
: error.path;
return relative.length
? `${formatPath(relative)}: ${error.message}`
: error.message;
};
export const errorsEqual = (
a: ConfigValidationError[],
b: ConfigValidationError[]
): boolean =>
a.length === b.length &&
a.every(
(error, index) =>
error.message === b[index].message &&
error.code === b[index].code &&
pathEquals(error.path, b[index].path)
);
const TRIGGER_LIST_KEYS = ["triggers", "wait_for_trigger"];
const isTriggerSublist = (value: unknown): value is { triggers: unknown } =>
typeof value === "object" &&
value !== null &&
Object.keys(value).length === 1 &&
"triggers" in value;
// Core validates triggers after flattening `triggers:` sublists one level
// deep, so its trigger indexes count the triggers of the flat list.
const flatTriggerPaths = (triggers: unknown[]): ConfigPath[] =>
triggers.flatMap((trigger, index) =>
isTriggerSublist(trigger)
? (ensureArray(trigger.triggers) ?? []).map((_, subIndex) => [
index,
"triggers",
subIndex,
])
: [[index]]
);
const isConfigObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const childAt = (value: unknown, segment: string | number): unknown =>
typeof value === "object" && value !== null
? (value as Record<string | number, unknown>)[segment]
: undefined;
const valueAt = (config: unknown, path: ConfigPath): unknown =>
path.reduce<unknown>(childAt, config);
/** Maps an error path of core's validated config onto the edited config. */
export const configErrorPath = (
config: unknown,
path: ConfigPath
): ConfigPath => {
const result: ConfigPath = [];
let value = config;
for (let index = 0; index < path.length; index++) {
const segment = path[index];
const parentKey = result[result.length - 1];
// Core wraps each action of the `parallel` shorthand in a sequence
if (
segment === "sequence" &&
path[index + 1] === 0 &&
result[result.length - 2] === "parallel" &&
isConfigObject(value) &&
!("sequence" in value)
) {
index++;
continue;
}
const mapped =
typeof segment === "number" &&
Array.isArray(value) &&
TRIGGER_LIST_KEYS.includes(parentKey as string)
? flatTriggerPaths(value)[segment]
: undefined;
(mapped ?? [segment]).forEach((part) => {
result.push(part);
value = childAt(value, part);
});
}
return result;
};
export const mapErrorsToConfig = (
config: unknown,
errors: ConfigValidationError[]
): ConfigValidationError[] =>
errors.map((error) => ({
...error,
path: configErrorPath(config, error.path),
}));
// Rows sit at list items that are objects, not at entries of value lists
// like `entity_id`
const errorItemPath = (config: unknown, path: ConfigPath): ConfigPath => {
let value = config;
let end = 0;
path.forEach((segment, index) => {
value = childAt(value, segment);
if (typeof segment === "number" && isConfigObject(value)) {
end = index + 1;
}
});
return path.slice(0, end);
};
/** A config item with an error, to recognize it after edits. */
export interface KnownErrorItem {
path: ConfigPath;
item?: object;
content?: string;
}
const knownItemAt = (config: unknown, path: ConfigPath): KnownErrorItem => {
const item = valueAt(config, path);
return isConfigObject(item)
? { path, item, content: JSON.stringify(item) }
: { path };
};
export const knownErrorItems = (
config: unknown,
errors: ConfigValidationError[]
): KnownErrorItem[] => {
const known: KnownErrorItem[] = [];
errors.forEach((error) => {
const path = errorItemPath(config, error.path);
if (!known.some((entry) => pathEquals(entry.path, path))) {
known.push(knownItemAt(config, path));
}
});
return known;
};
interface ConfigItems {
paths: Map<object, ConfigPath>;
contents: Map<string, ConfigPath[]>;
}
// The list entries that are objects, the items rows show
const configItems = (config: unknown): ConfigItems => {
const items: ConfigItems = { paths: new Map(), contents: new Map() };
const walk = (value: unknown, path: ConfigPath) => {
if (typeof value !== "object" || value === null) {
return;
}
if (typeof path[path.length - 1] === "number" && isConfigObject(value)) {
items.paths.set(value, path);
const content = JSON.stringify(value);
items.contents.set(content, [
...(items.contents.get(content) ?? []),
path,
]);
}
Object.entries(value).forEach(([key, child]) => {
walk(child, [...path, Array.isArray(value) ? Number(key) : key]);
});
};
walk(config, []);
return items;
};
const followItem = (
entry: KnownErrorItem,
previous: ConfigItems,
current: ConfigItems,
config: unknown
): KnownErrorItem | undefined => {
// Unchanged or moved
const path = entry.item && current.paths.get(entry.item);
if (path) {
return { ...entry, path };
}
// Moved after the YAML was parsed again
const samePaths = entry.content
? current.contents.get(entry.content)
: undefined;
if (samePaths?.length === 1) {
return knownItemAt(config, samePaths[0]);
}
// Identical items, e.g. two copies of a row, keep their place
if (samePaths?.some((other) => pathEquals(other, entry.path))) {
return knownItemAt(config, entry.path);
}
const now = valueAt(config, entry.path);
if (!isConfigObject(now)) {
return undefined;
}
// An item that was already there took the place, e.g. after a delete
const beforePaths = previous.paths.has(now)
? [previous.paths.get(now)!]
: (previous.contents.get(JSON.stringify(now)) ?? []);
if (
beforePaths.length &&
!beforePaths.some((other) => pathEquals(other, entry.path))
) {
return undefined;
}
// Edited in place
return knownItemAt(config, entry.path);
};
/** Follows known items from `previous` to `config` through moves and edits. */
export const followKnownErrorItems = (
known: KnownErrorItem[],
previous: unknown,
config: unknown
): KnownErrorItem[] => {
const previousItems = configItems(previous);
const currentItems = configItems(config);
return known
.map((entry) => followItem(entry, previousItems, currentItems, config))
.filter((entry): entry is KnownErrorItem => entry !== undefined);
};
/**
* Moves errors of `previous` to where their items are in `config`. Errors of
* items that are gone are dropped.
*/
export const followErrors = (
errors: ConfigValidationError[],
previous: unknown,
config: unknown
): ConfigValidationError[] => {
const previousItems = configItems(previous);
const currentItems = configItems(config);
return errors.flatMap((error) => {
const path = errorItemPath(previous, error.path);
const moved = followItem(
knownItemAt(previous, path),
previousItems,
currentItems,
config
);
return moved
? [{ ...error, path: [...moved.path, ...error.path.slice(path.length)] }]
: [];
});
};
/** The errors on items that already had one. */
export const errorsOnKnownItems = (
known: KnownErrorItem[],
config: unknown,
errors: ConfigValidationError[]
): ConfigValidationError[] =>
errors.filter((error) => {
const path = errorItemPath(config, error.path);
return known.some((entry) => pathEquals(entry.path, path));
});
export const targetErrorText = (
localize: LocalizeFunc,
error: ConfigValidationError
): string =>
localize(
error.code === "required"
? "ui.panel.config.automation.editor.errors.target_required"
: "ui.panel.config.automation.editor.errors.target_invalid"
);
export const deviceErrorText = (
localize: LocalizeFunc,
error: ConfigValidationError
): string =>
error.code === "required"
? localize("ui.panel.config.automation.editor.errors.device_required")
: error.message;
export const deviceAutomationErrorText = (
localize: LocalizeFunc,
kind: "triggers" | "conditions" | "actions",
error: ConfigValidationError
): string =>
error.code === "required"
? localize(`ui.panel.config.devices.automation.${kind}.required`)
: error.message;
@@ -0,0 +1,186 @@
import { createContext } from "@lit/context";
import {
consumeContext,
ContextController,
} from "../../../common/decorators/consume";
import { pathEquals, type ConfigPath } from "../../../common/util/config-path";
import type { ConfigValidationError } from "../../../data/config";
import {
automationErrorsContext,
errorField,
errorsEqual,
fieldErrors,
NO_ERRORS,
ownedErrors,
} from "./automation-errors";
import {
automationPathContext,
automationRowRegistryContext,
type AutomationRowRegistry,
} from "./automation-path";
const fieldsEqual = (a: Record<string, string>, b: Record<string, string>) => {
const keys = Object.keys(a);
return (
keys.length === Object.keys(b).length &&
keys.every((key) => a[key] === b[key])
);
};
/**
* @summary The fields whose errors an editor shows next to the field, so
* the sidebar doesn't list them again.
*/
export class InlineErrorFields {
private _fields = new Map<object, string[]>();
constructor(private readonly _onChange: () => void) {}
public set(owner: object, fields: string[]) {
this._fields.set(owner, fields);
this._onChange();
}
public delete(owner: object) {
if (this._fields.delete(owner)) {
this._onChange();
}
}
public has(field: string): boolean {
return [...this._fields.values()].some((fields) => fields.includes(field));
}
}
export const automationInlineErrorFieldsContext =
createContext<InlineErrorFields>("automation-inline-error-fields");
interface AutomationFieldErrorsOptions {
/** Fields the host shows the error of, see `InlineErrorFields`. */
inlineFields?: string[];
}
/**
* @summary Validation errors of the config at the host's path.
*
* Consumes the errors and the path of the closest ancestor and updates the
* host when the errors it exposes change.
*/
export class AutomationFieldErrorsController extends ContextController {
public fields: Record<string, string> = {};
/** Errors that belong to the row at exactly the path, see `errorOwner`. */
public rowErrors: ConfigValidationError[] = NO_ERRORS;
public path: ConfigPath = [];
@consumeContext({ context: automationErrorsContext, subscribe: true })
private _errors: ConfigValidationError[] = NO_ERRORS;
@consumeContext({ context: automationPathContext, subscribe: true })
private _path: ConfigPath = [];
@consumeContext({ context: automationRowRegistryContext })
private _registry?: AutomationRowRegistry;
@consumeContext({ context: automationInlineErrorFieldsContext })
private _inlineErrorFields?: InlineErrorFields;
private readonly _inlineFields?: string[];
private _claimedIn?: InlineErrorFields;
constructor(
host: ConstructorParameters<typeof ContextController>[0],
options: AutomationFieldErrorsOptions = {}
) {
super(host);
this._inlineFields = options.inlineFields;
}
private _nestedFieldErrors = new Map<string, Record<string, string>>();
/** First error message of each key inside a field, keyed by the key. */
public nestedFieldErrors(field: string): Record<string, string> {
const errors = fieldErrors(this._errors, [...this._path, field]);
const previous = this._nestedFieldErrors.get(field);
// The same object while nothing changed, so the host's child doesn't update
if (previous && fieldsEqual(previous, errors)) {
return previous;
}
this._nestedFieldErrors.set(field, errors);
return errors;
}
/** The error of a field as a whole, not of something inside it. */
public ownFieldError(field: string): ConfigValidationError | undefined {
return this._errors.find((error) =>
pathEquals(error.path, [...this._path, field])
);
}
/** The first error of a field of the config at the path. */
public fieldError(field: string): ConfigValidationError | undefined {
return this._errors.find(
(error) => errorField(error, this._path) === field
);
}
private _unsubscribeRegistry?: () => void;
public hostConnected() {
this._unsubscribeRegistry ??= this._registry?.subscribe(() => this._sync());
this._claimInlineFields();
this._sync();
}
public hostDisconnected() {
this._unsubscribeRegistry?.();
this._unsubscribeRegistry = undefined;
this._claimedIn?.delete(this);
this._claimedIn = undefined;
}
protected contextUpdated() {
this._claimInlineFields();
this._sync();
}
private _claimInlineFields() {
if (
!this._inlineFields ||
!this._inlineErrorFields ||
this._claimedIn === this._inlineErrorFields
) {
return;
}
this._claimedIn?.delete(this);
this._inlineErrorFields.set(this, this._inlineFields);
this._claimedIn = this._inlineErrorFields;
}
private _sync() {
const fields = this._errors.length
? fieldErrors(this._errors, this._path)
: {};
const row =
this._errors.length && this._path.length
? this._registry?.rowAt(this._path)
: undefined;
const rowErrors =
row && this._registry
? ownedErrors(this._registry, this._errors, row)
: NO_ERRORS;
if (
this.path === this._path &&
fieldsEqual(fields, this.fields) &&
errorsEqual(rowErrors, this.rowErrors)
) {
return;
}
this.path = this._path;
this.fields = fields;
this.rowErrors = rowErrors;
this.host.requestUpdate();
}
}
@@ -9,6 +9,13 @@ import {
stringifyPath,
type ConfigPath,
} from "../../../common/util/config-path";
import type { ConfigValidationError } from "../../../data/config";
import {
automationErrorsContext,
errorsEqual,
NO_ERRORS,
ownedErrors,
} from "./automation-errors";
export const automationPathContext =
createContext<ConfigPath>("automation-path");
@@ -34,14 +41,19 @@ export class AutomationRowRegistry {
{ path: ConfigPath; rows: AutomationPathRow[] }
>();
private _listeners = new Set<() => void>();
private _notifyScheduled = false;
public register(path: ConfigPath, row: AutomationPathRow) {
const key = stringifyPath(path);
const entry = this._rows.get(key);
if (!entry) {
this._rows.set(key, { path, rows: [row] });
return;
} else {
entry.rows = [...entry.rows.filter((claimed) => claimed !== row), row];
}
entry.rows = [...entry.rows.filter((claimed) => claimed !== row), row];
this._changed();
}
public unregister(path: ConfigPath, row: AutomationPathRow) {
@@ -54,6 +66,33 @@ export class AutomationRowRegistry {
if (!entry.rows.length) {
this._rows.delete(key);
}
this._changed();
}
/**
* Calls `listener` after rows were registered or unregistered, once for all
* changes made in the same task. Returns the function to unsubscribe.
*/
public subscribe(listener: () => void): () => void {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
}
private _changed() {
if (this._notifyScheduled || !this._listeners.size) {
return;
}
this._notifyScheduled = true;
queueMicrotask(() => {
this._notifyScheduled = false;
this._listeners.forEach((listener) => listener());
});
}
public rowAt(path: ConfigPath): AutomationPathRow | undefined {
return owner(this._rows.get(stringifyPath(path))?.rows);
}
public pathOf(row: AutomationPathRow): ConfigPath | undefined {
@@ -61,6 +100,11 @@ export class AutomationRowRegistry {
?.path;
}
public selectedPath(): ConfigPath | undefined {
return [...this._rows.values()].find((entry) => owner(entry.rows)?.selected)
?.path;
}
/** The rows containing `path`, outermost first. */
public rowsAlong(path: ConfigPath): AutomationPathRow[] {
return path
@@ -83,11 +127,19 @@ interface AutomationPathControllerOptions {
/**
* Consumes the path of the closest ancestor and provides it extended by the
* host's own segment to its descendants.
* host's own segment to its descendants. For a row, also finds the validation
* errors the row owns and updates the host when they change.
*/
export class AutomationPathController extends ContextController {
public path: ConfigPath = [];
public errors: ConfigValidationError[] = NO_ERRORS;
@consumeContext({ context: automationErrorsContext, subscribe: true })
private _configErrors: ConfigValidationError[] = NO_ERRORS;
private _unsubscribeRegistry?: () => void;
@consumeContext({ context: automationPathContext, subscribe: true })
private _parentPath: ConfigPath = [];
@@ -124,6 +176,12 @@ export class AutomationPathController extends ContextController {
public hostConnected() {
// A row moved by a drag reconnects without an update
this._sync();
if (this._options.row && this._registry && !this._unsubscribeRegistry) {
// Another row on the path of an error can take it over or give it back
this._unsubscribeRegistry = this._registry.subscribe(() =>
this._syncErrors()
);
}
}
private _sync() {
@@ -133,12 +191,26 @@ export class AutomationPathController extends ContextController {
this._provider.setValue(path);
}
this._syncRegistration();
this._syncErrors();
}
public hostDisconnected() {
this._unsubscribeRegistry?.();
this._unsubscribeRegistry = undefined;
this._unregister();
}
private _syncErrors() {
const errors =
this._registeredRow && this._registry && this._configErrors.length
? ownedErrors(this._registry, this._configErrors, this._registeredRow)
: NO_ERRORS;
if (!errorsEqual(errors, this.errors)) {
this.errors = errors;
this.host.requestUpdate();
}
}
private _syncRegistration() {
if (!this._registry || !this._options.row) {
return;
@@ -0,0 +1,27 @@
import { mdiAlertCircle } from "@mdi/js";
import { html, nothing } from "lit";
import type { ConfigPath } from "../../../common/util/config-path";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { ConfigValidationError } from "../../../data/config";
import { errorText } from "./automation-errors";
export const renderRowErrorIcon = (
errors: ConfigValidationError[],
path: ConfigPath,
label: string
) =>
errors.length
? html`<ha-svg-icon
id="error-icon"
slot="icons"
tabindex="0"
class="error-indicator"
.path=${mdiAlertCircle}
role="img"
aria-label=${label}
></ha-svg-icon>
<ha-tooltip for="error-icon" slot="icons">
${errors.map((error) => html`<p>${errorText(error, path)}</p>`)}
</ha-tooltip>`
: nothing;
@@ -97,6 +97,7 @@ import "./types/ha-automation-condition-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
export interface ConditionElement extends LitElement {
condition: Condition;
@@ -262,6 +263,11 @@ export default class HaAutomationConditionRow extends LitElement {
)}
</ha-automation-row-event-chip>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
<ha-dropdown
@@ -529,6 +535,7 @@ export default class HaAutomationConditionRow extends LitElement {
this.condition.condition
)}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
.dim=${this._testing}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
@@ -27,9 +27,18 @@ import {
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
import {
deviceAutomationErrorText,
deviceErrorText,
} from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
@customElement("ha-automation-condition-device")
export class HaDeviceCondition extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["device_id", "domain"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Object }) public condition!: DeviceCondition;
@@ -132,12 +141,20 @@ export class HaDeviceCondition extends LitElement {
}
protected render() {
const deviceError = this._fieldErrors.fieldError("device_id");
const automationError = this._fieldErrors.fieldError("domain");
const deviceId = this._deviceId || this.condition.device_id;
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.invalid=${!!deviceError}
.errorMessage=${
deviceError
? deviceErrorText(this.hass.localize, deviceError)
: undefined
}
@value-changed=${this._devicePicked}
.disabled=${this.disabled}
.label=${this.hass.localize(
@@ -145,6 +162,16 @@ export class HaDeviceCondition extends LitElement {
)}
></ha-device-picker>
<ha-device-condition-picker
.invalid=${!!automationError}
.errorMessage=${
automationError
? deviceAutomationErrorText(
this.hass.localize,
"conditions",
automationError
)
: undefined
}
.value=${this.condition}
.deviceId=${deviceId}
@value-changed=${this._deviceConditionPicked}
@@ -34,6 +34,9 @@ import {
} from "../../../../../data/target";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
import "../../../../../components/ha-input-helper-text";
import { targetErrorText } from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import { targetExpandGroupContext } from "../../../../../components/target-picker/target-expand-group-context";
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
@@ -53,6 +56,10 @@ export class HaPlatformCondition extends LitElement {
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["target"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: PlatformCondition;
@@ -234,6 +241,7 @@ export class HaPlatformCondition extends LitElement {
></ha-selector>`
: nothing
}
${this._renderTargetError()}
${
shouldRenderDataYaml
? html`<ha-yaml-editor
@@ -258,6 +266,15 @@ export class HaPlatformCondition extends LitElement {
`;
}
private _renderTargetError() {
const error = this._fieldErrors.fieldError("target");
return error
? html`<ha-input-helper-text class="target-error">
${targetErrorText(this.hass.localize, error)}
</ha-input-helper-text>`
: nothing;
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector["target"] | null | undefined) =>
targetSelector ? { target: { ...targetSelector } } : { target: {} }
@@ -680,6 +697,10 @@ export class HaPlatformCondition extends LitElement {
1px solid var(--divider-color)
);
}
.target-error {
color: var(--ha-color-on-danger-quiet);
padding-bottom: var(--ha-space-2);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -17,6 +17,7 @@ import { ensureArray } from "../../../../../common/array/ensure-array";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { StateCondition } from "../../../../../data/automation";
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
@@ -71,6 +72,10 @@ const SCHEMA = memoizeOne(
@customElement("ha-automation-condition-state")
export class HaStateCondition extends LitElement implements ConditionElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["entity_id", "attribute", "state", "for"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: StateCondition;
@@ -118,6 +123,7 @@ export class HaStateCondition extends LitElement implements ConditionElement {
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA(hasAttribute)}
@@ -56,7 +56,6 @@ import {
triggerAutomationActions,
} from "../../../data/automation";
import { substituteBlueprint } from "../../../data/blueprint";
import { validateConfig } from "../../../data/config";
import { UNAVAILABLE } from "../../../data/entity/entity";
import {
type EntityRegistryEntry,
@@ -71,7 +70,7 @@ import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { haStyle } from "../../../resources/styles";
import type { Entries, ValueChangedEvent } from "../../../types";
import type { ValueChangedEvent } from "../../../types";
import { isMac } from "../../../util/is_mac";
import { showEditorToast } from "./editor-toast";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
@@ -159,7 +158,14 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
const { id, ...rest } = normalizeAutomationConfig(config, report);
return { id, ...rest };
},
checkValidation: () => this._checkValidation(),
validationSections: (config) =>
"use_blueprint" in config
? {}
: {
triggers: config.triggers,
conditions: config.conditions,
actions: config.actions,
},
};
private _undoRedoController = new UndoRedoController<AutomationConfig>(this, {
@@ -501,16 +507,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
>
${
this.errors
? html`<ha-alert
alert-type="error"
slot="alerts"
>
${this.errors}
</ha-alert>`
: nothing
}
${this.renderConfigErrorsAlert("alerts")}
</blueprint-automation-editor>
`
: html`
@@ -522,6 +519,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
.config=${this.config}
.disabled=${this.readOnly}
.saving=${this.saving}
.configErrors=${this.configErrors}
@value-changed=${this._valueChanged}
@save-automation=${this._handleSaveAutomation}
@editor-save=${this._handleSaveAutomation}
@@ -530,26 +528,17 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
<div class="alert-wrapper" slot="alerts">
${this._renderDeprecatedMigratedAlert()}
${
this.errors || stateObj?.state === UNAVAILABLE
stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
.title=${
stateObj?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.automation.editor.unavailable"
)
: undefined
}
.title=${this.hass.localize(
"ui.panel.config.automation.editor.unavailable"
)}
>
${this.errors || this.validationErrors}
${
stateObj?.state === UNAVAILABLE
? html`<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>`
: nothing
}
<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>
</ha-alert>`
: nothing
}
@@ -628,6 +617,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
`
: this.mode === "yaml"
? html`${this._renderDeprecatedMigratedAlert()}
${this.renderConfigErrorsAlert()}
${
stateObj?.state === "off"
? html`
@@ -747,7 +737,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._checkValidation();
this.checkValidation();
});
this.currentEntityId = this.entityId;
this.readOnly = true;
@@ -775,32 +765,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this.currentEntityId = automation?.entity_id;
}
private async _checkValidation() {
this.validationErrors = undefined;
if (!this.currentEntityId || !this.config) {
return;
}
const stateObj = this.hass.states[this.currentEntityId];
if (stateObj?.state !== UNAVAILABLE) {
return;
}
const validation = await validateConfig(this.hass, {
triggers: this.config.triggers,
conditions: this.config.conditions,
actions: this.config.actions,
});
this.validationErrors = (
Object.entries(validation) as Entries<typeof validation>
).map(([key, value]) =>
value.valid
? ""
: html`${this.hass.localize(
`ui.panel.config.automation.editor.${key}.name`
)}:
${value.error}<br />`
);
}
private _valueChanged(ev: ValueChangedEvent<AutomationConfig>) {
ev.stopPropagation();
const config = ev.detail.value;
@@ -830,7 +794,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
private _showInfo() {
@@ -920,7 +883,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
protected async confirmUnsavedChanged(addHistory = true): Promise<boolean> {
@@ -1001,9 +963,8 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._yamlEditor?.setValue(this.config);
}
this.readOnly = true;
this.errors = undefined;
} catch (err: any) {
this.errors = err.message;
showEditorToast(this, { message: err.message });
}
}
@@ -1116,7 +1077,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private async _saveAutomation(id): Promise<void> {
this.saving = true;
this.validationErrors = undefined;
const config = this.config!;
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
if (this.entityRegistryUpdate !== undefined && !this.currentEntityId) {
@@ -1127,7 +1088,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
try {
await saveAutomationConfig(this.hass, id, this.config!);
await saveAutomationConfig(this.hass, id, config);
if (this.entityRegistryUpdate !== undefined) {
let entityId = this.currentEntityId;
@@ -1174,11 +1135,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._markDirtyStateClean();
this.deprecatedConfigMigrated = false;
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
message: errors.body?.message || errors.error || errors.body,
});
this.clearConfigErrors();
} catch (errors: unknown) {
this.showSaveErrors(errors, config);
throw errors;
} finally {
this.saving = false;
@@ -4,16 +4,26 @@ import type {
PropertyValues,
TemplateResult,
} from "lit";
import { css, html } from "lit";
import { property, state } from "lit/decorators";
import { css, html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { property, query, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
import { transform } from "../../../common/decorators/transform";
import { goBack, navigate } from "../../../common/navigate";
import { debounce } from "../../../common/util/debounce";
import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-alert";
import "../../../components/ha-spinner"; // used by renderLoading() provided to both editors
import type { AutomationMigrationReport } from "../../../data/automation";
import {
parseConfigSaveError,
validateConfigErrors,
type ConfigValidationError,
type ValidationSectionKeys,
} from "../../../data/config";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
showAlertDialog,
@@ -22,7 +32,28 @@ import {
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import type { Constructor, HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import {
errorsEqual,
errorsOnKnownItems,
errorText,
followErrors,
followKnownErrorItems,
knownErrorItems,
mapErrorsToConfig,
NO_ERRORS,
type KnownErrorItem,
} from "./automation-errors";
import type { EntityRegistryUpdate } from "./automation-save-dialog/show-dialog-automation-save";
import { showEditorToast } from "./editor-toast";
const CONFIG_ERRORS_TOAST_ID = "config-errors";
const REVALIDATE_DELAY = 500;
interface ErrorsEditorElement extends HTMLElement {
revealErrors(): Promise<void>;
}
/** Minimum config shape shared by both AutomationConfig and ScriptConfig. */
interface BaseEditorConfig {
@@ -57,6 +88,10 @@ export const automationScriptEditorStyles: CSSResult = css`
p {
margin-bottom: 0;
}
.config-errors p {
margin: 0;
white-space: pre-wrap;
}
ha-button[slot="fab"] {
position: fixed;
right: calc(16px + var(--safe-area-inset-right, 0px));
@@ -82,7 +117,11 @@ export const automationScriptEditorStyles: CSSResult = css`
export interface EditorDomainHooks<TConfig> {
fetchFileConfig(hass: HomeAssistant, id: string): Promise<TConfig>;
normalizeConfig(raw: TConfig, report?: AutomationMigrationReport): TConfig;
checkValidation(): Promise<void>;
/** The parts of the config `validate_config` checks, none for blueprints. */
validationSections(
config: TConfig
): Partial<Record<"triggers" | "conditions" | "actions", unknown>>;
validationSectionKeys?: ValidationSectionKeys;
domain: "automation" | "script";
}
@@ -111,8 +150,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@consume({ context: fullEntitiesContext, subscribe: true })
entityRegistry?: EntityRegistryEntry[];
@state() protected errors?: string;
@state() protected yamlErrors?: string;
@state() protected currentEntityId?: string;
@@ -125,7 +162,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@state() protected deprecatedConfigMigrated = false;
@state() protected validationErrors?: (string | TemplateResult)[];
@state() protected configErrors: ConfigValidationError[] = NO_ERRORS;
@state() protected config?: TConfig;
@@ -157,6 +194,14 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
private _relatedContextAreaId?: string;
@query("manual-automation-editor, manual-script-editor")
private _errorsEditor?: ErrorsEditorElement;
// Validation responses of an older run are dropped
private _validationRun = 0;
private _knownErrorItems: KnownErrorItem[] = [];
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
@@ -167,6 +212,212 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
}
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("config") && this.configErrors.length) {
this._knownErrorItems = followKnownErrorItems(
this._knownErrorItems,
changedProps.get("config"),
this.config
);
this._revalidate();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._revalidate.cancel();
if (this.configErrors.length) {
this._closeErrorsToast();
}
}
/** Shows the errors of saving `submitted`, the config sent to core. */
protected async showSaveErrors(err: unknown, submitted: TConfig) {
const { message, errors } = parseConfigSaveError(err);
if (!errors.length) {
this._showSaveFailedToast(message);
return;
}
this._validationRun++;
const submittedErrors = mapErrorsToConfig(submitted, errors);
// Edits made while saving moved or removed some of the items
const changed = submitted !== this.config;
this._setConfigErrors(
this.config,
changed
? followErrors(submittedErrors, submitted, this.config)
: submittedErrors
);
if (changed) {
// Followed errors miss items that were renamed and moved, so check
// everything again
await this._validateConfig(false);
}
this._showSaveFailedToast(
errors.length === 1
? errors[0].message
: this.hass.localize(
"ui.panel.config.automation.editor.errors.problems_found",
{ count: errors.length }
)
);
await this._revealErrors();
}
protected async checkValidation() {
if (!this.currentEntityId || !this.config) {
return;
}
if (this.hass.states[this.currentEntityId]?.state !== UNAVAILABLE) {
return;
}
const config = this.config;
const sections = this.domainHooks.validationSections(config);
if (!Object.keys(sections).length) {
return;
}
const run = ++this._validationRun;
let errors: ConfigValidationError[];
try {
errors = await validateConfigErrors(
this.hass,
sections,
this.domainHooks.validationSectionKeys
);
} catch (_err: unknown) {
// The unavailable alert still shows, only the rows stay unmarked
return;
}
if (run !== this._validationRun || config !== this.config) {
return;
}
this._setConfigErrors(config, mapErrorsToConfig(config, errors));
}
private _setConfigErrors(
config: TConfig | undefined,
errors: ConfigValidationError[]
) {
this.configErrors = errors;
this._knownErrorItems = knownErrorItems(config, errors);
}
private async _revealErrors() {
await this.updateComplete;
if (this.mode === "gui") {
await this._errorsEditor?.revealErrors();
}
}
/** All errors, for the editors that have no rows to show them on. */
protected renderConfigErrorsAlert(slot?: string) {
return this.configErrors.length
? html`<ha-alert
alert-type="error"
class="config-errors"
slot=${ifDefined(slot)}
>
${this.configErrors.map((error) => html`<p>${errorText(error)}</p>`)}
</ha-alert>`
: nothing;
}
private _showSaveFailedToast(problems: string) {
showEditorToast(this, {
id: CONFIG_ERRORS_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.automation.editor.errors.save_failed",
{ problems }
),
});
}
private _closeErrorsToast() {
// A removed editor can't reach the toast manager by bubbling
const host = this.isConnected
? this
: document.querySelector<HTMLElement>("home-assistant");
if (!host) {
return;
}
showToast(host, {
id: CONFIG_ERRORS_TOAST_ID,
message: "",
duration: 0,
});
}
// Updates the errors of the items that had one, so fixed problems clear
// while editing. New problems elsewhere, and errors outside the validated
// sections like an invalid mode, wait for the next save.
private _revalidate = debounce(() => {
this._validateConfig(true);
}, REVALIDATE_DELAY);
/**
* Validates the sections of the current config. With `onlyKnown`, only
* errors on items that already had one are kept.
*/
private async _validateConfig(onlyKnown: boolean) {
this._revalidate.cancel();
const config = this.config;
if (!config || (onlyKnown && !this.configErrors.length)) {
return;
}
const sections = this.domainHooks.validationSections(config);
// Blueprint inputs can't be validated on their own, so their errors
// only last until the next edit
if (!Object.keys(sections).length) {
this.clearConfigErrors();
return;
}
const sectionKeys = this.domainHooks.validationSectionKeys ?? {};
const run = ++this._validationRun;
let sectionErrors: ConfigValidationError[];
try {
sectionErrors = await validateConfigErrors(
this.hass,
sections,
sectionKeys
);
} catch (_err: unknown) {
return;
}
if (run !== this._validationRun || config !== this.config) {
return;
}
const mapped = mapErrorsToConfig(config, sectionErrors);
const validated = Object.keys(sections).map(
(key) => sectionKeys[key] ?? key
);
const errors = [
...this.configErrors.filter(
(error) => !validated.includes(error.path[0] as string)
),
...(onlyKnown
? errorsOnKnownItems(this._knownErrorItems, config, mapped)
: mapped),
];
if (!errors.length) {
this.clearConfigErrors();
return;
}
this._setConfigErrors(
config,
errorsEqual(errors, this.configErrors) ? this.configErrors : errors
);
}
protected clearConfigErrors() {
this._validationRun++;
this._revalidate.cancel();
if (this.configErrors.length) {
this.configErrors = NO_ERRORS;
this._closeErrorsToast();
}
}
private _setRelatedContext(): void {
const areaId = this.currentEntityId
? this.entityRegistry?.find(
@@ -277,6 +528,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected async loadConfig(id: string) {
const hooks = this.domainHooks;
const domain = hooks.domain;
this.clearConfigErrors();
try {
const config = await hooks.fetchFileConfig(this.hass, id);
if (!this.isConnected) {
@@ -296,7 +548,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
hooks.checkValidation();
this.checkValidation();
} catch (err: any) {
if (!this.isConnected) {
return;
@@ -1,3 +1,4 @@
import { ContextProvider } from "@lit/context";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { tinykeys } from "tinykeys";
@@ -5,6 +6,7 @@ import { consume } from "../../../common/decorators/consume";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeRTL } from "../../../common/util/compute_rtl";
import { pathEquals } from "../../../common/util/config-path";
import "../../../components/ha-snap-bottom-sheet";
import type { HaSnapBottomSheet } from "../../../components/ha-snap-bottom-sheet";
import {
@@ -22,6 +24,11 @@ import { manifestsContext } from "../../../data/context";
import type { DomainManifestLookup } from "../../../data/integration";
import { isTriggerList } from "../../../data/trigger";
import type { HomeAssistant } from "../../../types";
import {
automationPathContext,
automationRowRegistryContext,
type AutomationRowRegistry,
} from "./automation-path";
import "./sidebar/ha-automation-sidebar-action";
import type HaAutomationSidebarAction from "./sidebar/ha-automation-sidebar-action";
import "./sidebar/ha-automation-sidebar-condition";
@@ -61,6 +68,17 @@ export default class HaAutomationSidebar extends LitElement {
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: DomainManifestLookup;
@consume({ context: automationRowRegistryContext })
private _registry?: AutomationRowRegistry;
// Editors in the sidebar are not inside their row, so they get its path here
private readonly _pathProvider = new ContextProvider(this, {
context: automationPathContext,
initialValue: [],
});
private _unsubscribeRegistry?: () => void;
@query("ha-snap-bottom-sheet")
private _bottomSheetElement?: HaSnapBottomSheet;
@@ -83,6 +101,28 @@ export default class HaAutomationSidebar extends LitElement {
private _tinykeysUnsub?: () => void;
public connectedCallback() {
super.connectedCallback();
// A row dragged to another place while it is open changes its path
this._unsubscribeRegistry = this._registry?.subscribe(() =>
this._syncPath()
);
}
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (changedProperties.has("config")) {
this._syncPath();
}
}
private _syncPath() {
const path = this.config ? (this._registry?.selectedPath() ?? []) : [];
if (!pathEquals(path, this._pathProvider.value)) {
this._pathProvider.setValue(path);
}
}
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (
@@ -105,6 +145,8 @@ export default class HaAutomationSidebar extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeRegistry?.();
this._unsubscribeRegistry = undefined;
this._unregisterResizeHandlers();
this._tinykeysUnsub?.();
}
@@ -2,8 +2,8 @@ import { provide } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import {
html,
nothing,
type LitElement,
type nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
@@ -16,6 +16,7 @@ import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { nextRender } from "../../../common/util/render-status";
import {
extractSearchParam,
removeSearchParam,
@@ -24,6 +25,7 @@ import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type { SidebarConfig } from "../../../data/automation";
import type { ConfigValidationError } from "../../../data/config";
import {
dirtyStateContext,
type DirtyStateContext,
@@ -34,10 +36,19 @@ import type {
ValueChangedEvent,
} from "../../../types";
import { showToast } from "../../../util/toast";
import "../../../components/ha-alert";
import {
automationErrorsContext,
errorOwner,
errorText,
NO_ERRORS,
} from "./automation-errors";
import {
AutomationRowRegistry,
automationRowRegistryContext,
type AutomationPathRow,
} from "./automation-path";
import { scrollRowIntoView } from "./scroll-row-into-view";
import "./ha-automation-sidebar";
import type HaAutomationSidebar from "./ha-automation-sidebar";
@@ -90,6 +101,10 @@ export const ManualEditorMixin = <TConfig>(
@provide({ context: automationRowRegistryContext })
protected rowRegistry = new AutomationRowRegistry();
@provide({ context: automationErrorsContext })
@property({ attribute: false })
public configErrors: ConfigValidationError[] = NO_ERRORS;
declare protected collapsableElements?: NodeListOf<
Element & {
expandAll(): void;
@@ -103,16 +118,44 @@ export const ManualEditorMixin = <TConfig>(
throw new Error("Not implemented");
};
private _unsubscribeRows?: () => void;
// Rows register while they render, so errors only count as without a
// row once the rows had a frame to render
@state() private _rowsRendered = false;
public connectedCallback() {
super.connectedCallback();
window.addEventListener("paste", this.handlePaste);
// Which errors have a row changes as rows render
this._unsubscribeRows = this.rowRegistry.subscribe(() => {
if (this.configErrors.length) {
this.requestUpdate();
}
});
}
public disconnectedCallback() {
window.removeEventListener("paste", this.handlePaste);
this._unsubscribeRows?.();
this._unsubscribeRows = undefined;
super.disconnectedCallback();
}
private _renderUnownedErrors() {
if (!this._rowsRendered) {
return nothing;
}
const unowned = this.configErrors.filter(
(error) => !errorOwner(this.rowRegistry, error)
);
return unowned.length
? html`<ha-alert alert-type="error" class="unowned-errors">
${unowned.map((error) => html`<p>${errorText(error)}</p>`)}
</ha-alert>`
: nothing;
}
protected renderContent(): TemplateResult | typeof nothing {
throw new Error("Not implemented");
}
@@ -141,7 +184,7 @@ export const ManualEditorMixin = <TConfig>(
>
<div class="content">
<slot name="alerts"></slot>
${this.renderContent()}
${this._renderUnownedErrors()} ${this.renderContent()}
</div>
<div class="fab-positioner">
<ha-button
@@ -179,6 +222,9 @@ export const ManualEditorMixin = <TConfig>(
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
nextRender().then(() => {
this._rowsRendered = true;
});
this.style.setProperty(
"--sidebar-dynamic-width",
@@ -303,6 +349,54 @@ export const ManualEditorMixin = <TConfig>(
});
}
/**
* Expands the rows along the path of every error, then scrolls to the
* first one and opens it in the sidebar. Does nothing while a row with an
* error is open, so the user stays where they are.
*/
public async revealErrors() {
await this.updateComplete;
const errorRows = () =>
this.configErrors.map((error) =>
this.rowRegistry.rowsAlong(error.path)
);
const owners = (rows: AutomationPathRow[][]) =>
rows
.map((along) => along[along.length - 1])
.filter((owner) => owner !== undefined);
let rows = errorRows();
if (owners(rows).some((owner) => owner.selected)) {
return;
}
const depthOf = (along: AutomationPathRow[][]) =>
along.reduce((sum, path) => sum + path.length, 0);
// Collapsed rows don't render their children, so expand one level at a
// time until no deeper row shows up
let depth = -1;
while (depthOf(rows) > depth) {
depth = depthOf(rows);
rows.flat().forEach((row) => row.expand());
// eslint-disable-next-line no-await-in-loop
await nextRender();
rows = errorRows();
}
// Errors aren't in config order, so take the topmost row
const first = owners(rows).reduce<AutomationPathRow | undefined>(
(top, row) =>
!top ||
row.getBoundingClientRect().top < top.getBoundingClientRect().top
? row
: top,
undefined
);
if (!first) {
return;
}
first.openSidebar();
await nextRender();
scrollRowIntoView(first);
}
public expandAll() {
this.collapsableElements?.forEach((element) => {
element.expandAll();
@@ -65,6 +65,7 @@ import {
import "../trigger/ha-automation-trigger-references";
import { getUiSupportWarnings, isConfigObject } from "../ui-support";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
@customElement("ha-automation-option-row")
export default class HaAutomationOptionRow extends LitElement {
@@ -272,6 +273,11 @@ export default class HaAutomationOptionRow extends LitElement {
}
</h3>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
${
@@ -502,6 +508,7 @@ export default class HaAutomationOptionRow extends LitElement {
.collapsed=${this._collapsed}
.selected=${this._selected}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
@delete-row=${this._removeOption}
@@ -1,3 +1,4 @@
import { provide } from "@lit/context";
import { mdiClose, mdiDotsVertical } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
@@ -12,6 +13,13 @@ import "../../../../components/ha-icon-button";
import { ScrollableFadeMixin } from "../../../../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../../components/ha-alert";
import { errorField, errorText } from "../automation-errors";
import {
automationInlineErrorFieldsContext,
AutomationFieldErrorsController,
InlineErrorFields,
} from "../automation-field-errors";
import "../ha-automation-editor-warning";
export interface SidebarOverflowMenuEntry {
@@ -44,11 +52,22 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
@query(".card-content") private _contentElement!: HTMLDivElement;
private readonly _errors = new AutomationFieldErrorsController(this);
@provide({ context: automationInlineErrorFieldsContext })
protected inlineErrorFields = new InlineErrorFields(() =>
this.requestUpdate()
);
protected get scrollableElement(): HTMLElement | null {
return this._contentElement;
}
protected render() {
const errors = this._errors.rowErrors.filter((error) => {
const field = errorField(error, this._errors.path);
return field === undefined || !this.inlineErrorFields.has(field);
});
return html`
<ha-card
outlined
@@ -90,6 +109,15 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
</ha-automation-editor-warning>`
: nothing
}
${
errors.length
? html`<ha-alert alert-type="error" class="errors">
${errors.map(
(error) => html`<p>${errorText(error, this._errors.path)}</p>`
)}
</ha-alert>`
: nothing
}
<div class="card-content ha-scrollbar">
<slot></slot>
${this.renderScrollableFades(this.isWide)}
@@ -148,6 +176,15 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
padding-bottom: max(var(--safe-area-inset-bottom, 0px), 32px);
}
.errors {
margin: 0 var(--ha-space-4) var(--ha-space-4);
}
.errors p {
margin: 0;
white-space: pre-wrap;
}
.fade-top {
top: var(--ha-space-17);
}
+22
View File
@@ -124,6 +124,19 @@ export const rowStyles = css`
white-space: pre-wrap;
margin: 0;
}
.error-indicator {
color: var(--ha-color-on-danger-quiet);
padding: var(--ha-space-3);
}
.error-indicator + ha-tooltip::part(body) {
cursor: default;
max-width: 300px;
}
.error-indicator + ha-tooltip p {
white-space: pre-wrap;
margin: 0;
}
`;
export const editorStyles = css`
@@ -280,6 +293,15 @@ export const manualEditorStyles = css`
.header a {
color: var(--secondary-text-color);
}
.unowned-errors {
display: block;
margin-bottom: var(--ha-space-2);
}
.unowned-errors p {
margin: 0;
white-space: pre-wrap;
}
`;
export const automationRowsStyles = css`
@@ -121,6 +121,7 @@ import "./types/ha-automation-trigger-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
import { renderRowErrorIcon } from "../automation-row-error-icon";
import { targetExpandGroupContext } from "../../../../components/target-picker/target-expand-group-context";
export interface TriggerElement extends LitElement {
@@ -400,6 +401,11 @@ export default class HaAutomationTriggerRow extends LitElement {
)}
</ha-automation-row-event-chip>
${renderRowErrorIcon(
this.pathController.errors,
this.pathController.path,
this.hass.localize("ui.panel.config.automation.editor.errors.label")
)}
<slot name="icons" slot="icons"></slot>
<ha-dropdown
@@ -664,6 +670,7 @@ export default class HaAutomationTriggerRow extends LitElement {
.selected=${this._selected}
.highlight=${this.highlight}
.sortSelected=${this.sortSelected}
.error=${this.pathController.errors.length > 0}
.dim=${this._triggered}
@click=${this._toggleSidebar}
>${
@@ -29,9 +29,18 @@ import {
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
import {
deviceAutomationErrorText,
deviceErrorText,
} from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
@customElement("ha-automation-trigger-device")
export class HaDeviceTrigger extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["device_id", "domain"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Object }) public trigger!: DeviceTrigger;
@@ -136,12 +145,20 @@ export class HaDeviceTrigger extends LitElement {
}
protected render() {
const deviceError = this._fieldErrors.fieldError("device_id");
const automationError = this._fieldErrors.fieldError("domain");
const deviceId = this._deviceId || this.trigger.device_id;
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.invalid=${!!deviceError}
.errorMessage=${
deviceError
? deviceErrorText(this.hass.localize, deviceError)
: undefined
}
@value-changed=${this._devicePicked}
.disabled=${this.disabled}
.label=${this.hass.localize(
@@ -149,6 +166,16 @@ export class HaDeviceTrigger extends LitElement {
)}
></ha-device-picker>
<ha-device-trigger-picker
.invalid=${!!automationError}
.errorMessage=${
automationError
? deviceAutomationErrorText(
this.hass.localize,
"triggers",
automationError
)
: undefined
}
.value=${this.trigger}
.deviceId=${deviceId}
@value-changed=${this._deviceTriggerPicked}
@@ -4,6 +4,7 @@ import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
@@ -183,6 +184,17 @@ const SCHEMA = [
@customElement("ha-automation-trigger-numeric_state")
export class HaNumericStateTrigger extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: [
"entity_id",
"attribute",
"value_template",
"above",
"below",
"for",
],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: NumericStateTrigger;
@@ -230,6 +242,7 @@ export class HaNumericStateTrigger extends LitElement {
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
@@ -23,6 +23,9 @@ import {
} from "../../../../../data/trigger";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
import "../../../../../components/ha-input-helper-text";
import { targetErrorText } from "../../automation-errors";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import { targetExpandGroupContext } from "../../../../../components/target-picker/target-expand-group-context";
const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
@@ -43,6 +46,10 @@ export class HaPlatformTrigger extends LitElement {
@provide({ context: targetExpandGroupContext })
protected _targetExpandGroup = false;
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["target"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: PlatformTrigger;
@@ -226,6 +233,7 @@ export class HaPlatformTrigger extends LitElement {
></ha-selector>`
: nothing
}
${this._renderTargetError()}
${
shouldRenderDataYaml
? html`<ha-yaml-editor
@@ -250,6 +258,15 @@ export class HaPlatformTrigger extends LitElement {
`;
}
private _renderTargetError() {
const error = this._fieldErrors.fieldError("target");
return error
? html`<ha-input-helper-text class="target-error">
${targetErrorText(this.hass.localize, error)}
</ha-input-helper-text>`
: nothing;
}
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector["target"] | null | undefined) =>
targetSelector ? { target: { ...targetSelector } } : { target: {} }
@@ -558,6 +575,10 @@ export class HaPlatformTrigger extends LitElement {
1px solid var(--divider-color)
);
}
.target-error {
color: var(--ha-color-on-danger-quiet);
padding-bottom: var(--ha-space-2);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -20,6 +20,7 @@ import { hasTemplate } from "../../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { ANY_STATE_VALUE } from "../../../../../components/entity/const";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import type {
HaFormSchema,
SchemaUnion,
@@ -57,6 +58,10 @@ const stateTriggerStruct = assign(
@customElement("ha-automation-trigger-state")
export class HaStateTrigger extends LitElement implements TriggerElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["entity_id", "attribute", "from", "to", "for"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: StateTrigger;
@@ -311,6 +316,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${data}
.schema=${schema}
@@ -3,6 +3,7 @@ import { customElement, property } from "lit/decorators";
import type { TemplateTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import "../../../../../components/ha-form/ha-form";
import { AutomationFieldErrorsController } from "../../automation-field-errors";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
@@ -25,6 +26,10 @@ const SCHEMA = [
@customElement("ha-automation-trigger-template")
export class HaTemplateTrigger extends LitElement {
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
inlineFields: ["value_template", "for"],
});
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: TemplateTrigger;
@@ -54,6 +59,7 @@ export class HaTemplateTrigger extends LitElement {
return html`
<ha-form
.error=${this._fieldErrors.fields}
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
+21 -69
View File
@@ -35,7 +35,6 @@ import "../../../components/ha-svg-icon";
import "../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../components/ha-yaml-editor";
import { substituteBlueprint } from "../../../data/blueprint";
import { validateConfig } from "../../../data/config";
import { UNAVAILABLE } from "../../../data/entity/entity";
import {
type EntityRegistryEntry,
@@ -62,7 +61,6 @@ import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { Entries } from "../../../types";
import { isMac } from "../../../util/is_mac";
import { showEditorToast } from "../automation/editor-toast";
import { showAutomationModeDialog } from "../automation/automation-mode-dialog/show-dialog-automation-mode";
@@ -99,7 +97,9 @@ export class HaScriptEditor extends SubscribeMixin(
domain: "script",
fetchFileConfig: fetchScriptFileConfig,
normalizeConfig: normalizeScriptConfig,
checkValidation: () => this._checkValidation(),
validationSections: (config) =>
"use_blueprint" in config ? {} : { actions: config.sequence },
validationSectionKeys: { actions: "sequence" },
};
private _undoRedoController = new UndoRedoController<ScriptConfig>(this, {
@@ -429,16 +429,7 @@ export class HaScriptEditor extends SubscribeMixin(
@value-changed=${this._valueChanged}
@save-script=${this._handleSaveScript}
>
${
this.errors
? html`<ha-alert
alert-type="error"
slot="alerts"
>
${this.errors}
</ha-alert>`
: nothing
}
${this.renderConfigErrorsAlert("alerts")}
</blueprint-script-editor>
`
: html`
@@ -449,6 +440,7 @@ export class HaScriptEditor extends SubscribeMixin(
.config=${this.config}
.disabled=${this.readOnly}
.saving=${this.saving}
.configErrors=${this.configErrors}
@value-changed=${this._valueChanged}
@editor-save=${this._handleSaveScript}
@save-script=${this._handleSaveScript}
@@ -456,26 +448,17 @@ export class HaScriptEditor extends SubscribeMixin(
<div class="alert-wrapper" slot="alerts">
${this._renderDeprecatedMigratedAlert()}
${
this.errors || stateObj?.state === UNAVAILABLE
stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
.title=${
stateObj?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.script.editor.unavailable"
)
: undefined
}
.title=${this.hass.localize(
"ui.panel.config.script.editor.unavailable"
)}
>
${this.errors || this.validationErrors}
${
stateObj?.state === UNAVAILABLE
? html`<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>`
: nothing
}
<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>
</ha-alert>`
: nothing
}
@@ -532,6 +515,7 @@ export class HaScriptEditor extends SubscribeMixin(
`
: this.mode === "yaml"
? html`${this._renderDeprecatedMigratedAlert()}
${this.renderConfigErrorsAlert()}
<ha-yaml-editor
.defaultValue=${this._preprocessYaml()}
.readOnly=${this.readOnly}
@@ -628,7 +612,7 @@ export class HaScriptEditor extends SubscribeMixin(
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._checkValidation();
this.checkValidation();
});
const regEntry = this.entityRegistry?.find(
(ent) => ent.entity_id === this.entityId
@@ -641,37 +625,12 @@ export class HaScriptEditor extends SubscribeMixin(
}
}
private async _checkValidation() {
this.validationErrors = undefined;
if (!this.currentEntityId || !this.config) {
return;
}
const stateObj = this.hass.states[this.currentEntityId];
if (stateObj?.state !== UNAVAILABLE) {
return;
}
const validation = await validateConfig(this.hass, {
actions: this.config.sequence,
});
this.validationErrors = (
Object.entries(validation) as Entries<typeof validation>
).map(([key, value]) =>
value.valid
? ""
: html`${this.hass.localize(
`ui.panel.config.automation.editor.${key}.name`
)}:
${value.error}<br />`
);
}
private _valueChanged(ev) {
if (this.config) {
this._undoRedoController.commit(this.config);
}
this.config = ev.detail.value;
this.errors = undefined;
this._updateDirtyState({
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
@@ -784,7 +743,6 @@ export class HaScriptEditor extends SubscribeMixin(
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
protected async confirmUnsavedChanged(addHistory = true): Promise<boolean> {
@@ -864,9 +822,8 @@ export class HaScriptEditor extends SubscribeMixin(
this._yamlEditor?.setValue(this.config);
}
this.readOnly = true;
this.errors = undefined;
} catch (err: any) {
this.errors = err.message;
showEditorToast(this, { message: err.message });
}
}
@@ -982,6 +939,7 @@ export class HaScriptEditor extends SubscribeMixin(
private async _saveScript(id): Promise<void> {
this.saving = true;
const config = this.config!;
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
if (this.entityRegistryUpdate !== undefined && !this.scriptId) {
@@ -992,11 +950,7 @@ export class HaScriptEditor extends SubscribeMixin(
}
try {
await this.hass!.callApi(
"POST",
"config/script/config/" + id,
this.config
);
await this.hass!.callApi("POST", "config/script/config/" + id, config);
if (this.entityRegistryUpdate !== undefined) {
let entityId = this.currentEntityId;
@@ -1044,11 +998,9 @@ export class HaScriptEditor extends SubscribeMixin(
this._markDirtyStateClean();
this.deprecatedConfigMigrated = false;
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
message: errors.body?.message || errors.error || errors.body,
});
this.clearConfigErrors();
} catch (errors: unknown) {
this.showSaveErrors(errors, config);
throw errors;
} finally {
this.saving = false;
+17 -3
View File
@@ -5738,6 +5738,14 @@
"disabled": "Automation is disabled",
"read_only": "This automation cannot be edited from the UI, because it is not stored in the automations.yaml file, or doesn't have an ID.",
"unavailable": "Automation is unavailable",
"errors": {
"label": "Problem",
"problems_found": "{count} problems found",
"save_failed": "Saving failed: {problems}",
"target_required": "Choose at least one target.",
"target_invalid": "Some targets aren't valid. Remove or replace them.",
"device_required": "Choose a device."
},
"deprecated_migrated": {
"title": "Deprecated options updated",
"description": "This automation used deprecated options that have been automatically updated to their new equivalents. Save the automation to apply the changes."
@@ -7527,15 +7535,21 @@
},
"triggers": {
"no_triggers": "No triggers",
"unknown_trigger": "Unknown trigger"
"unknown_trigger": "Unknown trigger",
"required": "Choose a trigger.",
"not_supported": "This device has no triggers. Choose another device."
},
"conditions": {
"no_conditions": "No conditions",
"unknown_condition": "Unknown condition"
"unknown_condition": "Unknown condition",
"required": "Choose a condition.",
"not_supported": "This device has no conditions. Choose another device."
},
"actions": {
"no_actions": "No actions",
"unknown_action": "Unknown action"
"unknown_action": "Unknown action",
"required": "Choose an action.",
"not_supported": "This device has no actions. Choose another device."
}
},
"script": {
+13
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
formatPath,
pathEquals,
pathStartsWith,
stringifyPath,
} from "../../../src/common/util/config-path";
@@ -15,5 +17,16 @@ describe("config path", () => {
expect(pathEquals(["actions", 0], ["actions", "0"])).toBe(false);
expect(pathEquals(["actions", 0], ["actions", 0, "then"])).toBe(false);
expect(pathEquals([], [])).toBe(true);
expect(pathStartsWith(["actions", 0, "delay"], ["actions", 0])).toBe(true);
expect(pathStartsWith(["actions", 0], ["actions", 0])).toBe(true);
expect(pathStartsWith(["actions", 0], ["actions", 0, "delay"])).toBe(false);
expect(pathStartsWith(["actions", 0], ["actions", "0"])).toBe(false);
expect(formatPath(["actions", 1, "then", 0, "delay"])).toBe(
"actions[1].then[0].delay"
);
expect(formatPath([0, "for"])).toBe("[0].for");
expect(formatPath([])).toBe("");
});
});
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import {
parseConfigSaveError,
validationResultErrors,
} from "../../src/data/config";
describe("config validation errors", () => {
it("get the section key in front of validate_config paths", () => {
expect(
validationResultErrors({
triggers: { valid: true, error: null },
actions: {
valid: false,
error: "Bad delay at 'actions[1].then[0].delay'",
errors: [
{ message: "Bad delay", path: [1, "then", 0, "delay"] },
{ message: "Unknown device", path: [] },
],
},
conditions: {
valid: false,
error: "Bad condition",
errors: [{ message: "Bad condition", path: [0] }],
},
})
).toEqual([
{ message: "Bad delay", path: ["actions", 1, "then", 0, "delay"] },
{ message: "Unknown device", path: ["actions"] },
{ message: "Bad condition", path: ["conditions", 0] },
]);
// Scripts validate their sequence as actions
expect(
validationResultErrors(
{
actions: {
valid: false,
error: "Bad delay",
errors: [{ message: "Bad delay", path: [0, "delay"] }],
},
},
{ actions: "sequence" }
)
).toEqual([{ message: "Bad delay", path: ["sequence", 0, "delay"] }]);
});
it("are read from a failed save", () => {
const errors = [
{ message: "Bad for", path: ["triggers", 1, "for"], code: "invalid" },
];
expect(
parseConfigSaveError({
error: "Response error: 400",
status_code: 400,
body: { message: "Message malformed: Bad for", errors },
})
).toEqual({ message: "Message malformed: Bad for", errors });
// Errors that are not about the config
expect(
parseConfigSaveError({
error: "Response error: 401",
status_code: 401,
body: { message: "Unauthorized" },
})
).toEqual({ message: "Unauthorized", errors: [] });
expect(
parseConfigSaveError({
error: "Request error",
status_code: undefined,
body: undefined,
})
).toEqual({ message: "Request error", errors: [] });
});
});
@@ -0,0 +1,230 @@
import { describe, expect, it } from "vitest";
import {
configErrorPath,
errorOwner,
errorsOnKnownItems,
followErrors,
followKnownErrorItems,
knownErrorItems,
errorText,
fieldErrors,
ownedErrors,
} from "../../../../src/panels/config/automation/automation-errors";
import {
AutomationRowRegistry,
type AutomationPathRow,
} from "../../../../src/panels/config/automation/automation-path";
const createRow = () =>
({
selected: false,
openSidebar: () => undefined,
expand: () => undefined,
}) as unknown as AutomationPathRow;
describe("automation errors", () => {
it("belong to the deepest rendered row on their path", () => {
const registry = new AutomationRowRegistry();
const block = createRow();
const nested = createRow();
const other = createRow();
registry.register(["actions", 0], block);
registry.register(["actions", 0, "then", 0], nested);
registry.register(["actions", 1], other);
const inNested = { message: "a", path: ["actions", 0, "then", 0, "delay"] };
// The nested row of this path is not rendered, so its block owns it
const inCollapsed = { message: "b", path: ["actions", 0, "else", 0] };
const global = { message: "c", path: ["mode"] };
const noPath = { message: "d", path: [] };
const errors = [inNested, inCollapsed, global, noPath];
expect(errorOwner(registry, inNested)).toBe(nested);
expect(ownedErrors(registry, errors, block)).toEqual([inCollapsed]);
expect(ownedErrors(registry, errors, nested)).toEqual([inNested]);
expect(ownedErrors(registry, errors, other)).toEqual([]);
expect(errorOwner(registry, global)).toBeUndefined();
expect(errorOwner(registry, noPath)).toBeUndefined();
});
it("map to form fields and read relative to a row", () => {
const errors = [
{ message: "bad for", path: ["triggers", 1, "for", "hours"] },
{ message: "second for", path: ["triggers", 1, "for"] },
{ message: "bad entity", path: ["triggers", 1, "entity_id"] },
{ message: "row itself", path: ["triggers", 1] },
{ message: "other row", path: ["triggers", 2, "for"] },
];
expect(fieldErrors(errors, ["triggers", 1])).toEqual({
for: "bad for",
entity_id: "bad entity",
});
expect(fieldErrors(errors, ["triggers", 3])).toEqual({});
expect(errorText(errors[0], ["triggers", 1])).toBe("for.hours: bad for");
expect(errorText(errors[3], ["triggers", 1])).toBe("row itself");
expect(errorText(errors[4])).toBe("triggers[2].for: other row");
});
it("map core paths back to the edited config", () => {
const config = {
triggers: [
{ trigger: "event" },
{ triggers: [{ trigger: "state" }, { trigger: "time" }] },
{ trigger: "sun" },
],
actions: [
{ delay: 1 },
{ wait_for_trigger: [{ triggers: { trigger: "event" } }] },
],
};
expect(configErrorPath(config, ["triggers", 0, "for"])).toEqual([
"triggers",
0,
"for",
]);
expect(configErrorPath(config, ["triggers", 2, "for"])).toEqual([
"triggers",
1,
"triggers",
1,
"for",
]);
expect(configErrorPath(config, ["triggers", 3])).toEqual(["triggers", 2]);
expect(
configErrorPath(config, ["actions", 1, "wait_for_trigger", 0, "x"])
).toEqual(["actions", 1, "wait_for_trigger", 0, "triggers", 0, "x"]);
// Paths that don't resolve stay as they are
expect(configErrorPath(config, ["triggers", 9, "for"])).toEqual([
"triggers",
9,
"for",
]);
expect(configErrorPath(config, ["mode"])).toEqual(["mode"]);
// Core wraps each action of the parallel shorthand in a sequence
const parallel = {
actions: [
{
parallel: [{ delay: 1 }, { sequence: [{ delay: 1 }, { delay: 2 }] }],
},
],
};
expect(
configErrorPath(parallel, [
"actions",
0,
"parallel",
0,
"sequence",
0,
"delay",
])
).toEqual(["actions", 0, "parallel", 0, "delay"]);
expect(
configErrorPath(parallel, ["actions", 0, "parallel", 1, "sequence", 1])
).toEqual(["actions", 0, "parallel", 1, "sequence", 1]);
});
it("keep only live errors on items that already had one", () => {
const a = { trigger: "state", entity_id: ["sensor.a"], for: "abc" };
const b = { trigger: "event", event_type: "b" };
const c = { trigger: "state", entity_id: "sensor.c", for: "xyz" };
const forError = (index: number) => ({
message: "bad for",
path: ["triggers", index, "for"],
});
const before = { triggers: [a, b, c] };
const known = knownErrorItems(before, [forError(0), forError(2)]);
// Edited in place, the item keeps its errors; a value list entry belongs
// to the trigger, and a new error on another item waits for the save
const edited = { triggers: [{ ...a, for: "abd" }, b, c] };
const valueEntry = {
message: "bad entity",
path: ["triggers", 0, "entity_id", 0],
};
const other = { message: "new", path: ["triggers", 1, "event_type"] };
expect(
errorsOnKnownItems(followKnownErrorItems(known, before, edited), edited, [
forError(0),
valueEntry,
other,
])
).toEqual([forError(0), valueEntry]);
// Deleting a row moves the next error, and the row that takes the old
// index doesn't count as known
const deleted = { triggers: [b, c] };
expect(
errorsOnKnownItems(
followKnownErrorItems(known, before, deleted),
deleted,
[forError(0), forError(1)]
)
).toEqual([forError(1)]);
// Moving a row past another doesn't make the other one known
const swapped = { triggers: [b, a, c] };
expect(
errorsOnKnownItems(
followKnownErrorItems(known, before, swapped),
swapped,
[forError(0), forError(1)]
)
).toEqual([forError(1)]);
// YAML is parsed again on every edit, so items are found by content
const reparsed = structuredClone(deleted);
expect(
errorsOnKnownItems(
followKnownErrorItems(known, before, reparsed),
reparsed,
[forError(0), forError(1)]
)
).toEqual([forError(1)]);
// Identical items both keep their errors after the YAML is parsed again
const twice = { triggers: [a, { ...a }] };
const knownTwice = knownErrorItems(twice, [forError(0), forError(1)]);
const reparsedTwice = structuredClone(twice);
expect(
errorsOnKnownItems(
followKnownErrorItems(knownTwice, twice, reparsedTwice),
reparsedTwice,
[forError(0), forError(1)]
)
).toEqual([forError(0), forError(1)]);
// An item edited and then moved keeps its errors
const knownC = knownErrorItems(before, [forError(2)]);
const editedC = { ...c, for: "xyy" };
const afterEdit = { triggers: [a, b, editedC] };
const afterMove = { triggers: [editedC, a, b] };
const followed = followKnownErrorItems(
followKnownErrorItems(knownC, before, afterEdit),
afterEdit,
afterMove
);
expect(
errorsOnKnownItems(followed, afterMove, [forError(0), forError(2)])
).toEqual([forError(0)]);
});
it("follow save errors to where their items are now", () => {
const a = { delay: "abc" };
const b = { delay: 1 };
const c = { wait_template: "{{ " };
const submitted = { actions: [a, b, c] };
const errors = [
{ message: "bad delay", path: ["actions", 0, "delay"] },
{ message: "bad template", path: ["actions", 2, "wait_template"] },
{ message: "bad mode", path: ["mode"] },
];
// A was deleted and C moved up while saving
expect(followErrors(errors, submitted, { actions: [c, b] })).toEqual([
{ message: "bad template", path: ["actions", 0, "wait_template"] },
{ message: "bad mode", path: ["mode"] },
]);
});
});