mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-12 16:04:15 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e96fac4dc | ||
|
|
1fa476ab67 | ||
|
|
0a4a354e22 |
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user