mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-23 09:16:38 +00:00
More comprehensive YAML config errors + dynamic checks for action configs (#8217)
This commit is contained in:
parent
5c0e151bc2
commit
0a3172dfdb
@ -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",
|
||||
|
45
src/common/structs/handle-errors.ts
Normal file
45
src/common/structs/handle-errors.ts
Normal 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 };
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -52,6 +52,8 @@ export class HuiErrorCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
pre {
|
||||
font-family: var(--code-font-family, monospace);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
@ -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";
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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({
|
||||
|
@ -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",
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user