More comprehensive YAML config errors + dynamic checks for action configs (#8217)

This commit is contained in:
Philip Allgaier 2021-01-27 12:33:57 +01:00 committed by GitHub
parent 5c0e151bc2
commit 0a3172dfdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 216 additions and 90 deletions

View File

@ -120,7 +120,7 @@
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"roboto-fontface": "^0.10.0", "roboto-fontface": "^0.10.0",
"sortablejs": "^1.10.2", "sortablejs": "^1.10.2",
"superstruct": "^0.10.12", "superstruct": "^0.10.13",
"tinykeys": "^1.1.1", "tinykeys": "^1.1.1",
"unfetch": "^4.1.0", "unfetch": "^4.1.0",
"vis-data": "^7.1.1", "vis-data": "^7.1.1",

View File

@ -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 };
};

View File

@ -27,7 +27,7 @@ import type { Action } from "../../../../data/script";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles"; import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; 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-choose";
import "./types/ha-automation-action-condition"; import "./types/ha-automation-action-condition";
import "./types/ha-automation-action-delay"; import "./types/ha-automation-action-delay";
@ -251,7 +251,7 @@ export default class HaAutomationActionRow extends LitElement {
} }
private _handleUiModeNotAvailable(ev: CustomEvent) { private _handleUiModeNotAvailable(ev: CustomEvent) {
this._warnings = handleStructError(ev.detail); this._warnings = handleStructError(this.hass, ev.detail).warnings;
if (!this._yamlMode) { if (!this._yamlMode) {
this._yamlMode = true; this._yamlMode = true;
} }

View File

@ -19,7 +19,7 @@ import type { HaYamlEditor } from "../../../../../components/ha-yaml-editor";
import { ServiceAction } from "../../../../../data/script"; import { ServiceAction } from "../../../../../data/script";
import type { PolymerChangedEvent } from "../../../../../polymer-types"; import type { PolymerChangedEvent } from "../../../../../polymer-types";
import type { HomeAssistant } from "../../../../../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"; import { ActionElement, handleChangeEvent } from "../ha-automation-action-row";
const actionStruct = object({ const actionStruct = object({

View File

@ -52,6 +52,8 @@ export class HuiErrorCard extends LitElement implements LovelaceCard {
} }
pre { pre {
font-family: var(--code-font-family, monospace); font-family: var(--code-font-family, monospace);
text-overflow: ellipsis;
overflow: hidden;
} }
`; `;
} }

View File

@ -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;
};

View File

@ -20,7 +20,7 @@ import {
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import { HistoryGraphCardConfig } from "../../cards/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 "../../components/hui-entity-editor";
import { EntityConfig } from "../../entity-rows/types"; import { EntityConfig } from "../../entity-rows/types";
import { LovelaceCardEditor } from "../../types"; import { LovelaceCardEditor } from "../../types";

View File

@ -1,9 +1,12 @@
export class GUISupportError extends Error { 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); super(message);
this.name = "GUISupportError"; this.name = "GUISupportError";
this.warnings = warnings; this.warnings = warnings;
this.errors = errors;
} }
} }

View File

@ -22,7 +22,7 @@ import type {
LovelaceConfig, LovelaceConfig,
} from "../../../data/lovelace"; } from "../../../data/lovelace";
import type { HomeAssistant } from "../../../types"; 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 type { LovelaceRowConfig } from "../entity-rows/types";
import { LovelaceHeaderFooterConfig } from "../header-footer/types"; import { LovelaceHeaderFooterConfig } from "../header-footer/types";
import type { LovelaceGenericElementEditor } from "../types"; import type { LovelaceGenericElementEditor } from "../types";
@ -63,14 +63,16 @@ export abstract class HuiElementEditor<T> extends LitElement {
@internalProperty() private _configElementType?: string; @internalProperty() private _configElementType?: string;
@internalProperty() private _GUImode = true; @internalProperty() private _guiMode = true;
// Error: Configuration broken - do not save // Error: Configuration broken - do not save
@internalProperty() private _error?: string; @internalProperty() private _errors?: string[];
// Warning: GUI editor can't handle configuration - ok to save // Warning: GUI editor can't handle configuration - ok to save
@internalProperty() private _warnings?: string[]; @internalProperty() private _warnings?: string[];
@internalProperty() private _guiSupported?: boolean;
@internalProperty() private _loading = false; @internalProperty() private _loading = false;
@query("ha-code-editor") _yamlEditor?: HaCodeEditor; @query("ha-code-editor") _yamlEditor?: HaCodeEditor;
@ -86,9 +88,9 @@ export abstract class HuiElementEditor<T> extends LitElement {
this._yaml = _yaml; this._yaml = _yaml;
try { try {
this._config = safeLoad(this.yaml); this._config = safeLoad(this.yaml);
this._error = undefined; this._errors = undefined;
} catch (err) { } catch (err) {
this._error = err.message; this._errors = [err.message];
} }
this._setConfig(); this._setConfig();
} }
@ -103,44 +105,51 @@ export abstract class HuiElementEditor<T> extends LitElement {
} }
this._config = config; this._config = config;
this._yaml = undefined; this._yaml = undefined;
this._error = undefined; this._errors = undefined;
this._setConfig(); this._setConfig();
} }
private _setConfig(): void { private _setConfig(): void {
if (!this._error) { if (!this._errors) {
try { try {
this._updateConfigElement(); this._updateConfigElement();
this._error = undefined;
} catch (err) { } catch (err) {
this._error = err.message; this._errors = [err.message];
} }
} }
fireEvent(this, "config-changed", { fireEvent(this, "config-changed", {
config: this.value! as any, config: this.value! as any,
error: this._error, error: this._errors?.join(", "),
guiModeAvailable: !(this.hasWarning || this.hasError), guiModeAvailable: !(
this.hasWarning ||
this.hasError ||
this._guiSupported === false
),
}); });
} }
public get hasWarning(): boolean { public get hasWarning(): boolean {
return this._warnings !== undefined; return this._warnings !== undefined && this._warnings.length > 0;
} }
public get hasError(): boolean { public get hasError(): boolean {
return this._error !== undefined; return this._errors !== undefined && this._errors.length > 0;
} }
public get GUImode(): boolean { public get GUImode(): boolean {
return this._GUImode; return this._guiMode;
} }
public set GUImode(guiMode: boolean) { public set GUImode(guiMode: boolean) {
this._GUImode = guiMode; this._guiMode = guiMode;
fireEvent(this as HTMLElement, "GUImode-changed", { fireEvent(this as HTMLElement, "GUImode-changed", {
guiMode, guiMode,
guiModeAvailable: !(this.hasWarning || this.hasError), guiModeAvailable: !(
this.hasWarning ||
this.hasError ||
this._guiSupported === false
),
}); });
} }
@ -194,29 +203,44 @@ export abstract class HuiElementEditor<T> extends LitElement {
mode="yaml" mode="yaml"
autofocus autofocus
.value=${this.yaml} .value=${this.yaml}
.error=${Boolean(this._error)} .error=${Boolean(this._errors)}
.rtl=${computeRTL(this.hass)} .rtl=${computeRTL(this.hass)}
@value-changed=${this._handleYAMLChanged} @value-changed=${this._handleYAMLChanged}
@keydown=${this._ignoreKeydown} @keydown=${this._ignoreKeydown}
></ha-code-editor> ></ha-code-editor>
</div> </div>
`} `}
${this._error ${this._guiSupported === false && this.configElementType
? html` ? html`
<div class="error"> <div class="info">
${this._error} ${this.hass.localize(
"ui.errors.config.editor_not_available",
"type",
this.configElementType
)}
</div> </div>
` `
: ""} : ""}
${this._warnings ${this.hasError
? html` ? html`
<div class="warning"> <div class="error">
UI editor is not supported for this config: ${this.hass.localize("ui.errors.config.error_detected")}:
<br /> <br />
<ul> <ul>
${this._warnings.map((warning) => html`<li>${warning}</li>`)} ${this._errors!.map((error) => html`<li>${error}</li>`)}
</ul> </ul>
You can still edit your config in YAML. </div>
`
: ""}
${this.hasWarning
? html`
<div class="warning">
${this.hass.localize("ui.errors.config.editor_not_supported")}:
<br />
<ul>
${this._warnings!.map((warning) => html`<li>${warning}</li>`)}
</ul>
${this.hass.localize("ui.errors.config.edit_in_yaml_supported")}
</div> </div>
` `
: ""} : ""}
@ -261,14 +285,18 @@ export abstract class HuiElementEditor<T> extends LitElement {
let configElement: LovelaceGenericElementEditor | undefined; let configElement: LovelaceGenericElementEditor | undefined;
try { try {
this._error = undefined; this._errors = undefined;
this._warnings = undefined; this._warnings = undefined;
if (this._configElementType !== this.configElementType) { if (this._configElementType !== this.configElementType) {
// If the type has changed, we need to load a new GUI editor // If the type has changed, we need to load a new GUI editor
this._guiSupported = false;
this._configElement = undefined;
if (!this.configElementType) { 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; this._configElementType = this.configElementType;
@ -276,37 +304,41 @@ export abstract class HuiElementEditor<T> extends LitElement {
this._loading = true; this._loading = true;
configElement = await this.getConfigElement(); configElement = await this.getConfigElement();
if (!configElement) { if (configElement) {
throw new Error( configElement.hass = this.hass;
`No visual editor available for: ${this.configElementType}` if ("lovelace" in configElement) {
configElement.lovelace = this.lovelace;
}
configElement.addEventListener("config-changed", (ev) =>
this._handleUIConfigChanged(ev as UIConfigChangedEvent)
); );
}
configElement.hass = this.hass; this._configElement = configElement;
if ("lovelace" in configElement) { this._guiSupported = true;
configElement.lovelace = this.lovelace;
} }
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 if (this._configElement) {
try { // Setup GUI editor and check that it can handle the current config
this._configElement!.setConfig(this.value); try {
} catch (err) { this._configElement.setConfig(this.value);
throw new GUISupportError( } catch (err) {
"Config is not supported", const msgs = handleStructError(this.hass, err);
handleStructError(err) throw new GUISupportError(
); "Config is not supported",
msgs.warnings,
msgs.errors
);
}
} else {
this.GUImode = false;
} }
} catch (err) { } catch (err) {
if (err instanceof GUISupportError) { if (err instanceof GUISupportError) {
this._warnings = err.warnings ?? [err.message]; this._warnings = err.warnings ?? [err.message];
this._errors = err.errors || undefined;
} else { } else {
this._error = err; this._errors = [err.message];
} }
this.GUImode = false; this.GUImode = false;
} finally { } finally {
@ -331,8 +363,10 @@ export abstract class HuiElementEditor<T> extends LitElement {
padding: 8px 0px; padding: 8px 0px;
} }
.error, .error,
.warning { .warning,
.info {
word-break: break-word; word-break: break-word;
margin-top: 8px;
} }
.error { .error {
color: var(--error-color); color: var(--error-color);
@ -340,9 +374,14 @@ export abstract class HuiElementEditor<T> extends LitElement {
.warning { .warning {
color: var(--warning-color); color: var(--warning-color);
} }
.warning ul { .warning ul,
.error ul {
margin: 4px 0; margin: 4px 0;
} }
.warning li,
.error li {
white-space: pre-wrap;
}
ha-circular-progress { ha-circular-progress {
display: block; display: block;
margin: auto; margin: auto;

View File

@ -2,6 +2,9 @@ import {
any, any,
array, array,
boolean, boolean,
dynamic,
enums,
literal,
number, number,
object, object,
optional, optional,
@ -92,12 +95,58 @@ export interface EditSubElementEvent {
subElementConfig: SubElementEditorConfig; subElementConfig: SubElementEditorConfig;
} }
export const actionConfigStruct = object({ export const actionConfigStruct = dynamic((_value, ctx) => {
action: string(), const test = actionConfigMap[ctx.branch[0][ctx.path[0]].action];
navigation_path: optional(string()), return test || actionConfigStructType;
url_path: optional(string()), });
service: optional(string()),
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()), 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({ const buttonEntitiesRowConfigStruct = object({

View File

@ -757,6 +757,18 @@
"day": "{count} {count, plural,\n one {day}\n other {days}\n}", "day": "{count} {count, plural,\n one {day}\n other {days}\n}",
"week": "{count} {count, plural,\n one {week}\n other {weeks}\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": { "login-form": {
"password": "Password", "password": "Password",
"remember": "Remember", "remember": "Remember",

View File

@ -12758,10 +12758,10 @@ subarg@^1.0.0:
dependencies: dependencies:
minimist "^1.1.0" minimist "^1.1.0"
superstruct@^0.10.12: superstruct@^0.10.13:
version "0.10.12" version "0.10.13"
resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.10.12.tgz#7b2c8adaf61b75257265eac3b588f30017f996f0" resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.10.13.tgz#705535a5598ff231bd976601a7b6b534a71a821b"
integrity sha512-FiNhfegyytDI0QxrrEoeGknFM28SnoHqCBpkWewUm8jRNj74NVxLpiiePvkOo41Ze/aKMSHa/twWjNF81mKaQQ== integrity sha512-W4SitSZ9MOyMPbHreoZVEneSZyPEeNGbdfJo/7FkJyRs/M3wQRFzq+t3S/NBwlrFSWdx1ONLjLb9pB+UKe4IqQ==
supports-color@6.0.0: supports-color@6.0.0:
version "6.0.0" version "6.0.0"