From 78937910ff9b56956b1f59d6be1fd8045d435684 Mon Sep 17 00:00:00 2001 From: karwosts Date: Fri, 15 Nov 2024 16:52:01 -0800 Subject: [PATCH 1/4] New scene editor design --- src/panels/config/scene/ha-scene-editor.ts | 702 ++++++++++++++------- src/translations/en.json | 7 + 2 files changed, 492 insertions(+), 217 deletions(-) diff --git a/src/panels/config/scene/ha-scene-editor.ts b/src/panels/config/scene/ha-scene-editor.ts index 6ea07086418e..df3db64e441d 100644 --- a/src/panels/config/scene/ha-scene-editor.ts +++ b/src/panels/config/scene/ha-scene-editor.ts @@ -1,10 +1,14 @@ import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; import "@material/mwc-list/mwc-list"; import { + mdiCheck, + mdiCog, mdiContentDuplicate, mdiContentSave, mdiDelete, mdiDotsVertical, + mdiInformationOutline, + mdiPlay, } from "@mdi/js"; import type { HassEvent } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues } from "lit"; @@ -18,10 +22,13 @@ import { computeStateName } from "../../../common/entity/compute_state_name"; import { navigate } from "../../../common/navigate"; import { computeRTL } from "../../../common/util/compute_rtl"; import { afterNextRender } from "../../../common/util/render-status"; +import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog"; import "../../../components/device/ha-device-picker"; import "../../../components/entity/ha-entities-picker"; import "../../../components/ha-area-picker"; import "../../../components/ha-button-menu"; +import "../../../components/ha-alert"; +import "../../../components/ha-button"; import "../../../components/ha-card"; import "../../../components/ha-fab"; import "../../../components/ha-icon-button"; @@ -99,6 +106,8 @@ export class HaSceneEditor extends SubscribeMixin( @state() private _errors?: string; + @state() private _yamlErrors?: string; + @state() private _config?: SceneConfig; @state() private _entities: string[] = []; @@ -115,6 +124,8 @@ export class HaSceneEditor extends SubscribeMixin( @state() private _scene?: SceneEntity; + @state() private _mode: "review" | "live" | "yaml" = "review"; + private _storedStates: SceneEntities = {}; private _unsubscribeEvents?: () => void; @@ -204,13 +215,6 @@ export class HaSceneEditor extends SubscribeMixin( if (!this.hass) { return nothing; } - const { devices, entities } = this._getEntitiesDevices( - this._entities, - this._devices, - this._deviceEntityLookup, - this._deviceRegistryEntries - ); - return html` + + ${this.hass.localize("ui.panel.config.scene.picker.apply")} + + + + ${this.hass.localize("ui.panel.config.scene.picker.show_info")} + + + + ${this.hass.localize( + "ui.panel.config.automation.picker.show_settings" + )} + + + +
  • + + + ${this.hass.localize("ui.panel.config.automation.editor.edit_ui")} + ${this._mode !== "yaml" + ? html`` + : nothing} + + + ${this.hass.localize("ui.panel.config.automation.editor.edit_yaml")} + ${this._mode === "yaml" + ? html`` + : nothing} + + +
  • + ${this.hass.localize( "ui.panel.config.scene.picker.duplicate_scene" @@ -257,220 +316,283 @@ export class HaSceneEditor extends SubscribeMixin( ${this._errors ? html`
    ${this._errors}
    ` : ""} -
    - ${this._config - ? html` -
    - -
    - - - - - -
    -
    -
    + + + + `; + } - -
    + private renderYamlMode() { + return html` `; + } + + private renderUiMode() { + const { devices, entities } = this._getEntitiesDevices( + this._entities, + this._devices, + this._deviceEntityLookup, + this._deviceRegistryEntries + ); + return html`
    + ${this._config + ? html` +
    + ${this._mode === "live" + ? html` ${this.hass.localize( - "ui.panel.config.scene.editor.devices.header" + "ui.panel.config.scene.editor.live_preview_detail" )} -
    -
    + ${this.hass.localize( + "ui.panel.config.scene.editor.back_to_review_mode" + )} + ` + : html` ${this.hass.localize( - "ui.panel.config.scene.editor.devices.introduction" + "ui.panel.config.scene.editor.review_mode_detail" )} -
    - - ${devices.map( - (device) => html` - -

    - ${device.name} - -

    - - ${device.entities.map((entityId) => { - const entityStateObj = this.hass.states[entityId]; - if (!entityStateObj) { - return nothing; - } - return html` - - - ${computeStateName(entityStateObj)} - - `; - })} - -
    - ` - )} - - ${this.hass.localize( + "ui.panel.config.scene.editor.live_preview" + )} + `} + +
    + + + + -
    - +
    + +
    + + +
    + ${this.hass.localize( + "ui.panel.config.scene.editor.devices.header" + )} +
    + ${this._mode === "live" + ? html`
    + ${this.hass.localize( + "ui.panel.config.scene.editor.devices.introduction" + )} +
    ` + : nothing} + ${devices.map( + (device) => html` + +

    + ${device.name} + -

    + .device=${device.id} + @click=${this._deleteDevice} + > + + + ${device.entities.map((entityId) => { + const entityStateObj = this.hass.states[entityId]; + if (!entityStateObj) { + return nothing; + } + return html` + + ${this._mode === "live" + ? html` + + ` + : nothing} + ${computeStateName(entityStateObj)} + + `; + })} + - - - ${this.showAdvanced - ? html` - -
    - ${this.hass.localize( - "ui.panel.config.scene.editor.entities.header" + ` + )} + ${this._mode === "live" + ? html` + +
    + -
    - ${this.hass.localize( - "ui.panel.config.scene.editor.entities.introduction" - )} -
    - ${entities.length - ? html` - - - ${entities.map((entityId) => { - const entityStateObj = - this.hass.states[entityId]; - if (!entityStateObj) { - return nothing; - } - return html` - - - ${computeStateName(entityStateObj)} -
    - -
    -
    - `; - })} -
    -
    - ` - : ""} - + >
    +
    +
    + ` + : nothing} + + + ${this.showAdvanced + ? html` +
    + ${this.hass.localize( + "ui.panel.config.scene.editor.entities.header" + )} +
    + ${this._mode === "live" + ? html`
    + ${this.hass.localize( + "ui.panel.config.scene.editor.entities.introduction" + )} +
    ` + : nothing} + ${entities.length + ? html` -
    - -
    + + ${entities.map((entityId) => { + const entityStateObj = this.hass.states[entityId]; + if (!entityStateObj) { + return nothing; + } + return html` + + ${this._mode === "live" + ? html` ` + : nothing} + ${computeStateName(entityStateObj)} +
    + +
    +
    + `; + })} +
    -
    - ` - : ""} - ` - : ""} -
    - - - - - `; + ` + : ""} + ${this._mode === "live" + ? html` +
    + +
    +
    ` + : nothing} +
    ` + : nothing} + ` + : nothing} +
    `; } protected updated(changedProps: PropertyValues): void { @@ -531,6 +653,7 @@ export class HaSceneEditor extends SubscribeMixin( } if ( changedProps.has("scenes") && + this._mode === "live" && this.sceneId && this._config && !this._scene @@ -540,27 +663,132 @@ export class HaSceneEditor extends SubscribeMixin( if (this._scenesSet && changedProps.has("scenes")) { this._scenesSet(); } + + if (changedProps.has("hass")) { + if (this._scene) { + if (this.hass.states[this._scene.entity_id] !== this._scene) { + this._scene = this.hass.states[this._scene.entity_id]; + } + } else if (this.sceneId) { + this._scene = Object.values(this.hass.states).find( + (stateObj) => + stateObj.entity_id.startsWith("scene") && + stateObj.attributes?.id === this.sceneId + ); + } + } } private async _handleMenuAction(ev: CustomEvent) { switch (ev.detail.index) { case 0: - this._duplicate(); + activateScene(this.hass, this._scene!.entity_id); break; case 1: + fireEvent(this, "hass-more-info", { entityId: this._scene!.entity_id }); + break; + case 2: + showMoreInfoDialog(this, { + entityId: this._scene!.entity_id, + view: "settings", + }); + break; + case 3: + if (this._mode === "yaml") { + this._initEntities(this._config!); + this._exitYamlMode(); + } + break; + case 4: + if (this._mode !== "yaml") { + this._enterYamlMode(); + } + break; + case 5: + this._duplicate(); + break; + case 6: this._deleteTapped(); break; } } + private async _exitYamlMode() { + if (this._yamlErrors) { + const result = await showConfirmationDialog(this, { + text: html`${this.hass.localize( + "ui.panel.config.automation.editor.switch_ui_yaml_error" + )}

    ${this._yamlErrors}`, + confirmText: this.hass!.localize("ui.common.continue"), + destructive: true, + dismissText: this.hass!.localize("ui.common.cancel"), + }); + if (!result) { + return; + } + } + this._yamlErrors = undefined; + this._mode = "review"; + } + + private _enterYamlMode() { + if (this._mode === "live") { + this._generateConfigFromLive(); + if (this._unsubscribeEvents) { + this._unsubscribeEvents(); + this._unsubscribeEvents = undefined; + } + applyScene(this.hass, this._storedStates); + } + this._mode = "yaml"; + } + + private async _enterLiveMode() { + if (this._dirty) { + const result = await showConfirmationDialog(this, { + text: this.hass.localize( + "ui.panel.config.scene.editor.enter_live_mode_unsaved" + ), + confirmText: this.hass!.localize("ui.common.continue"), + destructive: true, + dismissText: this.hass!.localize("ui.common.cancel"), + }); + if (!result) { + return; + } + } + + this._entities.forEach((entity) => this._storeState(entity)); + this._mode = "live"; + this._setScene(); + } + + private _exitLiveMode() { + this._generateConfigFromLive(); + if (this._unsubscribeEvents) { + this._unsubscribeEvents(); + this._unsubscribeEvents = undefined; + } + applyScene(this.hass, this._storedStates); + this._mode = "review"; + } + + private _yamlChanged(ev: CustomEvent) { + ev.stopPropagation(); + this._dirty = true; + if (!ev.detail.isValid) { + this._yamlErrors = ev.detail.errorMsg; + return; + } + this._yamlErrors = undefined; + this._config = ev.detail.value; + this._errors = undefined; + } + private async _setScene() { - const scene = this.scenes.find( - (entity: SceneEntity) => entity.attributes.id === this.sceneId - ); - if (!scene) { + if (!this._scene) { return; } - this._scene = scene; const { context } = await activateScene(this.hass, this._scene.entity_id); this._activateContextId = context.id; this._unsubscribeEvents = @@ -601,7 +829,9 @@ export class HaSceneEditor extends SubscribeMixin( this._initEntities(config); - this._setScene(); + this._scene = this.scenes.find( + (entity: SceneEntity) => entity.attributes.id === this.sceneId + ); this._dirty = false; this._config = config; @@ -609,7 +839,6 @@ export class HaSceneEditor extends SubscribeMixin( private _initEntities(config: SceneConfig) { this._entities = Object.keys(config.entities); - this._entities.forEach((entity) => this._storeState(entity)); this._single_entities = []; const filteredEntityReg = this._entityRegistryEntries.filter((entityReg) => @@ -665,6 +894,12 @@ export class HaSceneEditor extends SubscribeMixin( this._single_entities = this._single_entities.filter( (entityId) => entityId !== deleteEntityId ); + if (this._config!.entities) { + delete this._config!.entities[deleteEntityId]; + } + if (this._config!.metadata) { + delete this._config!.metadata[deleteEntityId]; + } this._dirty = true; } @@ -700,6 +935,11 @@ export class HaSceneEditor extends SubscribeMixin( this._entities = this._entities.filter( (entityId) => !deviceEntities.includes(entityId) ); + if (this._config!.entities) { + deviceEntities.forEach((entityId) => { + delete this._config!.entities[entityId]; + }); + } this._dirty = true; } @@ -758,7 +998,9 @@ export class HaSceneEditor extends SubscribeMixin( }; private _goBack(): void { - applyScene(this.hass, this._storedStates); + if (this._mode === "live") { + applyScene(this.hass, this._storedStates); + } afterNextRender(() => history.back()); } @@ -780,7 +1022,9 @@ export class HaSceneEditor extends SubscribeMixin( private async _delete(): Promise { await deleteScene(this.hass, this.sceneId!); - applyScene(this.hass, this._storedStates); + if (this._mode === "live") { + applyScene(this.hass, this._storedStates); + } history.back(); } @@ -865,16 +1109,29 @@ export class HaSceneEditor extends SubscribeMixin( return { ...stateObj.attributes, state: stateObj.state }; } - private async _saveScene(): Promise { - const id = !this.sceneId ? "" + Date.now() : this.sceneId!; + private _generateConfigFromLive() { this._config = { ...this._config!, entities: this._calculateStates(), metadata: this._calculateMetaData(), }; + } + + private async _saveScene(): Promise { + if (this._yamlErrors) { + showToast(this, { + message: this._yamlErrors, + }); + return; + } + + const id = !this.sceneId ? "" + Date.now() : this.sceneId!; + if (this._mode === "live") { + this._generateConfigFromLive(); + } try { this._saving = true; - await saveScene(this.hass, id, this._config); + await saveScene(this.hass, id, this._config!); if (this._updatedAreaId !== undefined) { let scene = @@ -982,6 +1239,14 @@ export class HaSceneEditor extends SubscribeMixin( bottom: calc(-80px - env(safe-area-inset-bottom)); transition: bottom 0.3s; } + ha-alert { + display: block; + margin-bottom: 24px; + } + ha-button { + white-space: nowrap; + --mdc-theme-primary: var(--primary-color); + } ha-fab.dirty { bottom: 0; } @@ -1002,6 +1267,9 @@ export class HaSceneEditor extends SubscribeMixin( justify-content: center; align-items: center; } + li[role="separator"] { + border-bottom-color: var(--divider-color); + } `, ]; } diff --git a/src/translations/en.json b/src/translations/en.json index cda8a005c5d9..4eddb839a4cb 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3848,6 +3848,7 @@ "edit_scene": "Edit scene", "show_info": "[%key:ui::panel::config::automation::editor::show_info%]", "activate": "Activate", + "apply": "Apply", "delete_scene": "Delete scene", "delete": "[%key:ui::common::delete%]", "delete_confirm_title": "Delete scene?", @@ -3872,6 +3873,12 @@ "search": "Search {number} scenes" }, "editor": { + "review_mode": "Review Mode", + "review_mode_detail": "You can adjust the scene's details and remove devices or entities. To fully edit, switch to Live Preview, which will apply the scene.", + "live_preview": "Live Preview", + "live_preview_detail": "In Live Preview, all changes to this scene are applied in real-time to your devices and entities.", + "enter_live_mode_unsaved": "If you have unsaved changes for this scene, continuing to live preview will apply the saved scene, which may erase your unsaved changes. Consider if you would like to save the scene first before activating it.", + "back_to_review_mode": "Back to review mode", "default_name": "New scene", "load_error_not_editable": "Only scenes in scenes.yaml are editable.", "load_error_unknown": "Error loading scene ({err_no}).", From edd1cc01c1fa73c60ddd9222cf2fd37121fcc76c Mon Sep 17 00:00:00 2001 From: karwosts Date: Sat, 16 Nov 2024 06:45:47 -0800 Subject: [PATCH 2/4] bugfixes --- src/panels/config/scene/ha-scene-editor.ts | 17 ++++++----------- src/translations/en.json | 2 +- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/panels/config/scene/ha-scene-editor.ts b/src/panels/config/scene/ha-scene-editor.ts index df3db64e441d..f6b9b94cb7c3 100644 --- a/src/panels/config/scene/ha-scene-editor.ts +++ b/src/panels/config/scene/ha-scene-editor.ts @@ -644,22 +644,13 @@ export class HaSceneEditor extends SubscribeMixin( this._deviceEntityLookup[entity.device_id].push(entity.entity_id); if ( this._entities.includes(entity.entity_id) && - !this._single_entities.includes(entity.device_id) && + !this._single_entities.includes(entity.entity_id) && !this._devices.includes(entity.device_id) ) { this._devices = [...this._devices, entity.device_id]; } } } - if ( - changedProps.has("scenes") && - this._mode === "live" && - this.sceneId && - this._config && - !this._scene - ) { - this._setScene(); - } if (this._scenesSet && changedProps.has("scenes")) { this._scenesSet(); } @@ -760,7 +751,8 @@ export class HaSceneEditor extends SubscribeMixin( this._entities.forEach((entity) => this._storeState(entity)); this._mode = "live"; - this._setScene(); + await this._setScene(); + this._subscribeEvents(); } private _exitLiveMode() { @@ -791,6 +783,9 @@ export class HaSceneEditor extends SubscribeMixin( } const { context } = await activateScene(this.hass, this._scene.entity_id); this._activateContextId = context.id; + } + + private async _subscribeEvents() { this._unsubscribeEvents = await this.hass!.connection.subscribeEvents( (event) => this._stateChanged(event), diff --git a/src/translations/en.json b/src/translations/en.json index 4eddb839a4cb..75c2116713dc 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3877,7 +3877,7 @@ "review_mode_detail": "You can adjust the scene's details and remove devices or entities. To fully edit, switch to Live Preview, which will apply the scene.", "live_preview": "Live Preview", "live_preview_detail": "In Live Preview, all changes to this scene are applied in real-time to your devices and entities.", - "enter_live_mode_unsaved": "If you have unsaved changes for this scene, continuing to live preview will apply the saved scene, which may erase your unsaved changes. Consider if you would like to save the scene first before activating it.", + "enter_live_mode_unsaved": "You have unsaved changes to this scene. Continuing to live preview will apply the saved scene, which may overwrite your unsaved changes. Consider if you would like to save the scene first before activating it.", "back_to_review_mode": "Back to review mode", "default_name": "New scene", "load_error_not_editable": "Only scenes in scenes.yaml are editable.", From 081a0a86db5f3bd2c98460dc64105fac2ace712b Mon Sep 17 00:00:00 2001 From: karwosts Date: Sun, 17 Nov 2024 10:52:55 -0800 Subject: [PATCH 3/4] reorder overflow menu, change label --- src/panels/config/scene/ha-scene-dashboard.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/panels/config/scene/ha-scene-dashboard.ts b/src/panels/config/scene/ha-scene-dashboard.ts index 5ce3e327f0f5..442a83410725 100644 --- a/src/panels/config/scene/ha-scene-dashboard.ts +++ b/src/panels/config/scene/ha-scene-dashboard.ts @@ -338,6 +338,13 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { .hass=${this.hass} narrow .items=${[ + { + path: mdiPlay, + label: this.hass.localize( + "ui.panel.config.scene.picker.apply" + ), + action: () => this._activateScene(scene), + }, { path: mdiInformationOutline, label: this.hass.localize( @@ -352,13 +359,6 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { ), action: () => this._openSettings(scene), }, - { - path: mdiPlay, - label: this.hass.localize( - "ui.panel.config.scene.picker.activate" - ), - action: () => this._activateScene(scene), - }, { path: mdiTag, label: this.hass.localize( From c4e7b23747cc5b2c9599d37d5ffcadff0d75f292 Mon Sep 17 00:00:00 2001 From: karwosts Date: Sun, 17 Nov 2024 13:24:02 -0800 Subject: [PATCH 4/4] category support --- src/panels/config/scene/ha-scene-editor.ts | 53 ++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/src/panels/config/scene/ha-scene-editor.ts b/src/panels/config/scene/ha-scene-editor.ts index f6b9b94cb7c3..f5743115581d 100644 --- a/src/panels/config/scene/ha-scene-editor.ts +++ b/src/panels/config/scene/ha-scene-editor.ts @@ -9,6 +9,7 @@ import { mdiDotsVertical, mdiInformationOutline, mdiPlay, + mdiTag, } from "@mdi/js"; import type { HassEvent } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues } from "lit"; @@ -23,6 +24,7 @@ import { navigate } from "../../../common/navigate"; import { computeRTL } from "../../../common/util/compute_rtl"; import { afterNextRender } from "../../../common/util/render-status"; import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog"; +import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; import "../../../components/device/ha-device-picker"; import "../../../components/entity/ha-entities-picker"; import "../../../components/ha-area-picker"; @@ -150,6 +152,16 @@ export class HaSceneEditor extends SubscribeMixin( } ); + private _getCategory = memoizeOne( + (entries: EntityRegistryEntry[], entity_id: string | undefined) => { + if (!entity_id) { + return undefined; + } + const entry = entries.find((ent) => ent.entity_id === entity_id); + return entry?.categories?.scene; + } + ); + private _getEntitiesDevices = memoizeOne( ( entities: string[], @@ -266,6 +278,17 @@ export class HaSceneEditor extends SubscribeMixin( > + + ${this.hass.localize( + `ui.panel.config.scene.picker.${this._getCategory(this._entityRegistryEntries, this._scene?.entity_id) ? "edit_category" : "assign_category"}` + )} + + +
  • @@ -685,20 +708,23 @@ export class HaSceneEditor extends SubscribeMixin( }); break; case 3: + this._editCategory(this._scene!); + break; + case 4: if (this._mode === "yaml") { this._initEntities(this._config!); this._exitYamlMode(); } break; - case 4: + case 5: if (this._mode !== "yaml") { this._enterYamlMode(); } break; - case 5: + case 6: this._duplicate(); break; - case 6: + case 7: this._deleteTapped(); break; } @@ -1195,6 +1221,27 @@ export class HaSceneEditor extends SubscribeMixin( : undefined; } + private _editCategory(scene: any) { + const entityReg = this._entityRegistryEntries.find( + (reg) => reg.entity_id === scene.entity_id + ); + if (!entityReg) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.scene.picker.no_category_support" + ), + text: this.hass.localize( + "ui.panel.config.scene.picker.no_category_entity_reg" + ), + }); + return; + } + showAssignCategoryDialog(this, { + scope: "scene", + entityReg, + }); + } + static get styles(): CSSResultGroup { return [ haStyle,