From 37d2c6844a28f885f82632c0e5d31f34d1729332 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 20 Nov 2024 06:12:23 -0800 Subject: [PATCH] Update Scene Editor (#22847) * New scene editor design * bugfixes * reorder overflow menu, change label * category support --- src/panels/config/scene/ha-scene-dashboard.ts | 14 +- src/panels/config/scene/ha-scene-editor.ts | 774 ++++++++++++------ src/translations/en.json | 7 + 3 files changed, 556 insertions(+), 239 deletions(-) diff --git a/src/panels/config/scene/ha-scene-dashboard.ts b/src/panels/config/scene/ha-scene-dashboard.ts index 5ce3e327f0..442a834107 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( diff --git a/src/panels/config/scene/ha-scene-editor.ts b/src/panels/config/scene/ha-scene-editor.ts index 6ea0708641..f574311558 100644 --- a/src/panels/config/scene/ha-scene-editor.ts +++ b/src/panels/config/scene/ha-scene-editor.ts @@ -1,10 +1,15 @@ 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, + mdiTag, } from "@mdi/js"; import type { HassEvent } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues } from "lit"; @@ -18,10 +23,14 @@ 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 { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; 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 +108,8 @@ export class HaSceneEditor extends SubscribeMixin( @state() private _errors?: string; + @state() private _yamlErrors?: string; + @state() private _config?: SceneConfig; @state() private _entities: string[] = []; @@ -115,6 +126,8 @@ export class HaSceneEditor extends SubscribeMixin( @state() private _scene?: SceneEntity; + @state() private _mode: "review" | "live" | "yaml" = "review"; + private _storedStates: SceneEntities = {}; private _unsubscribeEvents?: () => void; @@ -139,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[], @@ -204,13 +227,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.scene.picker.${this._getCategory(this._entityRegistryEntries, this._scene?.entity_id) ? "edit_category" : "assign_category"}` + )} + + + +
  • + + + ${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,208 +339,7 @@ export class HaSceneEditor extends SubscribeMixin( ${this._errors ? html`
    ${this._errors}
    ` : ""} -
    - ${this._config - ? html` -
    - -
    - - - - - -
    -
    -
    - - -
    - ${this.hass.localize( - "ui.panel.config.scene.editor.devices.header" - )} -
    -
    - ${this.hass.localize( - "ui.panel.config.scene.editor.devices.introduction" - )} -
    - - ${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.showAdvanced - ? html` - -
    - ${this.hass.localize( - "ui.panel.config.scene.editor.entities.header" - )} -
    -
    - ${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)} -
    - -
    -
    - `; - })} -
    -
    - ` - : ""} - - -
    - -
    -
    -
    - ` - : ""} - ` - : ""} -
    + ${this._mode === "yaml" ? this.renderYamlMode() : this.renderUiMode()} `; + } + + 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.live_preview_detail" + )} + ${this.hass.localize( + "ui.panel.config.scene.editor.back_to_review_mode" + )} + ` + : html` + ${this.hass.localize( + "ui.panel.config.scene.editor.review_mode_detail" + )} + ${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.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} +
    + + ${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 { super.updated(changedProps); @@ -522,47 +667,151 @@ 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.sceneId && - this._config && - !this._scene - ) { - this._setScene(); - } 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: + this._editCategory(this._scene!); + break; + case 4: + if (this._mode === "yaml") { + this._initEntities(this._config!); + this._exitYamlMode(); + } + break; + case 5: + if (this._mode !== "yaml") { + this._enterYamlMode(); + } + break; + case 6: + this._duplicate(); + break; + case 7: this._deleteTapped(); break; } } - private async _setScene() { - const scene = this.scenes.find( - (entity: SceneEntity) => entity.attributes.id === this.sceneId - ); - if (!scene) { + 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"; + await this._setScene(); + this._subscribeEvents(); + } + + 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() { + if (!this._scene) { return; } - this._scene = scene; 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), @@ -601,7 +850,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 +860,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 +915,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 +956,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 +1019,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 +1043,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 +1130,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 = @@ -943,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, @@ -982,6 +1281,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 +1309,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 8e86302514..0fc6b12128 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3857,6 +3857,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?", @@ -3881,6 +3882,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": "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.", "load_error_unknown": "Error loading scene ({err_no}).",