mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-28 03:36:44 +00:00
Allow pasting YAML in automation/script editor directly (#24838)
* Allow pasting YAML in automation/script editor directly * Highlight pasted items * Change highlighting * Also reset in script editor * Show dialog when pasting to changed automation/script * Add shortcuts to shortcuts dialog * Use translated shortcut * Change timeout and clear on save * Fix imports * Process code review * Move paste logic to manual-*-editor
This commit is contained in:
parent
29c11978b3
commit
ab415188ba
30
src/common/dom/can-override-input.ts
Normal file
30
src/common/dom/can-override-input.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export const canOverrideAlphanumericInput = (composedPath: EventTarget[]) => {
|
||||
if (composedPath.some((el) => "tagName" in el && el.tagName === "HA-MENU")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const el = composedPath[0] as Element;
|
||||
|
||||
if (el.tagName === "TEXTAREA") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (el.parentElement?.tagName === "HA-SELECT") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (el.tagName !== "INPUT") {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch ((el as HTMLInputElement).type) {
|
||||
case "button":
|
||||
case "checkbox":
|
||||
case "hidden":
|
||||
case "radio":
|
||||
case "range":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
@ -474,3 +474,16 @@ export const migrateAutomationAction = (
|
||||
|
||||
return action;
|
||||
};
|
||||
|
||||
export const normalizeScriptConfig = (config: ScriptConfig): ScriptConfig => {
|
||||
// Normalize data: ensure sequence is a list
|
||||
// Happens when people copy paste their scripts into the config
|
||||
const value = config.sequence;
|
||||
if (value && !Array.isArray(value)) {
|
||||
config.sequence = [value];
|
||||
}
|
||||
if (config.sequence) {
|
||||
config.sequence = migrateAutomationAction(config.sequence);
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
@ -68,6 +68,16 @@ const _SHORTCUTS: Section[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ui.dialogs.shortcuts.automations.title",
|
||||
items: [
|
||||
{
|
||||
type: "shortcut",
|
||||
shortcut: [{ key: "ui.dialogs.shortcuts.shortcuts.ctrl_cmd" }, "V"],
|
||||
key: "ui.dialogs.shortcuts.automations.paste",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ui.dialogs.shortcuts.charts.title",
|
||||
items: [
|
||||
|
@ -674,6 +674,12 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
ha-tooltip {
|
||||
cursor: default;
|
||||
}
|
||||
:host([highlight]) ha-card {
|
||||
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
|
||||
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
|
||||
border-color: var(--state-inactive-color);
|
||||
box-shadow: var(--shadow-default), var(--shadow-focus);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@ -32,6 +32,8 @@ export default class HaAutomationAction extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public actions!: Action[];
|
||||
|
||||
@property({ attribute: false }) public highlightedActions?: Action[];
|
||||
|
||||
@state() private _showReorder = false;
|
||||
|
||||
@storage({
|
||||
@ -91,6 +93,7 @@ export default class HaAutomationAction extends LitElement {
|
||||
@move-up=${this._moveUp}
|
||||
@value-changed=${this._actionChanged}
|
||||
.hass=${this.hass}
|
||||
?highlight=${this.highlightedActions?.includes(action)}
|
||||
>
|
||||
${this._showReorder && !this.disabled
|
||||
? html`
|
||||
|
@ -587,6 +587,12 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
ha-md-menu-item > ha-svg-icon {
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
:host([highlight]) ha-card {
|
||||
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
|
||||
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
|
||||
border-color: var(--state-inactive-color);
|
||||
box-shadow: var(--shadow-default), var(--shadow-focus);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@ -30,6 +30,8 @@ export default class HaAutomationCondition extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public conditions!: Condition[];
|
||||
|
||||
@property({ attribute: false }) public highlightedConditions?: Condition[];
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state() private _showReorder = false;
|
||||
@ -140,6 +142,7 @@ export default class HaAutomationCondition extends LitElement {
|
||||
@move-up=${this._moveUp}
|
||||
@value-changed=${this._conditionChanged}
|
||||
.hass=${this.hass}
|
||||
?highlight=${this.highlightedConditions?.includes(cond)}
|
||||
>
|
||||
${this._showReorder && !this.disabled
|
||||
? html`
|
||||
|
@ -21,7 +21,7 @@ import {
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
@ -81,6 +81,7 @@ import {
|
||||
} from "./automation-save-dialog/show-dialog-automation-save";
|
||||
import "./blueprint-automation-editor";
|
||||
import "./manual-automation-editor";
|
||||
import type { HaManualAutomationEditor } from "./manual-automation-editor";
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@ -149,6 +150,9 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityRegistry!: EntityRegistryEntry[];
|
||||
|
||||
@query("manual-automation-editor")
|
||||
private _manualEditor?: HaManualAutomationEditor;
|
||||
|
||||
private _configSubscriptions: Record<
|
||||
string,
|
||||
(config?: AutomationConfig) => void
|
||||
@ -469,6 +473,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
.stateObj=${stateObj}
|
||||
.config=${this._config}
|
||||
.disabled=${Boolean(this._readOnly)}
|
||||
.dirty=${this._dirty}
|
||||
@value-changed=${this._valueChanged}
|
||||
></manual-automation-editor>
|
||||
`}
|
||||
@ -552,7 +557,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
} as AutomationConfig;
|
||||
this._entityId = undefined;
|
||||
this._readOnly = false;
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
if (changedProps.has("entityId") && this.entityId) {
|
||||
@ -952,6 +956,8 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
||||
return;
|
||||
}
|
||||
|
||||
this._manualEditor?.resetPastedConfig();
|
||||
|
||||
const id = this.automationId || String(Date.now());
|
||||
if (!this.automationId) {
|
||||
const saved = await this._promptAutomationAlias();
|
||||
|
@ -3,17 +3,30 @@ import { mdiHelpCircle } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { load } from "js-yaml";
|
||||
import {
|
||||
any,
|
||||
array,
|
||||
assert,
|
||||
assign,
|
||||
object,
|
||||
optional,
|
||||
string,
|
||||
union,
|
||||
} from "superstruct";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-markdown";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
Condition,
|
||||
ManualAutomationConfig,
|
||||
Trigger,
|
||||
} from "../../../data/automation";
|
||||
import { normalizeAutomationConfig } from "../../../data/automation";
|
||||
import type { Action } from "../../../data/script";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@ -29,6 +42,26 @@ import {
|
||||
removeSearchParam,
|
||||
} from "../../../common/url/search-params";
|
||||
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
|
||||
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
|
||||
|
||||
const baseConfigStruct = object({
|
||||
alias: optional(string()),
|
||||
description: optional(string()),
|
||||
triggers: optional(array(any())),
|
||||
conditions: optional(array(any())),
|
||||
actions: optional(array(any())),
|
||||
mode: optional(string()),
|
||||
max_exceeded: optional(string()),
|
||||
id: optional(string()),
|
||||
});
|
||||
|
||||
const automationConfigStruct = union([
|
||||
assign(baseConfigStruct, object({ triggers: array(any()) })),
|
||||
assign(baseConfigStruct, object({ conditions: array(any()) })),
|
||||
assign(baseConfigStruct, object({ actions: array(any()) })),
|
||||
]);
|
||||
|
||||
@customElement("manual-automation-editor")
|
||||
export class HaManualAutomationEditor extends LitElement {
|
||||
@ -44,6 +77,22 @@ export class HaManualAutomationEditor extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public stateObj?: HassEntity;
|
||||
|
||||
@property({ attribute: false }) public dirty = false;
|
||||
|
||||
@state() private _pastedConfig?: ManualAutomationConfig;
|
||||
|
||||
private _previousConfig?: ManualAutomationConfig;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("paste", this._handlePaste);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
window.removeEventListener("paste", this._handlePaste);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues): void {
|
||||
super.firstUpdated(changedProps);
|
||||
const expanded = extractSearchParam("expanded");
|
||||
@ -123,6 +172,7 @@ export class HaManualAutomationEditor extends LitElement {
|
||||
role="region"
|
||||
aria-labelledby="triggers-heading"
|
||||
.triggers=${this.config.triggers || []}
|
||||
.highlightedTriggers=${this._pastedConfig?.triggers || []}
|
||||
.path=${["triggers"]}
|
||||
@value-changed=${this._triggerChanged}
|
||||
.hass=${this.hass}
|
||||
@ -164,6 +214,7 @@ export class HaManualAutomationEditor extends LitElement {
|
||||
role="region"
|
||||
aria-labelledby="conditions-heading"
|
||||
.conditions=${this.config.conditions || []}
|
||||
.highlightedConditions=${this._pastedConfig?.conditions || []}
|
||||
.path=${["conditions"]}
|
||||
@value-changed=${this._conditionChanged}
|
||||
.hass=${this.hass}
|
||||
@ -203,6 +254,7 @@ export class HaManualAutomationEditor extends LitElement {
|
||||
role="region"
|
||||
aria-labelledby="actions-heading"
|
||||
.actions=${this.config.actions || []}
|
||||
.highlightedActions=${this._pastedConfig?.actions || []}
|
||||
.path=${["actions"]}
|
||||
@value-changed=${this._actionChanged}
|
||||
.hass=${this.hass}
|
||||
@ -214,6 +266,7 @@ export class HaManualAutomationEditor extends LitElement {
|
||||
|
||||
private _triggerChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
this.resetPastedConfig();
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { ...this.config!, triggers: ev.detail.value as Trigger[] },
|
||||
});
|
||||
@ -221,6 +274,7 @@ export class HaManualAutomationEditor extends LitElement {
|
||||
|
||||
private _conditionChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
this.resetPastedConfig();
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.config!,
|
||||
@ -231,6 +285,7 @@ export class HaManualAutomationEditor extends LitElement {
|
||||
|
||||
private _actionChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
this.resetPastedConfig();
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { ...this.config!, actions: ev.detail.value as Action[] },
|
||||
});
|
||||
@ -245,6 +300,152 @@ export class HaManualAutomationEditor extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handlePaste = async (ev: ClipboardEvent) => {
|
||||
if (!canOverrideAlphanumericInput(ev.composedPath())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paste = ev.clipboardData?.getData("text");
|
||||
if (!paste) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded: any = load(paste);
|
||||
if (loaded) {
|
||||
let normalized: AutomationConfig | undefined;
|
||||
|
||||
try {
|
||||
normalized = normalizeAutomationConfig(loaded);
|
||||
} catch (_err: any) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
assert(normalized, automationConfigStruct);
|
||||
} catch (_err: any) {
|
||||
showToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.paste_invalid_config"
|
||||
),
|
||||
duration: 4000,
|
||||
dismissable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.dirty) {
|
||||
const result = await new Promise<boolean>((resolve) => {
|
||||
showPasteReplaceDialog(this, {
|
||||
domain: "automation",
|
||||
pastedConfig: normalized,
|
||||
onClose: () => resolve(false),
|
||||
onAppend: () => {
|
||||
this._appendToExistingConfig(normalized);
|
||||
resolve(false);
|
||||
},
|
||||
onReplace: () => resolve(true),
|
||||
});
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// replace the config completely
|
||||
this._replaceExistingConfig(normalized);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _appendToExistingConfig(config: ManualAutomationConfig) {
|
||||
// make a copy otherwise we will reference the original config
|
||||
this._previousConfig = { ...this.config } as ManualAutomationConfig;
|
||||
this._pastedConfig = config;
|
||||
|
||||
if (!this.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("triggers" in config) {
|
||||
this.config.triggers = ensureArray(this.config.triggers || []).concat(
|
||||
ensureArray(config.triggers)
|
||||
);
|
||||
}
|
||||
if ("conditions" in config) {
|
||||
this.config.conditions = ensureArray(this.config.conditions || []).concat(
|
||||
ensureArray(config.conditions)
|
||||
);
|
||||
}
|
||||
if ("actions" in config) {
|
||||
this.config.actions = ensureArray(this.config.actions || []).concat(
|
||||
ensureArray(config.actions)
|
||||
) as Action[];
|
||||
}
|
||||
|
||||
this._showPastedToastWithUndo();
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.config!,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _replaceExistingConfig(config: ManualAutomationConfig) {
|
||||
// make a copy otherwise we will reference the original config
|
||||
this._previousConfig = { ...this.config } as ManualAutomationConfig;
|
||||
this._pastedConfig = config;
|
||||
this.config = config;
|
||||
|
||||
this._showPastedToastWithUndo();
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.config,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _showPastedToastWithUndo() {
|
||||
showToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.paste_toast_message"
|
||||
),
|
||||
duration: 4000,
|
||||
action: {
|
||||
text: this.hass.localize("ui.common.undo"),
|
||||
action: () => {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this._previousConfig!,
|
||||
},
|
||||
});
|
||||
|
||||
this._previousConfig = undefined;
|
||||
this._pastedConfig = undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public resetPastedConfig() {
|
||||
if (!this._previousConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._pastedConfig = undefined;
|
||||
this._previousConfig = undefined;
|
||||
|
||||
showToast(this, {
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
@ -0,0 +1,101 @@
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { css, type CSSResultGroup, html, LitElement, nothing } from "lit";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import { createCloseHeading } from "../../../../components/ha-dialog";
|
||||
import "../trigger/ha-automation-trigger-row";
|
||||
import type { PasteReplaceDialogParams } from "./show-dialog-paste-replace";
|
||||
|
||||
@customElement("ha-dialog-paste-replace")
|
||||
class DialogPasteReplace extends LitElement implements HassDialog {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _opened = false;
|
||||
|
||||
@state() private _params!: PasteReplaceDialogParams;
|
||||
|
||||
public showDialog(params: PasteReplaceDialogParams): void {
|
||||
this._opened = true;
|
||||
this._params = params;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
if (this._opened) {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
this._opened = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (!this._opened) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@closed=${this.closeDialog}
|
||||
.heading=${createCloseHeading(
|
||||
this.hass,
|
||||
this.hass.localize(
|
||||
`ui.panel.config.${this._params.domain}.editor.paste_confirm.title`
|
||||
)
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.${this._params.domain}.editor.paste_confirm.text`
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ha-yaml-editor
|
||||
.hass=${this.hass}
|
||||
.defaultValue=${this._params?.pastedConfig}
|
||||
read-only
|
||||
></ha-yaml-editor>
|
||||
|
||||
<div slot="primaryAction">
|
||||
<ha-button @click=${this._handleAppend}>
|
||||
${this.hass.localize("ui.common.append")}
|
||||
</ha-button>
|
||||
<ha-button @click=${this._handleReplace}>
|
||||
${this.hass.localize("ui.common.replace")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleReplace() {
|
||||
this._params?.onReplace();
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _handleAppend() {
|
||||
this._params?.onAppend();
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-dialog-paste-replace": DialogPasteReplace;
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { AutomationConfig } from "../../../../data/automation";
|
||||
import type { ScriptConfig } from "../../../../data/script";
|
||||
|
||||
export const loadPasteReplaceDialog = () => import("./dialog-paste-replace");
|
||||
|
||||
interface BasePasteReplaceDialogParams<D, T> {
|
||||
domain: D;
|
||||
pastedConfig: T;
|
||||
onClose: () => void;
|
||||
onAppend: () => void;
|
||||
onReplace: () => void;
|
||||
}
|
||||
|
||||
export type PasteReplaceDialogParams =
|
||||
| BasePasteReplaceDialogParams<"automation", AutomationConfig>
|
||||
| BasePasteReplaceDialogParams<"script", ScriptConfig>;
|
||||
|
||||
export const showPasteReplaceDialog = (
|
||||
element: HTMLElement,
|
||||
params: PasteReplaceDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "ha-dialog-paste-replace",
|
||||
dialogImport: loadPasteReplaceDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
@ -738,6 +738,12 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
ha-md-menu-item > ha-svg-icon {
|
||||
--mdc-icon-size: 24px;
|
||||
}
|
||||
:host([highlight]) ha-card {
|
||||
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
|
||||
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
|
||||
border-color: var(--state-inactive-color);
|
||||
box-shadow: var(--shadow-default), var(--shadow-focus);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@ -32,6 +32,8 @@ export default class HaAutomationTrigger extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public triggers!: Trigger[];
|
||||
|
||||
@property({ attribute: false }) public highlightedTriggers?: Trigger[];
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state() private _showReorder = false;
|
||||
@ -92,6 +94,7 @@ export default class HaAutomationTrigger extends LitElement {
|
||||
@value-changed=${this._triggerChanged}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
?highlight=${this.highlightedTriggers?.includes(trg)}
|
||||
>
|
||||
${this._showReorder && !this.disabled
|
||||
? html`
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import "@material/mwc-button";
|
||||
import {
|
||||
mdiCog,
|
||||
@ -20,21 +21,23 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consume } from "@lit/context";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import { afterNextRender } from "../../../common/util/render-status";
|
||||
import { promiseTimeout } from "../../../common/util/promise-timeout";
|
||||
import { afterNextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-button-menu";
|
||||
import "../../../components/ha-fab";
|
||||
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-yaml-editor";
|
||||
import { substituteBlueprint } from "../../../data/blueprint";
|
||||
import { validateConfig } from "../../../data/config";
|
||||
import { fullEntitiesContext } from "../../../data/context";
|
||||
import { UNAVAILABLE } from "../../../data/entity";
|
||||
import {
|
||||
type EntityRegistryEntry,
|
||||
@ -42,12 +45,12 @@ import {
|
||||
} from "../../../data/entity_registry";
|
||||
import type { BlueprintScriptConfig, ScriptConfig } from "../../../data/script";
|
||||
import {
|
||||
normalizeScriptConfig,
|
||||
deleteScript,
|
||||
fetchScriptFileConfig,
|
||||
getScriptEditorInitData,
|
||||
getScriptStateConfig,
|
||||
hasScriptFields,
|
||||
migrateAutomationAction,
|
||||
showScriptEditor,
|
||||
triggerScript,
|
||||
} from "../../../data/script";
|
||||
@ -58,21 +61,18 @@ import {
|
||||
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import "../../../layouts/hass-subpage";
|
||||
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, HomeAssistant, Route } from "../../../types";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { showAutomationModeDialog } from "../automation/automation-mode-dialog/show-dialog-automation-mode";
|
||||
import type { EntityRegistryUpdate } from "../automation/automation-save-dialog/show-dialog-automation-save";
|
||||
import { showAutomationSaveDialog } from "../automation/automation-save-dialog/show-dialog-automation-save";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import "./blueprint-script-editor";
|
||||
import "./manual-script-editor";
|
||||
import type { HaManualScriptEditor } from "./manual-script-editor";
|
||||
import { substituteBlueprint } from "../../../data/blueprint";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
|
||||
import { fullEntitiesContext } from "../../../data/context";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
|
||||
export class HaScriptEditor extends SubscribeMixin(
|
||||
PreventUnsavedMixin(KeyboardShortcutMixin(LitElement))
|
||||
@ -427,6 +427,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
.isWide=${this.isWide}
|
||||
.config=${this._config}
|
||||
.disabled=${this._readOnly}
|
||||
.dirty=${this._dirty}
|
||||
@value-changed=${this._valueChanged}
|
||||
></manual-script-editor>
|
||||
`}
|
||||
@ -499,12 +500,11 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
...initData,
|
||||
} as ScriptConfig;
|
||||
this._readOnly = false;
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
if (changedProps.has("entityId") && this.entityId) {
|
||||
getScriptStateConfig(this.hass, this.entityId).then((c) => {
|
||||
this._config = this._normalizeConfig(c.config);
|
||||
this._config = normalizeScriptConfig(c.config);
|
||||
this._checkValidation();
|
||||
});
|
||||
const regEntry = this.entityRegistry.find(
|
||||
@ -543,25 +543,12 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
);
|
||||
}
|
||||
|
||||
private _normalizeConfig(config: ScriptConfig): ScriptConfig {
|
||||
// Normalize data: ensure sequence is a list
|
||||
// Happens when people copy paste their scripts into the config
|
||||
const value = config.sequence;
|
||||
if (value && !Array.isArray(value)) {
|
||||
config.sequence = [value];
|
||||
}
|
||||
if (config.sequence) {
|
||||
config.sequence = migrateAutomationAction(config.sequence);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
fetchScriptFileConfig(this.hass, this.scriptId!).then(
|
||||
(config) => {
|
||||
this._dirty = false;
|
||||
this._readOnly = false;
|
||||
this._config = this._normalizeConfig(config);
|
||||
this._config = normalizeScriptConfig(config);
|
||||
const entity = this.entityRegistry.find(
|
||||
(ent) => ent.platform === "script" && ent.unique_id === this.scriptId
|
||||
);
|
||||
@ -770,7 +757,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
);
|
||||
|
||||
const newConfig = {
|
||||
...this._normalizeConfig(result.substituted_config),
|
||||
...normalizeScriptConfig(result.substituted_config),
|
||||
alias: config.alias,
|
||||
description: config.description,
|
||||
};
|
||||
@ -913,6 +900,8 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
return;
|
||||
}
|
||||
|
||||
this._manualEditor?.resetPastedConfig();
|
||||
|
||||
if (!this.scriptId) {
|
||||
const saved = await this._promptScriptAlias();
|
||||
if (!saved) {
|
||||
|
@ -353,6 +353,12 @@ export default class HaScriptFieldRow extends LitElement {
|
||||
li[role="separator"] {
|
||||
border-bottom-color: var(--divider-color);
|
||||
}
|
||||
:host([highlight]) ha-card {
|
||||
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
|
||||
--shadow-focus: 0 0 0 1px var(--state-inactive-color);
|
||||
border-color: var(--state-inactive-color);
|
||||
box-shadow: var(--shadow-default), var(--shadow-focus);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@ -20,6 +20,8 @@ export default class HaScriptFields extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public fields!: Fields;
|
||||
|
||||
@property({ attribute: false }) public highlightedFields?: Fields;
|
||||
|
||||
private _focusLastActionOnChange = false;
|
||||
|
||||
protected render() {
|
||||
@ -37,6 +39,7 @@ export default class HaScriptFields extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._fieldChanged}
|
||||
.hass=${this.hass}
|
||||
?highlight=${this.highlightedFields?.[key] !== undefined}
|
||||
>
|
||||
</ha-script-field-row>
|
||||
`
|
||||
|
@ -2,7 +2,18 @@ import "@material/mwc-button/mwc-button";
|
||||
import { mdiHelpCircle } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { load } from "js-yaml";
|
||||
import {
|
||||
any,
|
||||
array,
|
||||
assert,
|
||||
enums,
|
||||
number,
|
||||
object,
|
||||
optional,
|
||||
string,
|
||||
} from "superstruct";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
|
||||
import {
|
||||
@ -13,6 +24,7 @@ import "../../../components/ha-card";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-markdown";
|
||||
import type { Action, Fields, ScriptConfig } from "../../../data/script";
|
||||
import { MODES, normalizeScriptConfig } from "../../../data/script";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
@ -20,6 +32,20 @@ import "../automation/action/ha-automation-action";
|
||||
import type HaAutomationAction from "../automation/action/ha-automation-action";
|
||||
import "./ha-script-fields";
|
||||
import type HaScriptFields from "./ha-script-fields";
|
||||
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { showPasteReplaceDialog } from "../automation/paste-replace-dialog/show-dialog-paste-replace";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
|
||||
const scriptConfigStruct = object({
|
||||
alias: optional(string()),
|
||||
description: optional(string()),
|
||||
sequence: optional(array(any())),
|
||||
icon: optional(string()),
|
||||
mode: optional(enums([typeof MODES])),
|
||||
max: optional(number()),
|
||||
fields: optional(object()),
|
||||
});
|
||||
|
||||
@customElement("manual-script-editor")
|
||||
export class HaManualScriptEditor extends LitElement {
|
||||
@ -33,11 +59,17 @@ export class HaManualScriptEditor extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public config!: ScriptConfig;
|
||||
|
||||
@property({ attribute: false }) public dirty = false;
|
||||
|
||||
@query("ha-script-fields")
|
||||
private _scriptFields?: HaScriptFields;
|
||||
|
||||
private _openFields = false;
|
||||
|
||||
@state() private _pastedConfig?: ScriptConfig;
|
||||
|
||||
private _previousConfig?: ScriptConfig;
|
||||
|
||||
public addFields() {
|
||||
this._openFields = true;
|
||||
fireEvent(this, "value-changed", {
|
||||
@ -126,6 +158,7 @@ export class HaManualScriptEditor extends LitElement {
|
||||
role="region"
|
||||
aria-labelledby="fields-heading"
|
||||
.fields=${this.config.fields}
|
||||
.highlightedFields=${this._pastedConfig?.fields}
|
||||
@value-changed=${this._fieldsChanged}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
@ -154,6 +187,7 @@ export class HaManualScriptEditor extends LitElement {
|
||||
role="region"
|
||||
aria-labelledby="sequence-heading"
|
||||
.actions=${this.config.sequence || []}
|
||||
.highlightedActions=${this._pastedConfig?.sequence || []}
|
||||
.path=${["sequence"]}
|
||||
@value-changed=${this._sequenceChanged}
|
||||
.hass=${this.hass}
|
||||
@ -165,6 +199,7 @@ export class HaManualScriptEditor extends LitElement {
|
||||
|
||||
private _fieldsChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
this.resetPastedConfig();
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { ...this.config!, fields: ev.detail.value as Fields },
|
||||
});
|
||||
@ -172,11 +207,165 @@ export class HaManualScriptEditor extends LitElement {
|
||||
|
||||
private _sequenceChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
this.resetPastedConfig();
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { ...this.config!, sequence: ev.detail.value as Action[] },
|
||||
});
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("paste", this._handlePaste);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
window.removeEventListener("paste", this._handlePaste);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private _handlePaste = async (ev: ClipboardEvent) => {
|
||||
// Ignore events on inputs/textareas
|
||||
if (!canOverrideAlphanumericInput(ev.composedPath())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const paste = ev.clipboardData?.getData("text");
|
||||
if (!paste) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded: any = load(paste);
|
||||
if (loaded) {
|
||||
let normalized: ScriptConfig | undefined;
|
||||
|
||||
try {
|
||||
normalized = normalizeScriptConfig(loaded);
|
||||
} catch (_err: any) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
assert(normalized, scriptConfigStruct);
|
||||
} catch (_err: any) {
|
||||
showToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.script.editor.paste_invalid_config"
|
||||
),
|
||||
duration: 4000,
|
||||
dismissable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized) {
|
||||
ev.preventDefault();
|
||||
|
||||
if (this.dirty) {
|
||||
const result = await new Promise<boolean>((resolve) => {
|
||||
showPasteReplaceDialog(this, {
|
||||
domain: "script",
|
||||
pastedConfig: normalized,
|
||||
onClose: () => resolve(false),
|
||||
onAppend: () => {
|
||||
this._appendToExistingConfig(normalized);
|
||||
resolve(false);
|
||||
},
|
||||
onReplace: () => resolve(true),
|
||||
});
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// replace the config completely
|
||||
this._replaceExistingConfig(normalized);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _appendToExistingConfig(config: ScriptConfig) {
|
||||
// make a copy otherwise we will reference the original config
|
||||
this._previousConfig = { ...this.config } as ScriptConfig;
|
||||
this._pastedConfig = config;
|
||||
|
||||
if (!this.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("fields" in config) {
|
||||
this.config.fields = {
|
||||
...this.config.fields,
|
||||
...config.fields,
|
||||
};
|
||||
}
|
||||
if ("sequence" in config) {
|
||||
this.config.sequence = ensureArray(this.config.sequence || []).concat(
|
||||
ensureArray(config.sequence)
|
||||
) as Action[];
|
||||
}
|
||||
|
||||
this._showPastedToastWithUndo();
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.config,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _replaceExistingConfig(config: ScriptConfig) {
|
||||
// make a copy otherwise we will reference the original config
|
||||
this._previousConfig = { ...this.config } as ScriptConfig;
|
||||
this._pastedConfig = config;
|
||||
this.config = config;
|
||||
|
||||
this._showPastedToastWithUndo();
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.config,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _showPastedToastWithUndo() {
|
||||
showToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.script.editor.paste_toast_message"
|
||||
),
|
||||
duration: 4000,
|
||||
action: {
|
||||
text: this.hass.localize("ui.common.undo"),
|
||||
action: () => {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this._previousConfig!,
|
||||
},
|
||||
});
|
||||
|
||||
this._previousConfig = undefined;
|
||||
this._pastedConfig = undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public resetPastedConfig() {
|
||||
if (!this._previousConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._pastedConfig = undefined;
|
||||
this._previousConfig = undefined;
|
||||
|
||||
showToast(this, {
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
@ -14,6 +14,7 @@ import { showToast } from "../util/toast";
|
||||
import type { HassElement } from "./hass-element";
|
||||
import { extractSearchParamsObject } from "../common/url/search-params";
|
||||
import { showVoiceCommandDialog } from "../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import { canOverrideAlphanumericInput } from "../common/dom/can-override-input";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@ -80,7 +81,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
|
||||
private _showVoiceCommandDialog(e: KeyboardEvent) {
|
||||
if (
|
||||
!this.hass?.enableShortcuts ||
|
||||
!this._canOverrideAlphanumericInput(e) ||
|
||||
!canOverrideAlphanumericInput(e.composedPath()) ||
|
||||
!this._conversation(this.hass.config.components)
|
||||
) {
|
||||
return;
|
||||
@ -113,7 +114,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
|
||||
private async _createMyLink(e: KeyboardEvent) {
|
||||
if (
|
||||
!this.hass?.enableShortcuts ||
|
||||
!this._canOverrideAlphanumericInput(e)
|
||||
!canOverrideAlphanumericInput(e.composedPath())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@ -182,42 +183,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
|
||||
return (
|
||||
this.hass?.user?.is_admin &&
|
||||
this.hass.enableShortcuts &&
|
||||
this._canOverrideAlphanumericInput(e)
|
||||
canOverrideAlphanumericInput(e.composedPath())
|
||||
);
|
||||
}
|
||||
|
||||
private _canOverrideAlphanumericInput(e: KeyboardEvent) {
|
||||
const composedPath = e.composedPath();
|
||||
|
||||
if (
|
||||
composedPath.some((el) => "tagName" in el && el.tagName === "HA-MENU")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const el = composedPath[0] as Element;
|
||||
|
||||
if (el.tagName === "TEXTAREA") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (el.parentElement?.tagName === "HA-SELECT") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (el.tagName !== "INPUT") {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch ((el as HTMLInputElement).type) {
|
||||
case "button":
|
||||
case "checkbox":
|
||||
case "hidden":
|
||||
case "radio":
|
||||
case "range":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -372,7 +372,9 @@
|
||||
"default": "Default",
|
||||
"dont_save": "Don't save",
|
||||
"copy": "Copy",
|
||||
"show": "Show"
|
||||
"show": "Show",
|
||||
"replace": "Replace",
|
||||
"append": "Append"
|
||||
},
|
||||
"components": {
|
||||
"selectors": {
|
||||
@ -1935,6 +1937,10 @@
|
||||
"title": "Assist",
|
||||
"open_assist": "open Assist dialog"
|
||||
},
|
||||
"automations": {
|
||||
"title": "Automations",
|
||||
"paste": "to paste automation YAML from clipboard to automation editor"
|
||||
},
|
||||
"charts": {
|
||||
"title": "Charts",
|
||||
"drag_to_zoom": "to zoom in part of a chart",
|
||||
@ -4343,7 +4349,13 @@
|
||||
"add_category": "Add category",
|
||||
"add_labels": "Add labels",
|
||||
"add_area": "Add area"
|
||||
}
|
||||
},
|
||||
"paste_confirm": {
|
||||
"title": "Pasted automation",
|
||||
"text": "How do you want to paste your automation?"
|
||||
},
|
||||
"paste_toast_message": "Pasted automation from clipboard",
|
||||
"paste_invalid_config": "Pasted automation is not editable in the visual editor"
|
||||
},
|
||||
"trace": {
|
||||
"refresh": "[%key:ui::common::refresh%]",
|
||||
@ -4576,7 +4588,13 @@
|
||||
"unsaved_new_text": "You can save your changes, or delete this script. You can't undo this action.",
|
||||
"unsaved_confirm_title": "Save changes?",
|
||||
"unsaved_confirm_text": "You have made some changes in this script. You can save these changes, or discard them and leave. You can't undo this action."
|
||||
}
|
||||
},
|
||||
"paste_confirm": {
|
||||
"title": "Pasted script",
|
||||
"text": "How do you want to paste your script?"
|
||||
},
|
||||
"paste_toast_message": "Pasted script from clipboard",
|
||||
"paste_invalid_config": "Pasted script is not editable in the visual editor"
|
||||
},
|
||||
"trace": {
|
||||
"edit_script": "Edit script"
|
||||
|
Loading…
x
Reference in New Issue
Block a user