From 5cd6f22e996321e0b80620c38e18734fd6fc6a4b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 22 Dec 2024 16:59:04 +0100 Subject: [PATCH] Add category and labels to automation/script save and rename dialog (#23240) Co-authored-by: Bram Kragten --- src/common/util/debounce.ts | 6 +- src/components/ha-fab.ts | 3 + src/mixins/prevent-unsaved-mixin.ts | 2 +- .../dialog-automation-rename.ts | 211 ++++++++++++++---- .../show-dialog-automation-rename.ts | 28 ++- .../config/automation/ha-automation-editor.ts | 93 +++++++- src/panels/config/script/ha-script-editor.ts | 80 ++++++- src/translations/en.json | 6 + 8 files changed, 355 insertions(+), 74 deletions(-) diff --git a/src/common/util/debounce.ts b/src/common/util/debounce.ts index cd61f072b95d..ec96fa63eb56 100644 --- a/src/common/util/debounce.ts +++ b/src/common/util/debounce.ts @@ -3,7 +3,7 @@ // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for // N milliseconds. If `immediate` is passed, trigger the function on the -// leading edge, instead of the trailing. +// leading edge and on the trailing. export const debounce = ( func: (...args: T) => void, @@ -14,9 +14,7 @@ export const debounce = ( const debouncedFunc = (...args: T): void => { const later = () => { timeout = undefined; - if (!immediate) { - func(...args); - } + func(...args); }; const callNow = immediate && !timeout; clearTimeout(timeout); diff --git a/src/components/ha-fab.ts b/src/components/ha-fab.ts index b5ef0364026a..a477cea80fd2 100644 --- a/src/components/ha-fab.ts +++ b/src/components/ha-fab.ts @@ -19,6 +19,9 @@ export class HaFab extends FabBase { margin-inline-end: 12px; direction: var(--direction); } + :disabled { + opacity: var(--light-disabled-opacity); + } `, // safari workaround - must be explicit mainWindow.document.dir === "rtl" diff --git a/src/mixins/prevent-unsaved-mixin.ts b/src/mixins/prevent-unsaved-mixin.ts index a1c7b5af9219..352710ba4e7a 100644 --- a/src/mixins/prevent-unsaved-mixin.ts +++ b/src/mixins/prevent-unsaved-mixin.ts @@ -30,7 +30,7 @@ export const PreventUnsavedMixin = >( window.removeEventListener("beforeunload", this._handleUnload); } - public willUpdate(changedProperties: PropertyValues): void { + protected willUpdate(changedProperties: PropertyValues): void { super.willUpdate(changedProperties); if (this.isDirty) { diff --git a/src/panels/config/automation/automation-rename-dialog/dialog-automation-rename.ts b/src/panels/config/automation/automation-rename-dialog/dialog-automation-rename.ts index 1a646d0045b8..cf429a318796 100644 --- a/src/panels/config/automation/automation-rename-dialog/dialog-automation-rename.ts +++ b/src/panels/config/automation/automation-rename-dialog/dialog-automation-rename.ts @@ -2,19 +2,25 @@ import "@material/mwc-button"; import type { CSSResultGroup } from "lit"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { mdiClose, mdiPlus } from "@mdi/js"; import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/ha-alert"; -import { createCloseHeading } from "../../../../components/ha-dialog"; import "../../../../components/ha-domain-icon"; import "../../../../components/ha-icon-picker"; import "../../../../components/ha-textarea"; import "../../../../components/ha-textfield"; +import "../../../../components/ha-labels-picker"; +import "../../category/ha-category-picker"; +import "../../../../components/ha-expansion-panel"; +import "../../../../components/chips/ha-chip-set"; +import "../../../../components/chips/ha-assist-chip"; import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import { haStyle, haStyleDialog } from "../../../../resources/styles"; import type { HomeAssistant } from "../../../../types"; import type { AutomationRenameDialogParams, + EntityRegistryUpdate, ScriptRenameDialogParams, } from "./show-dialog-automation-rename"; @@ -26,6 +32,10 @@ class DialogAutomationRename extends LitElement implements HassDialog { @state() private _error?: string; + @state() private _visibleOptionals: string[] = []; + + @state() private _entryUpdates!: EntityRegistryUpdate; + private _params!: AutomationRenameDialogParams | ScriptRenameDialogParams; private _newName?: string; @@ -46,6 +56,17 @@ class DialogAutomationRename extends LitElement implements HassDialog { `ui.panel.config.${this._params.domain}.editor.default_name` ); this._newDescription = params.config.description || ""; + this._entryUpdates = params.entityRegistryUpdate || { + labels: params.entityRegistryEntry?.labels || [], + category: params.entityRegistryEntry?.categories[params.domain] || "", + }; + + this._visibleOptionals = [ + this._newDescription ? "description" : "", + this._newIcon ? "icon" : "", + this._entryUpdates.category ? "category" : "", + this._entryUpdates.labels.length > 0 ? "labels" : "", + ]; } public closeDialog(): void { @@ -55,6 +76,19 @@ class DialogAutomationRename extends LitElement implements HassDialog { fireEvent(this, "dialog-closed", { dialog: this.localName }); } this._opened = false; + this._visibleOptionals = []; + } + + protected _renderOptionalChip(id: string, label: string) { + if (this._visibleOptionals.includes(id)) { + return nothing; + } + + return html` + + + + `; } protected render() { @@ -66,15 +100,27 @@ class DialogAutomationRename extends LitElement implements HassDialog { open scrimClickAction @closed=${this.closeDialog} - .heading=${createCloseHeading( - this.hass, - this.hass.localize( - this._params.config.alias - ? "ui.panel.config.automation.editor.rename" - : "ui.panel.config.automation.editor.save" - ) + .heading=${this.hass.localize( + this._params.config.alias + ? "ui.panel.config.automation.editor.rename" + : "ui.panel.config.automation.editor.save" )} > + + + ${this.hass.localize( + this._params.config.alias + ? "ui.panel.config.automation.editor.rename" + : "ui.panel.config.automation.editor.save" + )} + ${this._error ? html`${this.hass.localize( @@ -96,7 +142,8 @@ class DialogAutomationRename extends LitElement implements HassDialog { @input=${this._valueChanged} > - ${this._params.domain === "script" + ${this._params.domain === "script" && + this._visibleOptionals.includes("icon") ? html` ` : nothing} - ` + : nothing} + ${this._visibleOptionals.includes("category") + ? html` ` + : nothing} + ${this._visibleOptionals.includes("labels") + ? html` ` + : nothing} + + + ${this._renderOptionalChip( + "description", + this.hass.localize( + "ui.panel.config.automation.editor.dialog.add_description" + ) )} - .placeholder=${this.hass.localize( - "ui.panel.config.automation.editor.description.placeholder" + ${this._params.domain === "script" + ? this._renderOptionalChip( + "icon", + this.hass.localize( + "ui.panel.config.automation.editor.dialog.add_icon" + ) + ) + : nothing} + ${this._renderOptionalChip( + "category", + this.hass.localize( + "ui.panel.config.automation.editor.dialog.add_category" + ) )} - name="description" - autogrow - .value=${this._newDescription} - @input=${this._valueChanged} - > - - - ${this.hass.localize("ui.dialogs.generic.cancel")} - - - ${this.hass.localize( - this._params.config.alias - ? "ui.panel.config.automation.editor.rename" - : "ui.panel.config.automation.editor.save" + ${this._renderOptionalChip( + "labels", + this.hass.localize( + "ui.panel.config.automation.editor.dialog.add_labels" + ) )} - + + +
+ + ${this.hass.localize("ui.dialogs.generic.cancel")} + + + ${this.hass.localize( + this._params.config.alias + ? "ui.panel.config.automation.editor.rename" + : "ui.panel.config.automation.editor.save" + )} + +
`; } + private _addOptional(ev) { + ev.stopPropagation(); + const option: string = ev.target.id; + this._visibleOptionals = [...this._visibleOptionals, option]; + } + + private _registryEntryChanged(ev) { + ev.stopPropagation(); + const id: string = ev.target.id; + const value = ev.detail.value; + + this._entryUpdates = { ...this._entryUpdates, [id]: value }; + } + private _iconChanged(ev: CustomEvent) { ev.stopPropagation(); this._newIcon = ev.detail.value || undefined; @@ -162,19 +273,26 @@ class DialogAutomationRename extends LitElement implements HassDialog { this._error = "Name is required"; return; } + if (this._params.domain === "script") { - this._params.updateConfig({ - ...this._params.config, - alias: this._newName, - description: this._newDescription, - icon: this._newIcon, - }); + this._params.updateConfig( + { + ...this._params.config, + alias: this._newName, + description: this._newDescription, + icon: this._newIcon, + }, + this._entryUpdates + ); } else { - this._params.updateConfig({ - ...this._params.config, - alias: this._newName, - description: this._newDescription, - }); + this._params.updateConfig( + { + ...this._params.config, + alias: this._newName, + description: this._newDescription, + }, + this._entryUpdates + ); } this.closeDialog(); @@ -185,12 +303,21 @@ class DialogAutomationRename extends LitElement implements HassDialog { haStyle, haStyleDialog, css` + ha-dialog { + --dialog-content-padding: 0 24px 24px 24px; + } ha-textfield, ha-textarea, - ha-icon-picker { + ha-icon-picker, + ha-category-picker, + ha-labels-picker, + ha-chip-set { display: block; } - ha-icon-picker { + ha-icon-picker, + ha-category-picker, + ha-labels-picker, + ha-chip-set { margin-top: 16px; } ha-alert { diff --git a/src/panels/config/automation/automation-rename-dialog/show-dialog-automation-rename.ts b/src/panels/config/automation/automation-rename-dialog/show-dialog-automation-rename.ts index c70636240885..7ee809c3c08c 100644 --- a/src/panels/config/automation/automation-rename-dialog/show-dialog-automation-rename.ts +++ b/src/panels/config/automation/automation-rename-dialog/show-dialog-automation-rename.ts @@ -1,22 +1,38 @@ import { fireEvent } from "../../../../common/dom/fire_event"; import type { AutomationConfig } from "../../../../data/automation"; import type { ScriptConfig } from "../../../../data/script"; +import type { EntityRegistryEntry } from "../../../../data/entity_registry"; export const loadAutomationRenameDialog = () => import("./dialog-automation-rename"); -export interface AutomationRenameDialogParams { +interface BaseRenameDialogParams { + entityRegistryUpdate?: EntityRegistryUpdate; + entityRegistryEntry?: EntityRegistryEntry; + onClose: () => void; +} + +export interface EntityRegistryUpdate { + labels: string[]; + category: string; +} + +export interface AutomationRenameDialogParams extends BaseRenameDialogParams { config: AutomationConfig; domain: "automation"; - updateConfig: (config: AutomationConfig) => void; - onClose: () => void; + updateConfig: ( + config: AutomationConfig, + entityRegistryUpdate: EntityRegistryUpdate + ) => void; } -export interface ScriptRenameDialogParams { +export interface ScriptRenameDialogParams extends BaseRenameDialogParams { config: ScriptConfig; domain: "script"; - updateConfig: (config: ScriptConfig) => void; - onClose: () => void; + updateConfig: ( + config: ScriptConfig, + entityRegistryUpdate: EntityRegistryUpdate + ) => void; } export const showAutomationRenameDialog = ( diff --git a/src/panels/config/automation/ha-automation-editor.ts b/src/panels/config/automation/ha-automation-editor.ts index 5a4a4ce6e006..fb5d36368062 100644 --- a/src/panels/config/automation/ha-automation-editor.ts +++ b/src/panels/config/automation/ha-automation-editor.ts @@ -53,8 +53,8 @@ import { substituteBlueprint } from "../../../data/blueprint"; import { validateConfig } from "../../../data/config"; import { UNAVAILABLE } from "../../../data/entity"; import { - fetchEntityRegistry, type EntityRegistryEntry, + updateEntityRegistryEntry, } from "../../../data/entity_registry"; import { showAlertDialog, @@ -67,7 +67,10 @@ import type { Entries, HomeAssistant, Route } from "../../../types"; import { showToast } from "../../../util/toast"; import "../ha-config-section"; import { showAutomationModeDialog } from "./automation-mode-dialog/show-dialog-automation-mode"; -import { showAutomationRenameDialog } from "./automation-rename-dialog/show-dialog-automation-rename"; +import { + type EntityRegistryUpdate, + showAutomationRenameDialog, +} from "./automation-rename-dialog/show-dialog-automation-rename"; import "./blueprint-automation-editor"; import "./manual-automation-editor"; import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog"; @@ -137,6 +140,12 @@ export class HaAutomationEditor extends PreventUnsavedMixin( }) private _registryEntry?: EntityRegistryEntry; + @state() private _saving = false; + + @state() + @consume({ context: fullEntitiesContext, subscribe: true }) + _entityRegistry!: EntityRegistryEntry[]; + private _configSubscriptions: Record< string, (config?: AutomationConfig) => void @@ -144,6 +153,33 @@ export class HaAutomationEditor extends PreventUnsavedMixin( private _configSubscriptionsId = 1; + private _entityRegistryUpdate?: EntityRegistryUpdate; + + private _newAutomationId?: string; + + private _entityRegCreated?: ( + value: PromiseLike | EntityRegistryEntry + ) => void; + + protected willUpdate(changedProps) { + super.willUpdate(changedProps); + + if ( + this._entityRegCreated && + this._newAutomationId && + changedProps.has("entityRegistry") + ) { + const automation = this._entityRegistry.find( + (entity: EntityRegistryEntry) => + entity.unique_id === this._newAutomationId + ); + if (automation) { + this._entityRegCreated(automation); + this._entityRegCreated = undefined; + } + } + } + protected render(): TemplateResult | typeof nothing { if (!this._config) { return nothing; @@ -456,8 +492,11 @@ export class HaAutomationEditor extends PreventUnsavedMixin( @@ -577,8 +616,7 @@ export class HaAutomationEditor extends PreventUnsavedMixin( this._config = normalizeAutomationConfig(config); this._checkValidation(); } catch (err: any) { - const entityRegistry = await fetchEntityRegistry(this.hass.connection); - const entity = entityRegistry.find( + const entity = this._entityRegistry.find( (ent) => ent.platform === "automation" && ent.unique_id === this.automationId ); @@ -841,13 +879,16 @@ export class HaAutomationEditor extends PreventUnsavedMixin( showAutomationRenameDialog(this, { config: this._config!, domain: "automation", - updateConfig: (config) => { + updateConfig: (config, entityRegistryUpdate) => { this._config = config; + this._entityRegistryUpdate = entityRegistryUpdate; this._dirty = true; this.requestUpdate(); resolve(true); }, onClose: () => resolve(false), + entityRegistryUpdate: this._entityRegistryUpdate, + entityRegistryEntry: this._registryEntry, }); }); } @@ -883,21 +924,49 @@ export class HaAutomationEditor extends PreventUnsavedMixin( } } + this._saving = true; this._validationErrors = undefined; + try { await saveAutomationConfig(this.hass, id, this._config!); + + if (this._entityRegistryUpdate !== undefined) { + let entityId = this._entityId; + + // wait for automation to appear in entity registry when creating a new automation + if (!entityId) { + this._newAutomationId = id; + const automation = await new Promise( + (resolve) => { + this._entityRegCreated = resolve; + } + ); + entityId = automation.entity_id; + } + + if (entityId) { + await updateEntityRegistryEntry(this.hass, entityId, { + categories: { + automation: this._entityRegistryUpdate.category || null, + }, + labels: this._entityRegistryUpdate.labels || [], + }); + } + } + + this._dirty = false; + + if (!this.automationId) { + navigate(`/config/automation/edit/${id}`, { replace: true }); + } } catch (errors: any) { this._errors = errors.body.message || errors.error || errors.body; showToast(this, { message: errors.body.message || errors.error || errors.body, }); throw errors; - } - - this._dirty = false; - - if (!this.automationId) { - navigate(`/config/automation/edit/${id}`, { replace: true }); + } finally { + this._saving = false; } } diff --git a/src/panels/config/script/ha-script-editor.ts b/src/panels/config/script/ha-script-editor.ts index 5fce4fb94e0e..c75f8c151af0 100644 --- a/src/panels/config/script/ha-script-editor.ts +++ b/src/panels/config/script/ha-script-editor.ts @@ -35,7 +35,10 @@ import "../../../components/ha-svg-icon"; import "../../../components/ha-yaml-editor"; import { validateConfig } from "../../../data/config"; import { UNAVAILABLE } from "../../../data/entity"; -import type { EntityRegistryEntry } from "../../../data/entity_registry"; +import { + type EntityRegistryEntry, + updateEntityRegistryEntry, +} from "../../../data/entity_registry"; import type { BlueprintScriptConfig, ScriptConfig } from "../../../data/script"; import { deleteScript, @@ -58,6 +61,7 @@ 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-rename-dialog/show-dialog-automation-rename"; import { showAutomationRenameDialog } from "../automation/automation-rename-dialog/show-dialog-automation-rename"; import "./blueprint-script-editor"; import "./manual-script-editor"; @@ -116,6 +120,34 @@ export class HaScriptEditor extends SubscribeMixin( @state() private _blueprintConfig?: BlueprintScriptConfig; + @state() private _saving = false; + + private _entityRegistryUpdate?: EntityRegistryUpdate; + + private _newScriptId?: string; + + private _entityRegCreated?: ( + value: PromiseLike | EntityRegistryEntry + ) => void; + + protected willUpdate(changedProps) { + super.willUpdate(changedProps); + + if ( + this._entityRegCreated && + this._newScriptId && + changedProps.has("entityRegistry") + ) { + const script = this.entityRegistry.find( + (entity: EntityRegistryEntry) => entity.unique_id === this._newScriptId + ); + if (script) { + this._entityRegCreated(script); + this._entityRegCreated = undefined; + } + } + } + protected render(): TemplateResult | typeof nothing { if (!this._config) { return nothing; @@ -410,11 +442,12 @@ export class HaScriptEditor extends SubscribeMixin( @@ -812,13 +845,18 @@ export class HaScriptEditor extends SubscribeMixin( showAutomationRenameDialog(this, { config: this._config!, domain: "script", - updateConfig: (config) => { + updateConfig: (config, entityRegistryUpdate) => { this._config = config; + this._entityRegistryUpdate = entityRegistryUpdate; this._dirty = true; this.requestUpdate(); resolve(true); }, onClose: () => resolve(false), + entityRegistryUpdate: this._entityRegistryUpdate, + entityRegistryEntry: this.entityRegistry.find( + (entry) => entry.unique_id === this.scriptId + ), }); }); } @@ -855,24 +893,48 @@ export class HaScriptEditor extends SubscribeMixin( } const id = this.scriptId || this._entityId || Date.now(); + this._saving = true; try { await this.hass!.callApi( "POST", "config/script/config/" + id, this._config ); + + if (this._entityRegistryUpdate !== undefined) { + let entityId = id.toString().startsWith("script.") + ? id.toString() + : `script.${id}`; + + // wait for new script to appear in entity registry + if (!this.scriptId) { + const script = await new Promise((resolve) => { + this._entityRegCreated = resolve; + }); + entityId = script.entity_id; + } + + await updateEntityRegistryEntry(this.hass, entityId, { + categories: { + script: this._entityRegistryUpdate.category || null, + }, + labels: this._entityRegistryUpdate.labels || [], + }); + } + + this._dirty = false; + + if (!this.scriptId) { + navigate(`/config/script/edit/${id}`, { replace: true }); + } } catch (errors: any) { this._errors = errors.body.message || errors.error || errors.body; showToast(this, { message: errors.body.message || errors.error || errors.body, }); throw errors; - } - - this._dirty = false; - - if (!this.scriptId) { - navigate(`/config/script/edit/${id}`, { replace: true }); + } finally { + this._saving = false; } } diff --git a/src/translations/en.json b/src/translations/en.json index 18c3b1013ecf..1138144dc2fb 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3708,6 +3708,12 @@ "label": "Unknown" } } + }, + "dialog": { + "add_description": "Add description", + "add_icon": "Add icon", + "add_category": "Add category", + "add_labels": "Add labels" } }, "trace": {