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",
"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",

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

View File

@ -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({

View File

@ -52,6 +52,8 @@ export class HuiErrorCard extends LitElement implements LovelaceCard {
}
pre {
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 { 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";

View File

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

View File

@ -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<T> 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<T> 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<T> 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<T> 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}
></ha-code-editor>
</div>
`}
${this._error
${this._guiSupported === false && this.configElementType
? html`
<div class="error">
${this._error}
<div class="info">
${this.hass.localize(
"ui.errors.config.editor_not_available",
"type",
this.configElementType
)}
</div>
`
: ""}
${this._warnings
${this.hasError
? html`
<div class="warning">
UI editor is not supported for this config:
<div class="error">
${this.hass.localize("ui.errors.config.error_detected")}:
<br />
<ul>
${this._warnings.map((warning) => html`<li>${warning}</li>`)}
${this._errors!.map((error) => html`<li>${error}</li>`)}
</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>
`
: ""}
@ -261,14 +285,18 @@ export abstract class HuiElementEditor<T> 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<T> 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<T> 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<T> 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;

View File

@ -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({

View File

@ -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",

View File

@ -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"