From 0a3172dfdbb0b02f664fdc885470a8c3c8ee5448 Mon Sep 17 00:00:00 2001 From: Philip Allgaier Date: Wed, 27 Jan 2021 12:33:57 +0100 Subject: [PATCH] More comprehensive YAML config errors + dynamic checks for action configs (#8217) --- package.json | 2 +- src/common/structs/handle-errors.ts | 45 ++++++ .../common/structs/is-entity-id.ts | 0 .../lovelace => }/common/structs/is-icon.ts | 0 .../action/ha-automation-action-row.ts | 4 +- .../types/ha-automation-action-service.ts | 2 +- src/panels/lovelace/cards/hui-error-card.ts | 2 + .../lovelace/common/structs/handle-errors.ts | 24 --- .../hui-history-graph-card-editor.ts | 2 +- .../lovelace/editor/gui-support-error.ts | 7 +- .../lovelace/editor/hui-element-editor.ts | 139 +++++++++++------- src/panels/lovelace/editor/types.ts | 59 +++++++- src/translations/en.json | 12 ++ yarn.lock | 8 +- 14 files changed, 216 insertions(+), 90 deletions(-) create mode 100644 src/common/structs/handle-errors.ts rename src/{panels/lovelace => }/common/structs/is-entity-id.ts (100%) rename src/{panels/lovelace => }/common/structs/is-icon.ts (100%) delete mode 100644 src/panels/lovelace/common/structs/handle-errors.ts diff --git a/package.json b/package.json index 805fcd9e15..f6e9c22437 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "resize-observer-polyfill": "^1.5.1", "roboto-fontface": "^0.10.0", "sortablejs": "^1.10.2", - "superstruct": "^0.10.12", + "superstruct": "^0.10.13", "tinykeys": "^1.1.1", "unfetch": "^4.1.0", "vis-data": "^7.1.1", diff --git a/src/common/structs/handle-errors.ts b/src/common/structs/handle-errors.ts new file mode 100644 index 0000000000..7aa8889045 --- /dev/null +++ b/src/common/structs/handle-errors.ts @@ -0,0 +1,45 @@ +import { StructError } from "superstruct"; +import type { HomeAssistant } from "../../types"; + +export const handleStructError = ( + hass: HomeAssistant, + err: Error +): { warnings: string[]; errors?: string[] } => { + if (!(err instanceof StructError)) { + return { warnings: [err.message], errors: undefined }; + } + const errors: string[] = []; + const warnings: string[] = []; + for (const failure of err.failures()) { + if (failure.value === undefined) { + errors.push( + hass.localize( + "ui.errors.config.key_missing", + "key", + failure.path.join(".") + ) + ); + } else if (failure.type === "never") { + warnings.push( + hass.localize( + "ui.errors.config.key_not_expected", + "key", + failure.path.join(".") + ) + ); + } else { + warnings.push( + hass.localize( + "ui.errors.config.key_wrong_type", + "key", + failure.path.join("."), + "type_correct", + failure.type, + "type_wrong", + JSON.stringify(failure.value) + ) + ); + } + } + return { warnings, errors }; +}; diff --git a/src/panels/lovelace/common/structs/is-entity-id.ts b/src/common/structs/is-entity-id.ts similarity index 100% rename from src/panels/lovelace/common/structs/is-entity-id.ts rename to src/common/structs/is-entity-id.ts diff --git a/src/panels/lovelace/common/structs/is-icon.ts b/src/common/structs/is-icon.ts similarity index 100% rename from src/panels/lovelace/common/structs/is-icon.ts rename to src/common/structs/is-icon.ts diff --git a/src/panels/config/automation/action/ha-automation-action-row.ts b/src/panels/config/automation/action/ha-automation-action-row.ts index f28278e134..c13b6d4905 100644 --- a/src/panels/config/automation/action/ha-automation-action-row.ts +++ b/src/panels/config/automation/action/ha-automation-action-row.ts @@ -27,7 +27,7 @@ import type { Action } from "../../../../data/script"; import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../../resources/styles"; import type { HomeAssistant } from "../../../../types"; -import { handleStructError } from "../../../lovelace/common/structs/handle-errors"; +import { handleStructError } from "../../../../common/structs/handle-errors"; import "./types/ha-automation-action-choose"; import "./types/ha-automation-action-condition"; import "./types/ha-automation-action-delay"; @@ -251,7 +251,7 @@ export default class HaAutomationActionRow extends LitElement { } private _handleUiModeNotAvailable(ev: CustomEvent) { - this._warnings = handleStructError(ev.detail); + this._warnings = handleStructError(this.hass, ev.detail).warnings; if (!this._yamlMode) { this._yamlMode = true; } diff --git a/src/panels/config/automation/action/types/ha-automation-action-service.ts b/src/panels/config/automation/action/types/ha-automation-action-service.ts index cc5df3e369..f9351c6e13 100644 --- a/src/panels/config/automation/action/types/ha-automation-action-service.ts +++ b/src/panels/config/automation/action/types/ha-automation-action-service.ts @@ -19,7 +19,7 @@ import type { HaYamlEditor } from "../../../../../components/ha-yaml-editor"; import { ServiceAction } from "../../../../../data/script"; import type { PolymerChangedEvent } from "../../../../../polymer-types"; import type { HomeAssistant } from "../../../../../types"; -import { EntityIdOrAll } from "../../../../lovelace/common/structs/is-entity-id"; +import { EntityIdOrAll } from "../../../../../common/structs/is-entity-id"; import { ActionElement, handleChangeEvent } from "../ha-automation-action-row"; const actionStruct = object({ diff --git a/src/panels/lovelace/cards/hui-error-card.ts b/src/panels/lovelace/cards/hui-error-card.ts index 2b57b026e7..7739390717 100644 --- a/src/panels/lovelace/cards/hui-error-card.ts +++ b/src/panels/lovelace/cards/hui-error-card.ts @@ -52,6 +52,8 @@ export class HuiErrorCard extends LitElement implements LovelaceCard { } pre { font-family: var(--code-font-family, monospace); + text-overflow: ellipsis; + overflow: hidden; } `; } diff --git a/src/panels/lovelace/common/structs/handle-errors.ts b/src/panels/lovelace/common/structs/handle-errors.ts deleted file mode 100644 index b97ae217b1..0000000000 --- a/src/panels/lovelace/common/structs/handle-errors.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { StructError } from "superstruct"; - -export const handleStructError = (err: Error): string[] => { - if (!(err instanceof StructError)) { - return [err.message]; - } - const errors: string[] = []; - for (const failure of err.failures()) { - if (failure.type === "never") { - errors.push( - `Key "${failure.path.join(".")}" is not supported by the UI editor.` - ); - } else { - errors.push( - `The value of "${failure.path.join( - "." - )}" is not supported by the UI editor, we support "${ - failure.type - }" but received "${JSON.stringify(failure.value)}".` - ); - } - } - return errors; -}; diff --git a/src/panels/lovelace/editor/config-elements/hui-history-graph-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-history-graph-card-editor.ts index f7bf34b99b..b38545d422 100644 --- a/src/panels/lovelace/editor/config-elements/hui-history-graph-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-history-graph-card-editor.ts @@ -20,7 +20,7 @@ import { import { fireEvent } from "../../../../common/dom/fire_event"; import { HomeAssistant } from "../../../../types"; import { HistoryGraphCardConfig } from "../../cards/types"; -import { EntityId } from "../../common/structs/is-entity-id"; +import { EntityId } from "../../../../common/structs/is-entity-id"; import "../../components/hui-entity-editor"; import { EntityConfig } from "../../entity-rows/types"; import { LovelaceCardEditor } from "../../types"; diff --git a/src/panels/lovelace/editor/gui-support-error.ts b/src/panels/lovelace/editor/gui-support-error.ts index ce1aa8ff85..7d1b325345 100644 --- a/src/panels/lovelace/editor/gui-support-error.ts +++ b/src/panels/lovelace/editor/gui-support-error.ts @@ -1,9 +1,12 @@ export class GUISupportError extends Error { - public warnings?: string[] = []; + public warnings?: string[]; - constructor(message: string, warnings?: string[]) { + public errors?: string[]; + + constructor(message: string, warnings?: string[], errors?: string[]) { super(message); this.name = "GUISupportError"; this.warnings = warnings; + this.errors = errors; } } diff --git a/src/panels/lovelace/editor/hui-element-editor.ts b/src/panels/lovelace/editor/hui-element-editor.ts index 9e3ed9f9f9..2585201c02 100644 --- a/src/panels/lovelace/editor/hui-element-editor.ts +++ b/src/panels/lovelace/editor/hui-element-editor.ts @@ -22,7 +22,7 @@ import type { LovelaceConfig, } from "../../../data/lovelace"; import type { HomeAssistant } from "../../../types"; -import { handleStructError } from "../common/structs/handle-errors"; +import { handleStructError } from "../../../common/structs/handle-errors"; import type { LovelaceRowConfig } from "../entity-rows/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types"; import type { LovelaceGenericElementEditor } from "../types"; @@ -63,14 +63,16 @@ export abstract class HuiElementEditor extends LitElement { @internalProperty() private _configElementType?: string; - @internalProperty() private _GUImode = true; + @internalProperty() private _guiMode = true; // Error: Configuration broken - do not save - @internalProperty() private _error?: string; + @internalProperty() private _errors?: string[]; // Warning: GUI editor can't handle configuration - ok to save @internalProperty() private _warnings?: string[]; + @internalProperty() private _guiSupported?: boolean; + @internalProperty() private _loading = false; @query("ha-code-editor") _yamlEditor?: HaCodeEditor; @@ -86,9 +88,9 @@ export abstract class HuiElementEditor extends LitElement { this._yaml = _yaml; try { this._config = safeLoad(this.yaml); - this._error = undefined; + this._errors = undefined; } catch (err) { - this._error = err.message; + this._errors = [err.message]; } this._setConfig(); } @@ -103,44 +105,51 @@ export abstract class HuiElementEditor extends LitElement { } this._config = config; this._yaml = undefined; - this._error = undefined; + this._errors = undefined; this._setConfig(); } private _setConfig(): void { - if (!this._error) { + if (!this._errors) { try { this._updateConfigElement(); - this._error = undefined; } catch (err) { - this._error = err.message; + this._errors = [err.message]; } } fireEvent(this, "config-changed", { config: this.value! as any, - error: this._error, - guiModeAvailable: !(this.hasWarning || this.hasError), + error: this._errors?.join(", "), + guiModeAvailable: !( + this.hasWarning || + this.hasError || + this._guiSupported === false + ), }); } public get hasWarning(): boolean { - return this._warnings !== undefined; + return this._warnings !== undefined && this._warnings.length > 0; } public get hasError(): boolean { - return this._error !== undefined; + return this._errors !== undefined && this._errors.length > 0; } public get GUImode(): boolean { - return this._GUImode; + return this._guiMode; } public set GUImode(guiMode: boolean) { - this._GUImode = guiMode; + this._guiMode = guiMode; fireEvent(this as HTMLElement, "GUImode-changed", { guiMode, - guiModeAvailable: !(this.hasWarning || this.hasError), + guiModeAvailable: !( + this.hasWarning || + this.hasError || + this._guiSupported === false + ), }); } @@ -194,29 +203,44 @@ export abstract class HuiElementEditor extends LitElement { mode="yaml" autofocus .value=${this.yaml} - .error=${Boolean(this._error)} + .error=${Boolean(this._errors)} .rtl=${computeRTL(this.hass)} @value-changed=${this._handleYAMLChanged} @keydown=${this._ignoreKeydown} > `} - ${this._error + ${this._guiSupported === false && this.configElementType ? html` -
- ${this._error} +
+ ${this.hass.localize( + "ui.errors.config.editor_not_available", + "type", + this.configElementType + )}
` : ""} - ${this._warnings + ${this.hasError ? html` -
- UI editor is not supported for this config: +
+ ${this.hass.localize("ui.errors.config.error_detected")}:
    - ${this._warnings.map((warning) => html`
  • ${warning}
  • `)} + ${this._errors!.map((error) => html`
  • ${error}
  • `)}
- You can still edit your config in YAML. +
+ ` + : ""} + ${this.hasWarning + ? html` +
+ ${this.hass.localize("ui.errors.config.editor_not_supported")}: +
+
    + ${this._warnings!.map((warning) => html`
  • ${warning}
  • `)} +
+ ${this.hass.localize("ui.errors.config.edit_in_yaml_supported")}
` : ""} @@ -261,14 +285,18 @@ export abstract class HuiElementEditor extends LitElement { let configElement: LovelaceGenericElementEditor | undefined; try { - this._error = undefined; + this._errors = undefined; this._warnings = undefined; if (this._configElementType !== this.configElementType) { // If the type has changed, we need to load a new GUI editor + this._guiSupported = false; + this._configElement = undefined; if (!this.configElementType) { - throw new Error(`No type defined`); + throw new Error( + this.hass.localize("ui.errors.config.no_type_provided") + ); } this._configElementType = this.configElementType; @@ -276,37 +304,41 @@ export abstract class HuiElementEditor extends LitElement { this._loading = true; configElement = await this.getConfigElement(); - if (!configElement) { - throw new Error( - `No visual editor available for: ${this.configElementType}` + if (configElement) { + configElement.hass = this.hass; + if ("lovelace" in configElement) { + configElement.lovelace = this.lovelace; + } + configElement.addEventListener("config-changed", (ev) => + this._handleUIConfigChanged(ev as UIConfigChangedEvent) ); - } - configElement.hass = this.hass; - if ("lovelace" in configElement) { - configElement.lovelace = this.lovelace; + this._configElement = configElement; + this._guiSupported = true; } - configElement.addEventListener("config-changed", (ev) => - this._handleUIConfigChanged(ev as UIConfigChangedEvent) - ); - - this._configElement = configElement; } - // Setup GUI editor and check that it can handle the current config - try { - this._configElement!.setConfig(this.value); - } catch (err) { - throw new GUISupportError( - "Config is not supported", - handleStructError(err) - ); + if (this._configElement) { + // Setup GUI editor and check that it can handle the current config + try { + this._configElement.setConfig(this.value); + } catch (err) { + const msgs = handleStructError(this.hass, err); + throw new GUISupportError( + "Config is not supported", + msgs.warnings, + msgs.errors + ); + } + } else { + this.GUImode = false; } } catch (err) { if (err instanceof GUISupportError) { this._warnings = err.warnings ?? [err.message]; + this._errors = err.errors || undefined; } else { - this._error = err; + this._errors = [err.message]; } this.GUImode = false; } finally { @@ -331,8 +363,10 @@ export abstract class HuiElementEditor extends LitElement { padding: 8px 0px; } .error, - .warning { + .warning, + .info { word-break: break-word; + margin-top: 8px; } .error { color: var(--error-color); @@ -340,9 +374,14 @@ export abstract class HuiElementEditor extends LitElement { .warning { color: var(--warning-color); } - .warning ul { + .warning ul, + .error ul { margin: 4px 0; } + .warning li, + .error li { + white-space: pre-wrap; + } ha-circular-progress { display: block; margin: auto; diff --git a/src/panels/lovelace/editor/types.ts b/src/panels/lovelace/editor/types.ts index 3f657062a6..a42cc8dcb3 100644 --- a/src/panels/lovelace/editor/types.ts +++ b/src/panels/lovelace/editor/types.ts @@ -2,6 +2,9 @@ import { any, array, boolean, + dynamic, + enums, + literal, number, object, optional, @@ -92,12 +95,58 @@ export interface EditSubElementEvent { subElementConfig: SubElementEditorConfig; } -export const actionConfigStruct = object({ - action: string(), - navigation_path: optional(string()), - url_path: optional(string()), - service: optional(string()), +export const actionConfigStruct = dynamic((_value, ctx) => { + const test = actionConfigMap[ctx.branch[0][ctx.path[0]].action]; + return test || actionConfigStructType; +}); + +const actionConfigStructUser = object({ + user: string(), +}); + +const actionConfigStructConfirmation = union([ + boolean(), + object({ + text: optional(string()), + excemptions: optional(array(actionConfigStructUser)), + }), +]); + +const actionConfigStructUrl = object({ + action: literal("url"), + url_path: string(), + confirmation: optional(actionConfigStructConfirmation), +}); + +const actionConfigStructService = object({ + action: literal("call-service"), + service: string(), service_data: optional(object()), + confirmation: optional(actionConfigStructConfirmation), +}); + +const actionConfigStructNavigate = object({ + action: literal("navigate"), + navigation_path: string(), + confirmation: optional(actionConfigStructConfirmation), +}); + +const actionConfigMap = { + url: actionConfigStructUrl, + navigate: actionConfigStructNavigate, + "call-service": actionConfigStructService, +}; + +export const actionConfigStructType = object({ + action: enums([ + "none", + "toggle", + "more-info", + "call-service", + "url", + "navigate", + ]), + confirmation: optional(actionConfigStructConfirmation), }); const buttonEntitiesRowConfigStruct = object({ diff --git a/src/translations/en.json b/src/translations/en.json index 05a1238546..3d61e25995 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -757,6 +757,18 @@ "day": "{count} {count, plural,\n one {day}\n other {days}\n}", "week": "{count} {count, plural,\n one {week}\n other {weeks}\n}" }, + "errors": { + "config": { + "no_type_provided": "No type provided.", + "error_detected": "Configuration errors detected", + "editor_not_available": "No visual editor available for type \"{type}\".", + "editor_not_supported": "Visual editor is not supported for this configuration", + "edit_in_yaml_supported": "You can still edit your config in YAML.", + "key_missing": "Required key \"{key}\" is missing.", + "key_not_expected": "Key \"{key}\" is not expected or not supported by the visual editor.", + "key_wrong_type": "The provided value for \"{key}\" is not supported by the visual editor editor. We support ({type_correct}) but received ({type_wrong})." + } + }, "login-form": { "password": "Password", "remember": "Remember", diff --git a/yarn.lock b/yarn.lock index 5c11c81dc0..026e4d1e9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12758,10 +12758,10 @@ subarg@^1.0.0: dependencies: minimist "^1.1.0" -superstruct@^0.10.12: - version "0.10.12" - resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.10.12.tgz#7b2c8adaf61b75257265eac3b588f30017f996f0" - integrity sha512-FiNhfegyytDI0QxrrEoeGknFM28SnoHqCBpkWewUm8jRNj74NVxLpiiePvkOo41Ze/aKMSHa/twWjNF81mKaQQ== +superstruct@^0.10.13: + version "0.10.13" + resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.10.13.tgz#705535a5598ff231bd976601a7b6b534a71a821b" + integrity sha512-W4SitSZ9MOyMPbHreoZVEneSZyPEeNGbdfJo/7FkJyRs/M3wQRFzq+t3S/NBwlrFSWdx1ONLjLb9pB+UKe4IqQ== supports-color@6.0.0: version "6.0.0"