diff --git a/src/common/util/array-move.ts b/src/common/util/array-move.ts deleted file mode 100644 index 33ce6bb8e016..000000000000 --- a/src/common/util/array-move.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { ItemPath } from "../../types"; - -function findNestedItem( - obj: any, - path: ItemPath, - createNonExistingPath?: boolean -): any { - return path.reduce((ac, p, index, array) => { - if (ac === undefined) return undefined; - if (!ac[p] && createNonExistingPath) { - const nextP = array[index + 1]; - // Create object or array depending on next path - if (nextP === undefined || typeof nextP === "number") { - ac[p] = []; - } else { - ac[p] = {}; - } - } - return ac[p]; - }, obj); -} - -function updateNestedItem(obj: any, path: ItemPath): any { - const lastKey = path.pop()!; - const parent = findNestedItem(obj, path); - parent[lastKey] = Array.isArray(parent[lastKey]) - ? [...parent[lastKey]] - : [parent[lastKey]]; - return obj; -} - -export function nestedArrayMove( - obj: A, - oldIndex: number, - newIndex: number, - oldPath?: ItemPath, - newPath?: ItemPath -): A { - let newObj = (Array.isArray(obj) ? [...obj] : { ...obj }) as A; - - if (oldPath) { - newObj = updateNestedItem(newObj, [...oldPath]); - } - if (newPath) { - newObj = updateNestedItem(newObj, [...newPath]); - } - - const from = oldPath ? findNestedItem(newObj, oldPath) : newObj; - const to = newPath ? findNestedItem(newObj, newPath, true) : newObj; - - const item = from.splice(oldIndex, 1)[0]; - to.splice(newIndex, 0, item); - - return newObj; -} - -export function arrayMove( - array: T[], - oldIndex: number, - newIndex: number -): T[] { - const newArray = [...array]; - const [item] = newArray.splice(oldIndex, 1); - newArray.splice(newIndex, 0, item); - return newArray; -} diff --git a/src/components/ha-selector/ha-selector-action.ts b/src/components/ha-selector/ha-selector-action.ts index 23d93a2a2999..0741321c3d6c 100644 --- a/src/components/ha-selector/ha-selector-action.ts +++ b/src/components/ha-selector/ha-selector-action.ts @@ -32,7 +32,6 @@ export class HaActionSelector extends LitElement { .disabled=${this.disabled} .actions=${this._actions(this.value)} .hass=${this.hass} - .path=${this.selector.action?.path} > `; } diff --git a/src/components/ha-selector/ha-selector-condition.ts b/src/components/ha-selector/ha-selector-condition.ts index 721cf265ade0..4f30357c367f 100644 --- a/src/components/ha-selector/ha-selector-condition.ts +++ b/src/components/ha-selector/ha-selector-condition.ts @@ -24,7 +24,6 @@ export class HaConditionSelector extends LitElement { .disabled=${this.disabled} .conditions=${this.value || []} .hass=${this.hass} - .path=${this.selector.condition?.path} > `; } diff --git a/src/components/ha-selector/ha-selector-trigger.ts b/src/components/ha-selector/ha-selector-trigger.ts index 0974a31702cd..501e85f8d1c8 100644 --- a/src/components/ha-selector/ha-selector-trigger.ts +++ b/src/components/ha-selector/ha-selector-trigger.ts @@ -32,7 +32,6 @@ export class HaTriggerSelector extends LitElement { .disabled=${this.disabled} .triggers=${this._triggers(this.value)} .hass=${this.hass} - .path=${this.selector.trigger?.path} > `; } diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 32f25e702422..a93b805118f8 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -19,7 +19,6 @@ import { fireEvent } from "../common/dom/fire_event"; import { computeDomain } from "../common/entity/compute_domain"; import { computeObjectId } from "../common/entity/compute_object_id"; import { supportsFeature } from "../common/entity/supports-feature"; -import { nestedArrayMove } from "../common/util/array-move"; import { fetchIntegrationManifest, IntegrationManifest, @@ -597,15 +596,6 @@ export class HaServiceControl extends LitElement { } const selector = dataField?.selector ?? { text: undefined }; - const type = Object.keys(selector)[0]; - const enhancedSelector = ["action", "condition", "trigger"].includes(type) - ? { - [type]: { - ...selector[type], - path: [dataField.key], - }, - } - : selector; const showOptional = showOptionalToggle(dataField); @@ -646,7 +636,7 @@ export class HaServiceControl extends LitElement { (!this._value?.data || this._value.data[dataField.key] === undefined))} .hass=${this.hass} - .selector=${enhancedSelector} + .selector=${selector} .key=${dataField.key} @value-changed=${this._serviceDataChanged} .value=${this._value?.data @@ -654,7 +644,6 @@ export class HaServiceControl extends LitElement { : undefined} .placeholder=${dataField.default} .localizeValue=${this._localizeValueCallback} - @item-moved=${this._itemMoved} > ` : ""; @@ -856,22 +845,6 @@ export class HaServiceControl extends LitElement { }); } - private _itemMoved(ev) { - ev.stopPropagation(); - const { oldIndex, newIndex, oldPath, newPath } = ev.detail; - - const data = this.value?.data ?? {}; - - const newData = nestedArrayMove(data, oldIndex, newIndex, oldPath, newPath); - - fireEvent(this, "value-changed", { - value: { - ...this.value, - data: newData, - }, - }); - } - private _dataChanged(ev: CustomEvent) { ev.stopPropagation(); if (!ev.detail.isValid) { diff --git a/src/components/ha-sortable.ts b/src/components/ha-sortable.ts index 8d784445ad70..5ddb3ee30eeb 100644 --- a/src/components/ha-sortable.ts +++ b/src/components/ha-sortable.ts @@ -4,15 +4,19 @@ import { customElement, property } from "lit/decorators"; import type { SortableEvent } from "sortablejs"; import { fireEvent } from "../common/dom/fire_event"; import type { SortableInstance } from "../resources/sortable"; -import { ItemPath } from "../types"; declare global { interface HASSDomEvents { "item-moved": { oldIndex: number; newIndex: number; - oldPath?: ItemPath; - newPath?: ItemPath; + }; + "item-added": { + index: number; + data: any; + }; + "item-removed": { + index: number; }; "drag-start": undefined; "drag-end": undefined; @@ -21,7 +25,7 @@ declare global { export type HaSortableOptions = Omit< SortableInstance.SortableOptions, - "onStart" | "onChoose" | "onEnd" + "onStart" | "onChoose" | "onEnd" | "onUpdate" | "onAdd" | "onRemove" >; @customElement("ha-sortable") @@ -31,9 +35,6 @@ export class HaSortable extends LitElement { @property({ type: Boolean }) public disabled = false; - @property({ type: Array }) - public path?: ItemPath; - @property({ type: Boolean, attribute: "no-style" }) public noStyle: boolean = false; @@ -138,6 +139,9 @@ export class HaSortable extends LitElement { onChoose: this._handleChoose, onStart: this._handleStart, onEnd: this._handleEnd, + onUpdate: this._handleUpdate, + onAdd: this._handleAdd, + onRemove: this._handleRemove, }; if (this.draggableSelector) { @@ -159,33 +163,31 @@ export class HaSortable extends LitElement { this._sortable = new Sortable(container, options); } - private _handleEnd = async (evt: SortableEvent) => { + private _handleUpdate = (evt) => { + fireEvent(this, "item-moved", { + newIndex: evt.newIndex, + oldIndex: evt.oldIndex, + }); + }; + + private _handleAdd = (evt) => { + fireEvent(this, "item-added", { + index: evt.newIndex, + data: evt.item.sortableData, + }); + }; + + private _handleRemove = (evt) => { + fireEvent(this, "item-removed", { index: evt.oldIndex }); + }; + + private _handleEnd = async (evt) => { fireEvent(this, "drag-end"); // put back in original location if (this.rollback && (evt.item as any).placeholder) { (evt.item as any).placeholder.replaceWith(evt.item); delete (evt.item as any).placeholder; } - - const oldIndex = evt.oldIndex; - const oldPath = (evt.from.parentElement as HaSortable).path; - const newIndex = evt.newIndex; - const newPath = (evt.to.parentElement as HaSortable).path; - - if ( - oldIndex === undefined || - newIndex === undefined || - (oldIndex === newIndex && oldPath?.join(".") === newPath?.join(".")) - ) { - return; - } - - fireEvent(this, "item-moved", { - oldIndex, - newIndex, - oldPath, - newPath, - }); }; private _handleStart = () => { diff --git a/src/components/trace/hat-trace-timeline.ts b/src/components/trace/hat-trace-timeline.ts index fafbff2fee6f..980bc247aabc 100644 --- a/src/components/trace/hat-trace-timeline.ts +++ b/src/components/trace/hat-trace-timeline.ts @@ -33,7 +33,7 @@ import { LabelRegistryEntry } from "../../data/label_registry"; import { LogbookEntry } from "../../data/logbook"; import { ChooseAction, - ChooseActionChoice, + Option, IfAction, ParallelAction, RepeatAction, @@ -413,7 +413,7 @@ class ActionRenderer { : undefined; const choiceConfig = this._getDataFromPath( `${this.keys[index]}/choose/${chooseTrace.result.choice}` - ) as ChooseActionChoice | undefined; + ) as Option | undefined; const choiceName = choiceConfig ? `${ choiceConfig.alias || diff --git a/src/data/script.ts b/src/data/script.ts index c2273d18d576..a9d8e74550ba 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -224,13 +224,14 @@ export interface ForEachRepeat extends BaseRepeat { for_each: string | any[]; } -export interface ChooseActionChoice extends BaseAction { +export interface Option { + alias?: string; conditions: string | Condition[]; sequence: Action | Action[]; } export interface ChooseAction extends BaseAction { - choose: ChooseActionChoice | ChooseActionChoice[] | null; + choose: Option | Option[] | null; default?: Action | Action[]; } diff --git a/src/data/selector.ts b/src/data/selector.ts index 6ed4517170e4..363d95e68a17 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -5,7 +5,7 @@ import { supportsFeature } from "../common/entity/supports-feature"; import type { CropOptions } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog"; import { isHelperDomain } from "../panels/config/helpers/const"; import { UiAction } from "../panels/lovelace/components/hui-action-editor"; -import { HomeAssistant, ItemPath } from "../types"; +import { HomeAssistant } from "../types"; import { DeviceRegistryEntry, getDeviceIntegrationLookup, @@ -68,9 +68,8 @@ export type Selector = | UiStateContentSelector; export interface ActionSelector { - action: { - path?: ItemPath; - } | null; + // eslint-disable-next-line @typescript-eslint/ban-types + action: {} | null; } export interface AddonSelector { @@ -121,9 +120,8 @@ export interface ColorTempSelector { } export interface ConditionSelector { - condition: { - path?: ItemPath; - } | null; + // eslint-disable-next-line @typescript-eslint/ban-types + condition: {} | null; } export interface ConversationAgentSelector { @@ -432,9 +430,8 @@ export interface TimeSelector { } export interface TriggerSelector { - trigger: { - path?: ItemPath; - } | null; + // eslint-disable-next-line @typescript-eslint/ban-types + trigger: {} | null; } export interface TTSSelector { diff --git a/src/panels/config/areas/ha-config-areas-dashboard.ts b/src/panels/config/areas/ha-config-areas-dashboard.ts index 5892abde3a3b..7aa5f47b0f80 100644 --- a/src/panels/config/areas/ha-config-areas-dashboard.ts +++ b/src/panels/config/areas/ha-config-areas-dashboard.ts @@ -9,12 +9,13 @@ import { import { CSSResultGroup, LitElement, + PropertyValues, TemplateResult, css, html, nothing, } from "lit"; -import { customElement, property } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import { formatListWithAnds } from "../../../common/string/format-list"; @@ -49,7 +50,7 @@ import { } from "./show-dialog-area-registry-detail"; import { showFloorRegistryDetailDialog } from "./show-dialog-floor-registry-detail"; -const UNASSIGNED_PATH = ["__unassigned__"]; +const UNASSIGNED_FLOOR = "__unassigned__"; const SORT_OPTIONS = { sort: false, delay: 500, delayOnTouchOnly: true }; @@ -63,9 +64,11 @@ export class HaConfigAreasDashboard extends LitElement { @property({ attribute: false }) public route!: Route; + @state() private _areas: AreaRegistryEntry[] = []; + private _processAreas = memoizeOne( ( - areas: HomeAssistant["areas"], + areas: AreaRegistryEntry[], devices: HomeAssistant["devices"], entities: HomeAssistant["entities"], floors: HomeAssistant["floors"] @@ -99,8 +102,8 @@ export class HaConfigAreasDashboard extends LitElement { }; }; - const floorAreaLookup = getFloorAreaLookup(Object.values(areas)); - const unassisgnedAreas = Object.values(areas).filter( + const floorAreaLookup = getFloorAreaLookup(areas); + const unassignedAreas = areas.filter( (area) => !area.floor_id || !floorAreaLookup[area.floor_id] ); return { @@ -108,11 +111,21 @@ export class HaConfigAreasDashboard extends LitElement { ...floor, areas: (floorAreaLookup[floor.floor_id] || []).map(processArea), })), - unassisgnedAreas: unassisgnedAreas.map(processArea), + unassignedAreas: unassignedAreas.map(processArea), }; } ); + protected willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + if (changedProperties.has("hass")) { + const oldHass = changedProperties.get("hass"); + if (this.hass.areas !== oldHass?.areas) { + this._areas = Object.values(this.hass.areas); + } + } + } + protected render(): TemplateResult { const areasAndFloors = !this.hass.areas || @@ -121,7 +134,7 @@ export class HaConfigAreasDashboard extends LitElement { !this.hass.floors ? undefined : this._processAreas( - this.hass.areas, + this._areas, this.hass.devices, this.hass.entities, this.hass.floors @@ -183,10 +196,10 @@ export class HaConfigAreasDashboard extends LitElement {
${floor.areas.map((area) => this._renderArea(area))} @@ -194,7 +207,7 @@ export class HaConfigAreasDashboard extends LitElement {
` )} - ${areasAndFloors?.unassisgnedAreas.length + ${areasAndFloors?.unassignedAreas.length ? html`

@@ -206,13 +219,13 @@ export class HaConfigAreasDashboard extends LitElement {
- ${areasAndFloors?.unassisgnedAreas.map((area) => + ${areasAndFloors?.unassignedAreas.map((area) => this._renderArea(area) )}
@@ -246,7 +259,10 @@ export class HaConfigAreasDashboard extends LitElement { } private _renderArea(area) { - return html`
+ return html`
floor.floor_id === ev.detail.oldPath[0] - ); - area = oldFloor!.areas[ev.detail.oldIndex]; - } + private async _areaAdded(ev) { + ev.stopPropagation(); + const { floor } = ev.currentTarget; + + const newFloorId = floor === UNASSIGNED_FLOOR ? null : floor; + + const { data: area } = ev.detail; + + this._areas = this._areas.map((a) => { + if (a.area_id === area.area_id) { + return { ...a, floor_id: newFloorId }; + } + return a; + }); await updateAreaRegistryEntry(this.hass, area.area_id, { - floor_id: - ev.detail.newPath === UNASSIGNED_PATH ? null : ev.detail.newPath[0], + floor_id: newFloorId, }); } diff --git a/src/panels/config/automation/action/ha-automation-action-row.ts b/src/panels/config/automation/action/ha-automation-action-row.ts index ce4813b29fbb..0cb27a2a0b67 100644 --- a/src/panels/config/automation/action/ha-automation-action-row.ts +++ b/src/panels/config/automation/action/ha-automation-action-row.ts @@ -65,7 +65,7 @@ import { showPromptDialog, } from "../../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../../resources/styles"; -import type { HomeAssistant, ItemPath } from "../../../../types"; +import type { HomeAssistant } from "../../../../types"; import { showToast } from "../../../../util/toast"; import "./types/ha-automation-action-activate_scene"; import "./types/ha-automation-action-choose"; @@ -137,8 +137,6 @@ export default class HaAutomationActionRow extends LitElement { @property({ type: Boolean }) public disabled = false; - @property({ type: Array }) public path?: ItemPath; - @property({ type: Boolean }) public first?: boolean; @property({ type: Boolean }) public last?: boolean; @@ -432,7 +430,6 @@ export default class HaAutomationActionRow extends LitElement { action: this.action, narrow: this.narrow, disabled: this.disabled, - path: this.path, })}
`} diff --git a/src/panels/config/automation/action/ha-automation-action.ts b/src/panels/config/automation/action/ha-automation-action.ts index 301dc97411eb..a529bc58018c 100644 --- a/src/panels/config/automation/action/ha-automation-action.ts +++ b/src/panels/config/automation/action/ha-automation-action.ts @@ -13,14 +13,14 @@ import { repeat } from "lit/directives/repeat"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; import { listenMediaQuery } from "../../../../common/dom/media_query"; -import { nestedArrayMove } from "../../../../common/util/array-move"; +import { nextRender } from "../../../../common/util/render-status"; import "../../../../components/ha-button"; import "../../../../components/ha-sortable"; import "../../../../components/ha-svg-icon"; import { getService, isService } from "../../../../data/action"; import type { AutomationClipboard } from "../../../../data/automation"; import { Action } from "../../../../data/script"; -import { HomeAssistant, ItemPath } from "../../../../types"; +import { HomeAssistant } from "../../../../types"; import { PASTE_VALUE, showAddAutomationElementDialog, @@ -36,8 +36,6 @@ export default class HaAutomationAction extends LitElement { @property({ type: Boolean }) public disabled = false; - @property({ type: Array }) public path?: ItemPath; - @property({ attribute: false }) public actions!: Action[]; @state() private _showReorder: boolean = false; @@ -69,20 +67,17 @@ export default class HaAutomationAction extends LitElement { this._unsubMql = undefined; } - private get nested() { - return this.path !== undefined; - } - protected render() { return html`
${repeat( @@ -90,7 +85,7 @@ export default class HaAutomationAction extends LitElement { (action) => this._getKey(action), (action, idx) => html` { + ev.stopPropagation(); + const { index, data } = ev.detail; + const actions = [ + ...this.actions.slice(0, index), + data, + ...this.actions.slice(index), + ]; + // Add action locally to avoid UI jump + this.actions = actions; + await nextRender(); + fireEvent(this, "value-changed", { value: this.actions }); + } + + private async _actionRemoved(ev: CustomEvent): Promise { + ev.stopPropagation(); + const { index } = ev.detail; + const action = this.actions[index]; + // Remove action locally to avoid UI jump + this.actions = this.actions.filter((a) => a !== action); + await nextRender(); + // Ensure action is removed even after update + const actions = this.actions.filter((a) => a !== action); + fireEvent(this, "value-changed", { value: actions }); } private _actionChanged(ev: CustomEvent) { diff --git a/src/panels/config/automation/action/types/ha-automation-action-choose.ts b/src/panels/config/automation/action/types/ha-automation-action-choose.ts index 1da1e87d464a..42a4c0e19987 100644 --- a/src/panels/config/automation/action/types/ha-automation-action-choose.ts +++ b/src/panels/config/automation/action/types/ha-automation-action-choose.ts @@ -1,299 +1,40 @@ -import { consume } from "@lit-labs/context"; -import type { ActionDetail } from "@material/mwc-list"; -import { - mdiArrowDown, - mdiArrowUp, - mdiContentDuplicate, - mdiDelete, - mdiDotsVertical, - mdiDrag, - mdiPlus, - mdiRenameBox, -} from "@mdi/js"; -import deepClone from "deep-clone-simple"; -import { - CSSResultGroup, - LitElement, - PropertyValues, - css, - html, - nothing, -} from "lit"; +import { CSSResultGroup, LitElement, css, html } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { repeat } from "lit/directives/repeat"; import { ensureArray } from "../../../../../common/array/ensure-array"; import { fireEvent } from "../../../../../common/dom/fire_event"; -import { stopPropagation } from "../../../../../common/dom/stop_propagation"; -import { listenMediaQuery } from "../../../../../common/dom/media_query"; -import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter"; import "../../../../../components/ha-button"; -import "../../../../../components/ha-button-menu"; -import "../../../../../components/ha-icon-button"; -import "../../../../../components/ha-sortable"; -import { Condition } from "../../../../../data/automation"; -import { describeCondition } from "../../../../../data/automation_i18n"; -import { fullEntitiesContext } from "../../../../../data/context"; -import { EntityRegistryEntry } from "../../../../../data/entity_registry"; -import { - Action, - ChooseAction, - ChooseActionChoice, -} from "../../../../../data/script"; -import { - showConfirmationDialog, - showPromptDialog, -} from "../../../../../dialogs/generic/show-dialog-box"; +import { Action, ChooseAction, Option } from "../../../../../data/script"; import { haStyle } from "../../../../../resources/styles"; -import { HomeAssistant, ItemPath } from "../../../../../types"; +import { HomeAssistant } from "../../../../../types"; +import "../../option/ha-automation-option"; import { ActionElement } from "../ha-automation-action-row"; -const preventDefault = (ev) => ev.preventDefault(); - @customElement("ha-automation-action-choose") export class HaChooseAction extends LitElement implements ActionElement { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean }) public disabled = false; - @property({ attribute: false }) public path?: ItemPath; - @property({ attribute: false }) public action!: ChooseAction; @state() private _showDefault = false; - @state() private _expandedStates: boolean[] = []; - - @state() - @consume({ context: fullEntitiesContext, subscribe: true }) - _entityReg!: EntityRegistryEntry[]; - - @state() private _showReorder: boolean = false; - - private _expandLast = false; - - private _unsubMql?: () => void; - - public connectedCallback() { - super.connectedCallback(); - this._unsubMql = listenMediaQuery("(min-width: 600px)", (matches) => { - this._showReorder = matches; - }); - } - - public disconnectedCallback() { - super.disconnectedCallback(); - this._unsubMql?.(); - this._unsubMql = undefined; - } - public static get defaultConfig(): ChooseAction { return { choose: [{ conditions: [], sequence: [] }] }; } - private _expandedChanged(ev) { - this._expandedStates = this._expandedStates.concat(); - this._expandedStates[ev.target!.index] = ev.detail.expanded; - } - - private _getDescription(option) { - const conditions = ensureArray(option.conditions); - if (!conditions || conditions.length === 0) { - return this.hass.localize( - "ui.panel.config.automation.editor.actions.type.choose.no_conditions" - ); - } - let str = ""; - if (typeof conditions[0] === "string") { - str += conditions[0]; - } else { - str += describeCondition(conditions[0], this.hass, this._entityReg); - } - if (conditions.length > 1) { - str += this.hass.localize( - "ui.panel.config.automation.editor.actions.type.choose.option_description_additional", - { numberOfAdditionalConditions: conditions.length - 1 } - ); - } - return str; - } - protected render() { const action = this.action; - return html` - -
- ${repeat( - action.choose ? ensureArray(action.choose) : [], - (option) => option, - (option, idx) => html` -
- - -

- ${this.hass.localize( - "ui.panel.config.automation.editor.actions.type.choose.option", - { number: idx + 1 } - )}: - ${option.alias || - (this._expandedStates[idx] - ? "" - : this._getDescription(option))} -

- ${this._showReorder && !this.disabled - ? html` -
- -
- ` - : nothing} - - - - - ${this.hass.localize( - "ui.panel.config.automation.editor.actions.rename" - )} - - + const options = action.choose ? ensureArray(action.choose) : []; - - ${this.hass.localize( - "ui.panel.config.automation.editor.actions.duplicate" - )} - - - - - ${this.hass.localize( - "ui.panel.config.automation.editor.move_up" - )} - - - - - ${this.hass.localize( - "ui.panel.config.automation.editor.move_down" - )} - - - - - ${this.hass.localize( - "ui.panel.config.automation.editor.actions.type.choose.remove_option" - )} - - - - -
-

- ${this.hass.localize( - "ui.panel.config.automation.editor.actions.type.choose.conditions" - )}: -

- ( - option.conditions - )} - .disabled=${this.disabled} - .hass=${this.hass} - .idx=${idx} - @value-changed=${this._conditionChanged} - > -

- ${this.hass.localize( - "ui.panel.config.automation.editor.actions.type.choose.sequence" - )}: -

- -
-
-
-
- ` - )} -
- - - -
-
-
+ return html` + ${this._showDefault || action.default ? html` @@ -303,190 +44,39 @@ export class HaChooseAction extends LitElement implements ActionElement { )}:

` - : html``} + : html` + + `} `; } - private async _handleAction(ev: CustomEvent) { - switch (ev.detail.index) { - case 0: - await this._renameAction(ev); - break; - case 1: - this._duplicateOption(ev); - break; - case 2: - this._moveUp(ev); - break; - case 3: - this._moveDown(ev); - break; - case 4: - this._removeOption(ev); - break; - } - } - - private async _renameAction(ev: CustomEvent): Promise { - const index = (ev.target as any).idx; - const choose = this.action.choose - ? [...ensureArray(this.action.choose)] - : []; - const choice = choose[index]; - const alias = await showPromptDialog(this, { - title: this.hass.localize( - "ui.panel.config.automation.editor.actions.type.choose.change_alias" - ), - inputLabel: this.hass.localize( - "ui.panel.config.automation.editor.actions.type.choose.alias" - ), - inputType: "string", - placeholder: capitalizeFirstLetter(this._getDescription(choice)), - defaultValue: choice.alias, - confirmText: this.hass.localize("ui.common.submit"), - }); - if (alias !== null) { - if (alias === "") { - delete choose[index].alias; - } else { - choose[index].alias = alias; - } - fireEvent(this, "value-changed", { - value: { ...this.action, choose }, - }); - } - } - - private _duplicateOption(ev) { - const index = (ev.target as any).idx; - this._createOption(deepClone(ensureArray(this.action.choose)[index])); - } - - protected firstUpdated() { - ensureArray(this.action.choose).forEach(() => - this._expandedStates.push(false) - ); - } - - protected updated(changedProps: PropertyValues) { - super.updated(changedProps); - - if (this._expandLast) { - const nodes = this.shadowRoot!.querySelectorAll("ha-expansion-panel"); - nodes[nodes.length - 1].expanded = true; - this._expandLast = false; - } - } - private _addDefault() { this._showDefault = true; } - private _conditionChanged(ev: CustomEvent) { + private _optionsChanged(ev: CustomEvent) { ev.stopPropagation(); - const value = ev.detail.value as Condition[]; - const index = (ev.target as any).idx; - const choose = this.action.choose - ? [...ensureArray(this.action.choose)] - : []; - choose[index].conditions = value; + const value = ev.detail.value as Option[]; fireEvent(this, "value-changed", { - value: { ...this.action, choose }, - }); - } - - private _actionChanged(ev: CustomEvent) { - ev.stopPropagation(); - const value = ev.detail.value as Action[]; - const index = (ev.target as any).idx; - const choose = this.action.choose - ? [...ensureArray(this.action.choose)] - : []; - choose[index].sequence = value; - fireEvent(this, "value-changed", { - value: { ...this.action, choose }, - }); - } - - private _addOption() { - this._createOption({ conditions: [], sequence: [] }); - } - - private _createOption(opt: ChooseActionChoice) { - const choose = this.action.choose - ? [...ensureArray(this.action.choose)] - : []; - choose.push(opt); - fireEvent(this, "value-changed", { - value: { ...this.action, choose }, - }); - this._expandLast = true; - this._expandedStates[choose.length - 1] = true; - } - - private _moveUp(ev) { - const index = (ev.target as any).idx; - const newIndex = index - 1; - this._move(index, newIndex); - } - - private _moveDown(ev) { - const index = (ev.target as any).idx; - const newIndex = index + 1; - this._move(index, newIndex); - } - - private _move(index: number, newIndex: number) { - const options = ensureArray(this.action.choose)!.concat(); - const item = options.splice(index, 1)[0]; - options.splice(newIndex, 0, item); - - const expanded = this._expandedStates.splice(index, 1)[0]; - this._expandedStates.splice(newIndex, 0, expanded); - - fireEvent(this, "value-changed", { - value: { ...this.action, choose: options }, - }); - } - - private _removeOption(ev: CustomEvent) { - const index = (ev.target as any).idx; - showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.automation.editor.actions.type.choose.delete_confirm_title" - ), - text: this.hass.localize( - "ui.panel.config.automation.editor.actions.delete_confirm_text" - ), - dismissText: this.hass.localize("ui.common.cancel"), - confirmText: this.hass.localize("ui.common.delete"), - destructive: true, - confirm: () => { - const choose = this.action.choose - ? [...ensureArray(this.action.choose)] - : []; - choose.splice(index, 1); - this._expandedStates.splice(index, 1); - fireEvent(this, "value-changed", { - value: { ...this.action, choose }, - }); + value: { + ...this.action, + choose: value, }, }); } @@ -509,68 +99,9 @@ export class HaChooseAction extends LitElement implements ActionElement { return [ haStyle, css` - .options { - padding: 16px; - margin: -16px; - display: flex; - flex-direction: column; - gap: 16px; - } - .sortable-ghost { - background: none; - border-radius: var(--ha-card-border-radius, 12px); - } - .sortable-drag { - background: none; - } - .add-card mwc-button { - display: block; - text-align: center; - } - ha-expansion-panel { - --expansion-panel-summary-padding: 0 0 0 8px; - --expansion-panel-content-padding: 0; - } - mwc-list-item[disabled] { - --mdc-theme-text-primary-on-background: var(--disabled-text-color); - } - mwc-list-item.hidden { - display: none; - } - h3 { - margin: 0; - font-size: inherit; - font-weight: inherit; - } - ha-icon-button { - inset-inline-start: initial; - inset-inline-end: 0; - direction: var(--direction); - } - ha-svg-icon { - height: 20px; - } .link-button-row { padding: 14px 14px 0 14px; } - .card-content { - padding: 0 16px 16px 16px; - } - .handle { - padding: 12px; - cursor: move; /* fallback if grab cursor is unsupported */ - cursor: grab; - } - .handle ha-svg-icon { - pointer-events: none; - height: 24px; - } - .buttons { - display: flex; - flex-wrap: wrap; - gap: 8px; - order: 1; - } `, ]; } diff --git a/src/panels/config/automation/action/types/ha-automation-action-if.ts b/src/panels/config/automation/action/types/ha-automation-action-if.ts index 975fb03ec32d..9f3a15936325 100644 --- a/src/panels/config/automation/action/types/ha-automation-action-if.ts +++ b/src/panels/config/automation/action/types/ha-automation-action-if.ts @@ -4,7 +4,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event"; import "../../../../../components/ha-textfield"; import { Action, IfAction } from "../../../../../data/script"; import { haStyle } from "../../../../../resources/styles"; -import type { HomeAssistant, ItemPath } from "../../../../../types"; +import type { HomeAssistant } from "../../../../../types"; import type { Condition } from "../../../../lovelace/common/validate-condition"; import "../ha-automation-action"; import type { ActionElement } from "../ha-automation-action-row"; @@ -15,8 +15,6 @@ export class HaIfAction extends LitElement implements ActionElement { @property({ type: Boolean }) public disabled = false; - @property({ attribute: false }) public path?: ItemPath; - @property({ attribute: false }) public action!: IfAction; @state() private _showElse = false; @@ -38,7 +36,6 @@ export class HaIfAction extends LitElement implements ActionElement { )}*: + (localize: LocalizeFunc, type: string, template: boolean) => [ { name: "type", @@ -73,9 +66,7 @@ export class HaRepeatAction extends LitElement implements ActionElement { { name: type, selector: { - condition: { - path: [...(path ?? []), "repeat", type], - }, + condition: {}, }, }, ] as const satisfies readonly HaFormSchema[]) @@ -92,9 +83,7 @@ export class HaRepeatAction extends LitElement implements ActionElement { { name: "sequence", selector: { - action: { - path: [...(path ?? []), "repeat", "sequence"], - }, + action: {}, }, }, ] as const satisfies readonly HaFormSchema[] @@ -108,8 +97,7 @@ export class HaRepeatAction extends LitElement implements ActionElement { type ?? "count", "count" in action && typeof action.count === "string" ? isTemplate(action.count) - : false, - this.path + : false ); const data = { ...action, type }; diff --git a/src/panels/config/automation/action/types/ha-automation-action-sequence.ts b/src/panels/config/automation/action/types/ha-automation-action-sequence.ts index a8c16d6bed10..c2d350f456b5 100644 --- a/src/panels/config/automation/action/types/ha-automation-action-sequence.ts +++ b/src/panels/config/automation/action/types/ha-automation-action-sequence.ts @@ -1,11 +1,10 @@ import { CSSResultGroup, html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; -import memoizeOne from "memoize-one"; import { fireEvent } from "../../../../../common/dom/fire_event"; import "../../../../../components/ha-textfield"; import { Action, SequenceAction } from "../../../../../data/script"; import { haStyle } from "../../../../../resources/styles"; -import type { HomeAssistant, ItemPath } from "../../../../../types"; +import type { HomeAssistant } from "../../../../../types"; import "../ha-automation-action"; import type { ActionElement } from "../ha-automation-action-row"; @@ -15,8 +14,6 @@ export class HaSequenceAction extends LitElement implements ActionElement { @property({ type: Boolean }) public disabled = false; - @property({ attribute: false }) public path?: ItemPath; - @property({ attribute: false }) public action!: SequenceAction; public static get defaultConfig(): SequenceAction { @@ -25,17 +22,11 @@ export class HaSequenceAction extends LitElement implements ActionElement { }; } - private _getMemoizedPath = memoizeOne((path: ItemPath | undefined) => [ - ...(path ?? []), - "sequence", - ]); - protected render() { const { action } = this; return html` expandConditionWithShorthand(condition) ); @@ -68,7 +66,6 @@ export default class HaAutomationConditionEditor extends LitElement { hass: this.hass, condition: condition, disabled: this.disabled, - path: this.path, } )}
diff --git a/src/panels/config/automation/condition/ha-automation-condition-row.ts b/src/panels/config/automation/condition/ha-automation-condition-row.ts index b2aa1395b0dc..cf655ae21768 100644 --- a/src/panels/config/automation/condition/ha-automation-condition-row.ts +++ b/src/panels/config/automation/condition/ha-automation-condition-row.ts @@ -41,7 +41,7 @@ import { showPromptDialog, } from "../../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../../resources/styles"; -import { HomeAssistant, ItemPath } from "../../../../types"; +import { HomeAssistant } from "../../../../types"; import "./ha-automation-condition-editor"; export interface ConditionElement extends LitElement { @@ -83,8 +83,6 @@ export default class HaAutomationConditionRow extends LitElement { @property({ type: Boolean }) public disabled = false; - @property({ type: Array }) public path?: ItemPath; - @property({ type: Boolean }) public first?: boolean; @property({ type: Boolean }) public last?: boolean; @@ -302,7 +300,6 @@ export default class HaAutomationConditionRow extends LitElement { .disabled=${this.disabled} .hass=${this.hass} .condition=${this.condition} - .path=${this.path} >
diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index 481a34eb6e9a..bd22cb6b41eb 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -13,7 +13,7 @@ import { repeat } from "lit/directives/repeat"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; import { listenMediaQuery } from "../../../../common/dom/media_query"; -import { nestedArrayMove } from "../../../../common/util/array-move"; +import { nextRender } from "../../../../common/util/render-status"; import "../../../../components/ha-button"; import "../../../../components/ha-button-menu"; import "../../../../components/ha-sortable"; @@ -22,7 +22,7 @@ import type { AutomationClipboard, Condition, } from "../../../../data/automation"; -import type { HomeAssistant, ItemPath } from "../../../../types"; +import type { HomeAssistant } from "../../../../types"; import { PASTE_VALUE, showAddAutomationElementDialog, @@ -38,8 +38,6 @@ export default class HaAutomationCondition extends LitElement { @property({ type: Boolean }) public disabled = false; - @property({ type: Array }) public path?: ItemPath; - @state() private _showReorder: boolean = false; @storage({ @@ -115,10 +113,6 @@ export default class HaAutomationCondition extends LitElement { }); } - private get nested() { - return this.path !== undefined; - } - protected render() { if (!Array.isArray(this.conditions)) { return nothing; @@ -128,10 +122,11 @@ export default class HaAutomationCondition extends LitElement { handle-selector=".handle" draggable-selector="ha-automation-condition-row" .disabled=${!this._showReorder || this.disabled} - @item-moved=${this._conditionMoved} group="conditions" - .path=${this.path} invert-swap + @item-moved=${this._conditionMoved} + @item-added=${this._conditionAdded} + @item-removed=${this._conditionRemoved} >
${repeat( @@ -139,7 +134,7 @@ export default class HaAutomationCondition extends LitElement { (condition) => this._getKey(condition), (cond, idx) => html` { + ev.stopPropagation(); + const { index, data } = ev.detail; + const conditions = [ + ...this.conditions.slice(0, index), + data, + ...this.conditions.slice(index), + ]; + // Add condition locally to avoid UI jump + this.conditions = conditions; + await nextRender(); + fireEvent(this, "value-changed", { value: this.conditions }); + } + + private async _conditionRemoved(ev: CustomEvent): Promise { + ev.stopPropagation(); + const { index } = ev.detail; + const condition = this.conditions[index]; + // Remove condition locally to avoid UI jump + this.conditions = this.conditions.filter((c) => c !== condition); + await nextRender(); + // Ensure condition is removed even after update + const conditions = this.conditions.filter((c) => c !== condition); + fireEvent(this, "value-changed", { value: conditions }); } private _conditionChanged(ev: CustomEvent) { diff --git a/src/panels/config/automation/condition/types/ha-automation-condition-logical.ts b/src/panels/config/automation/condition/types/ha-automation-condition-logical.ts index caa875377d69..6e61e041e326 100644 --- a/src/panels/config/automation/condition/types/ha-automation-condition-logical.ts +++ b/src/panels/config/automation/condition/types/ha-automation-condition-logical.ts @@ -2,7 +2,7 @@ import { html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../../../../common/dom/fire_event"; import type { LogicalCondition } from "../../../../../data/automation"; -import type { HomeAssistant, ItemPath } from "../../../../../types"; +import type { HomeAssistant } from "../../../../../types"; import "../ha-automation-condition"; import type { ConditionElement } from "../ha-automation-condition-row"; @@ -17,12 +17,9 @@ export abstract class HaLogicalCondition @property({ type: Boolean }) public disabled = false; - @property({ attribute: false }) public path?: ItemPath; - protected render() { return html` @@ -174,7 +172,6 @@ export class HaManualAutomationEditor extends LitElement { .conditions=${this.config.conditions || []} .path=${["conditions"]} @value-changed=${this._conditionChanged} - @item-moved=${this._itemMoved} .hass=${this.hass} .disabled=${this.disabled} > @@ -214,7 +211,6 @@ export class HaManualAutomationEditor extends LitElement { .actions=${this.config.actions || []} .path=${["actions"]} @value-changed=${this._actionChanged} - @item-moved=${this._itemMoved} .hass=${this.hass} .narrow=${this.narrow} .disabled=${this.disabled} @@ -246,21 +242,6 @@ export class HaManualAutomationEditor extends LitElement { }); } - private _itemMoved(ev: CustomEvent): void { - ev.stopPropagation(); - const { oldIndex, newIndex, oldPath, newPath } = ev.detail; - const updatedConfig = nestedArrayMove( - this.config, - oldIndex, - newIndex, - oldPath, - newPath - ); - fireEvent(this, "value-changed", { - value: updatedConfig, - }); - } - private async _enable(): Promise { if (!this.hass || !this.stateObj) { return; diff --git a/src/panels/config/automation/option/ha-automation-option-row.ts b/src/panels/config/automation/option/ha-automation-option-row.ts new file mode 100644 index 000000000000..309117b10cde --- /dev/null +++ b/src/panels/config/automation/option/ha-automation-option-row.ts @@ -0,0 +1,335 @@ +import { consume } from "@lit-labs/context"; +import { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; +import "@material/mwc-list/mwc-list-item"; +import { + mdiArrowDown, + mdiArrowUp, + mdiContentDuplicate, + mdiDelete, + mdiDotsVertical, + mdiRenameBox, +} from "@mdi/js"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { ensureArray } from "../../../../common/array/ensure-array"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { preventDefault } from "../../../../common/dom/prevent_default"; +import { stopPropagation } from "../../../../common/dom/stop_propagation"; +import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter"; +import "../../../../components/ha-button-menu"; +import "../../../../components/ha-card"; +import "../../../../components/ha-expansion-panel"; +import "../../../../components/ha-icon-button"; +import { Condition } from "../../../../data/automation"; +import { describeCondition } from "../../../../data/automation_i18n"; +import { fullEntitiesContext } from "../../../../data/context"; +import { EntityRegistryEntry } from "../../../../data/entity_registry"; +import { Action, Option } from "../../../../data/script"; +import { + showConfirmationDialog, + showPromptDialog, +} from "../../../../dialogs/generic/show-dialog-box"; +import { haStyle } from "../../../../resources/styles"; +import type { HomeAssistant } from "../../../../types"; + +@customElement("ha-automation-option-row") +export default class HaAutomationOptionRow extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public option!: Option; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Number }) public index!: number; + + @property({ type: Boolean }) public first = false; + + @property({ type: Boolean }) public last = false; + + @state() private _expanded = false; + + @state() + @consume({ context: fullEntitiesContext, subscribe: true }) + _entityReg!: EntityRegistryEntry[]; + + private _expandedChanged(ev) { + if (ev.currentTarget.id !== "option") { + return; + } + this._expanded = ev.detail.expanded; + } + + private _getDescription() { + const conditions = ensureArray(this.option.conditions); + if (!conditions || conditions.length === 0) { + return this.hass.localize( + "ui.panel.config.automation.editor.actions.type.choose.no_conditions" + ); + } + let str = ""; + if (typeof conditions[0] === "string") { + str += conditions[0]; + } else { + str += describeCondition(conditions[0], this.hass, this._entityReg); + } + if (conditions.length > 1) { + str += this.hass.localize( + "ui.panel.config.automation.editor.actions.type.choose.option_description_additional", + { numberOfAdditionalConditions: conditions.length - 1 } + ); + } + return str; + } + + protected render() { + if (!this.option) return nothing; + + return html` + + +

+ ${this.hass.localize( + "ui.panel.config.automation.editor.actions.type.choose.option", + { number: this.index + 1 } + )}: + ${this.option.alias || + (this._expanded ? "" : this._getDescription())} +

+ + + + + + + ${this.hass.localize( + "ui.panel.config.automation.editor.actions.rename" + )} + + + + + ${this.hass.localize( + "ui.panel.config.automation.editor.actions.duplicate" + )} + + + + + ${this.hass.localize("ui.panel.config.automation.editor.move_up")} + + + + + ${this.hass.localize( + "ui.panel.config.automation.editor.move_down" + )} + + + + + ${this.hass.localize( + "ui.panel.config.automation.editor.actions.type.choose.remove_option" + )} + + + + +
+

+ ${this.hass.localize( + "ui.panel.config.automation.editor.actions.type.choose.conditions" + )}: +

+ ( + this.option.conditions + )} + .disabled=${this.disabled} + .hass=${this.hass} + @value-changed=${this._conditionChanged} + > +

+ ${this.hass.localize( + "ui.panel.config.automation.editor.actions.type.choose.sequence" + )}: +

+ +
+
+
+ `; + } + + private async _handleAction(ev: CustomEvent) { + switch (ev.detail.index) { + case 0: + await this._renameOption(); + break; + case 1: + fireEvent(this, "duplicate"); + break; + case 2: + fireEvent(this, "move-up"); + break; + case 3: + fireEvent(this, "move-down"); + break; + case 4: + this._removeOption(); + break; + } + } + + private _removeOption() { + showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.automation.editor.actions.type.choose.delete_confirm_title" + ), + text: this.hass.localize( + "ui.panel.config.automation.editor.actions.delete_confirm_text" + ), + dismissText: this.hass.localize("ui.common.cancel"), + confirmText: this.hass.localize("ui.common.delete"), + destructive: true, + confirm: () => + fireEvent(this, "value-changed", { + value: null, + }), + }); + } + + private async _renameOption(): Promise { + const alias = await showPromptDialog(this, { + title: this.hass.localize( + "ui.panel.config.automation.editor.actions.type.choose.change_alias" + ), + inputLabel: this.hass.localize( + "ui.panel.config.automation.editor.actions.type.choose.alias" + ), + inputType: "string", + placeholder: capitalizeFirstLetter(this._getDescription()), + defaultValue: this.option.alias, + confirmText: this.hass.localize("ui.common.submit"), + }); + if (alias !== null) { + const value = { ...this.option }; + if (alias === "") { + delete value.alias; + } else { + value.alias = alias; + } + fireEvent(this, "value-changed", { + value, + }); + } + } + + private _conditionChanged(ev: CustomEvent) { + ev.stopPropagation(); + const conditions = ev.detail.value as Condition[]; + const value = { ...this.option, conditions: conditions }; + fireEvent(this, "value-changed", { + value, + }); + } + + private _actionChanged(ev: CustomEvent) { + ev.stopPropagation(); + const actions = ev.detail.value as Action[]; + const value = { ...this.option, sequence: actions }; + fireEvent(this, "value-changed", { + value, + }); + } + + public expand() { + this.updateComplete.then(() => { + this.shadowRoot!.querySelector("ha-expansion-panel")!.expanded = true; + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + ha-button-menu, + ha-icon-button { + --mdc-theme-text-primary-on-background: var(--primary-text-color); + } + .disabled { + opacity: 0.5; + pointer-events: none; + } + ha-expansion-panel { + --expansion-panel-summary-padding: 0 0 0 8px; + --expansion-panel-content-padding: 0; + } + h3 { + margin: 0; + font-size: inherit; + font-weight: inherit; + } + .card-content { + padding: 16px; + } + + mwc-list-item[disabled] { + --mdc-theme-text-primary-on-background: var(--disabled-text-color); + } + mwc-list-item.hidden { + display: none; + } + .warning ul { + margin: 4px 0; + } + li[role="separator"] { + border-bottom-color: var(--divider-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-option-row": HaAutomationOptionRow; + } +} diff --git a/src/panels/config/automation/option/ha-automation-option.ts b/src/panels/config/automation/option/ha-automation-option.ts new file mode 100644 index 000000000000..459d4f582642 --- /dev/null +++ b/src/panels/config/automation/option/ha-automation-option.ts @@ -0,0 +1,290 @@ +import { mdiDrag, mdiPlus } from "@mdi/js"; +import deepClone from "deep-clone-simple"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + css, + html, + nothing, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import { storage } from "../../../../common/decorators/storage"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { listenMediaQuery } from "../../../../common/dom/media_query"; +import { nextRender } from "../../../../common/util/render-status"; +import "../../../../components/ha-button"; +import "../../../../components/ha-sortable"; +import "../../../../components/ha-svg-icon"; +import type { AutomationClipboard } from "../../../../data/automation"; +import { Option } from "../../../../data/script"; +import { HomeAssistant } from "../../../../types"; +import "./ha-automation-option-row"; +import type HaAutomationOptionRow from "./ha-automation-option-row"; + +@customElement("ha-automation-option") +export default class HaAutomationOption extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ attribute: false }) public options!: Option[]; + + @state() private _showReorder: boolean = false; + + @storage({ + key: "automationClipboard", + state: true, + subscribe: true, + storage: "sessionStorage", + }) + public _clipboard?: AutomationClipboard; + + private _focusLastOptionOnChange = false; + + private _optionsKeys = new WeakMap(); + + private _unsubMql?: () => void; + + public connectedCallback() { + super.connectedCallback(); + this._unsubMql = listenMediaQuery("(min-width: 600px)", (matches) => { + this._showReorder = matches; + }); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._unsubMql?.(); + this._unsubMql = undefined; + } + + protected render() { + return html` + +
+ ${repeat( + this.options, + (option) => this._getKey(option), + (option, idx) => html` + + ${this._showReorder && !this.disabled + ? html` +
+ +
+ ` + : nothing} +
+ ` + )} +
+ + + +
+
+
+ `; + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + + if (changedProps.has("options") && this._focusLastOptionOnChange) { + this._focusLastOptionOnChange = false; + + const row = this.shadowRoot!.querySelector( + "ha-automation-option-row:last-of-type" + )!; + row.updateComplete.then(() => { + row.expand(); + row.scrollIntoView(); + row.focus(); + }); + } + } + + public expandAll() { + const rows = this.shadowRoot!.querySelectorAll( + "ha-automation-option-row" + )!; + rows.forEach((row) => { + row.expand(); + }); + } + + private _addOption = () => { + const options = this.options.concat({ conditions: [], sequence: [] }); + this._focusLastOptionOnChange = true; + fireEvent(this, "value-changed", { value: options }); + }; + + private _getKey(option: Option) { + if (!this._optionsKeys.has(option)) { + this._optionsKeys.set(option, Math.random().toString()); + } + + return this._optionsKeys.get(option)!; + } + + private _moveUp(ev) { + ev.stopPropagation(); + const index = (ev.target as any).index; + const newIndex = index - 1; + this._move(index, newIndex); + } + + private _moveDown(ev) { + ev.stopPropagation(); + const index = (ev.target as any).index; + const newIndex = index + 1; + this._move(index, newIndex); + } + + private _move(oldIndex: number, newIndex: number) { + const options = this.options.concat(); + const item = options.splice(oldIndex, 1)[0]; + options.splice(newIndex, 0, item); + this.options = options; + fireEvent(this, "value-changed", { value: options }); + } + + private _optionMoved(ev: CustomEvent): void { + ev.stopPropagation(); + const { oldIndex, newIndex } = ev.detail; + this._move(oldIndex, newIndex); + } + + private async _optionAdded(ev: CustomEvent): Promise { + ev.stopPropagation(); + const { index, data } = ev.detail; + const options = [ + ...this.options.slice(0, index), + data, + ...this.options.slice(index), + ]; + // Add option locally to avoid UI jump + this.options = options; + await nextRender(); + fireEvent(this, "value-changed", { value: this.options }); + } + + private async _optionRemoved(ev: CustomEvent): Promise { + ev.stopPropagation(); + const { index } = ev.detail; + const option = this.options[index]; + // Remove option locally to avoid UI jump + this.options = this.options.filter((o) => o !== option); + await nextRender(); + // Ensure option is removed even after update + const options = this.options.filter((o) => o !== option); + fireEvent(this, "value-changed", { value: options }); + } + + private _optionChanged(ev: CustomEvent) { + ev.stopPropagation(); + const options = [...this.options]; + const newValue = ev.detail.value; + const index = (ev.target as any).index; + + if (newValue === null) { + options.splice(index, 1); + } else { + // Store key on new value. + const key = this._getKey(options[index]); + this._optionsKeys.set(newValue, key); + + options[index] = newValue; + } + + fireEvent(this, "value-changed", { value: options }); + } + + private _duplicateOption(ev: CustomEvent) { + ev.stopPropagation(); + const index = (ev.target as any).index; + fireEvent(this, "value-changed", { + value: this.options.concat(deepClone(this.options[index])), + }); + } + + static get styles(): CSSResultGroup { + return css` + .options { + padding: 16px; + margin: -16px; + display: flex; + flex-direction: column; + gap: 16px; + } + .sortable-ghost { + background: none; + border-radius: var(--ha-card-border-radius, 12px); + } + .sortable-drag { + background: none; + } + ha-automation-option-row { + display: block; + scroll-margin-top: 48px; + } + ha-svg-icon { + height: 20px; + } + .handle { + padding: 12px; + cursor: move; /* fallback if grab cursor is unsupported */ + cursor: grab; + } + .handle ha-svg-icon { + pointer-events: none; + height: 24px; + } + .buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; + order: 1; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-automation-option": HaAutomationOption; + } +} diff --git a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts index 174233692e2b..68eb1b5e4f76 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts @@ -58,7 +58,7 @@ import { showPromptDialog, } from "../../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../../resources/styles"; -import type { HomeAssistant, ItemPath } from "../../../../types"; +import type { HomeAssistant } from "../../../../types"; import "./types/ha-automation-trigger-calendar"; import "./types/ha-automation-trigger-conversation"; import "./types/ha-automation-trigger-device"; @@ -112,8 +112,6 @@ export default class HaAutomationTriggerRow extends LitElement { @property({ type: Boolean }) public disabled = false; - @property({ type: Array }) public path?: ItemPath; - @property({ type: Boolean }) public first?: boolean; @property({ type: Boolean }) public last?: boolean; @@ -383,7 +381,6 @@ export default class HaAutomationTriggerRow extends LitElement { hass: this.hass, trigger: this.trigger, disabled: this.disabled, - path: this.path, })}
`} diff --git a/src/panels/config/automation/trigger/ha-automation-trigger.ts b/src/panels/config/automation/trigger/ha-automation-trigger.ts index 2a1d25479991..6a4f84fb29cd 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger.ts @@ -13,7 +13,7 @@ import { repeat } from "lit/directives/repeat"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; import { listenMediaQuery } from "../../../../common/dom/media_query"; -import { nestedArrayMove } from "../../../../common/util/array-move"; +import { nextRender } from "../../../../common/util/render-status"; import "../../../../components/ha-button"; import "../../../../components/ha-button-menu"; import "../../../../components/ha-sortable"; @@ -23,14 +23,14 @@ import { Trigger, TriggerList, } from "../../../../data/automation"; -import { HomeAssistant, ItemPath } from "../../../../types"; +import { isTriggerList } from "../../../../data/trigger"; +import { HomeAssistant } from "../../../../types"; import { PASTE_VALUE, showAddAutomationElementDialog, } from "../show-add-automation-element-dialog"; import "./ha-automation-trigger-row"; import type HaAutomationTriggerRow from "./ha-automation-trigger-row"; -import { isTriggerList } from "../../../../data/trigger"; @customElement("ha-automation-trigger") export default class HaAutomationTrigger extends LitElement { @@ -40,8 +40,6 @@ export default class HaAutomationTrigger extends LitElement { @property({ type: Boolean }) public disabled = false; - @property({ type: Array }) public path?: ItemPath; - @state() private _showReorder: boolean = false; @storage({ @@ -71,20 +69,17 @@ export default class HaAutomationTrigger extends LitElement { this._unsubMql = undefined; } - private get nested() { - return this.path !== undefined; - } - protected render() { return html`
${repeat( @@ -92,7 +87,7 @@ export default class HaAutomationTrigger extends LitElement { (trigger) => this._getKey(trigger), (trg, idx) => html` { + ev.stopPropagation(); + const { index, data } = ev.detail; + const triggers = [ + ...this.triggers.slice(0, index), + data, + ...this.triggers.slice(index), + ]; + // Add trigger locally to avoid UI jump + this.triggers = triggers; + await nextRender(); + fireEvent(this, "value-changed", { value: this.triggers }); + } + + private async _triggerRemoved(ev: CustomEvent): Promise { + ev.stopPropagation(); + const { index } = ev.detail; + const trigger = this.triggers[index]; + // Remove trigger locally to avoid UI jump + this.triggers = this.triggers.filter((t) => t !== trigger); + await nextRender(); + // Ensure trigger is removed even after update + const triggers = this.triggers.filter((t) => t !== trigger); + fireEvent(this, "value-changed", { value: triggers }); } private _triggerChanged(ev: CustomEvent) { diff --git a/src/panels/config/automation/trigger/types/ha-automation-trigger-list.ts b/src/panels/config/automation/trigger/types/ha-automation-trigger-list.ts index 8a7481390868..5fd34bef77ea 100644 --- a/src/panels/config/automation/trigger/types/ha-automation-trigger-list.ts +++ b/src/panels/config/automation/trigger/types/ha-automation-trigger-list.ts @@ -2,7 +2,7 @@ import { css, html, LitElement } from "lit"; import { customElement, property } from "lit/decorators"; import { ensureArray } from "../../../../../common/array/ensure-array"; import type { TriggerList } from "../../../../../data/automation"; -import type { HomeAssistant, ItemPath } from "../../../../../types"; +import type { HomeAssistant } from "../../../../../types"; import "../ha-automation-trigger"; import { handleChangeEvent, @@ -15,8 +15,6 @@ export class HaTriggerList extends LitElement implements TriggerElement { @property({ attribute: false }) public trigger!: TriggerList; - @property({ attribute: false }) public path?: ItemPath; - @property({ type: Boolean }) public disabled = false; public static get defaultConfig(): TriggerList { @@ -30,7 +28,6 @@ export class HaTriggerList extends LitElement implements TriggerElement { return html` ${html``} `; } @@ -237,29 +226,6 @@ export abstract class HaBlueprintGenericEditor extends LitElement { }); } - private _itemMoved(ev) { - ev.stopPropagation(); - const { oldIndex, newIndex, oldPath, newPath } = ev.detail; - - const input = nestedArrayMove( - this._config.use_blueprint.input, - oldIndex, - newIndex, - oldPath, - newPath - ); - - fireEvent(this, "value-changed", { - value: { - ...this._config, - use_blueprint: { - ...this._config.use_blueprint, - input, - }, - }, - }); - } - static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/panels/config/script/manual-script-editor.ts b/src/panels/config/script/manual-script-editor.ts index 240dc31437aa..548fe74d1efb 100644 --- a/src/panels/config/script/manual-script-editor.ts +++ b/src/panels/config/script/manual-script-editor.ts @@ -15,7 +15,6 @@ import { extractSearchParam, removeSearchParam, } from "../../../common/url/search-params"; -import { nestedArrayMove } from "../../../common/util/array-move"; import "../../../components/ha-card"; import "../../../components/ha-icon-button"; import "../../../components/ha-markdown"; @@ -163,7 +162,6 @@ export class HaManualScriptEditor extends LitElement { .actions=${this.config.sequence || []} .path=${["sequence"]} @value-changed=${this._sequenceChanged} - @item-moved=${this._itemMoved} .hass=${this.hass} .narrow=${this.narrow} .disabled=${this.disabled} @@ -185,21 +183,6 @@ export class HaManualScriptEditor extends LitElement { }); } - private _itemMoved(ev: CustomEvent): void { - ev.stopPropagation(); - const { oldIndex, newIndex, oldPath, newPath } = ev.detail; - const updatedConfig = nestedArrayMove( - this.config, - oldIndex, - newIndex, - oldPath, - newPath - ); - fireEvent(this, "value-changed", { - value: updatedConfig, - }); - } - static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/panels/lovelace/badges/hui-view-badges.ts b/src/panels/lovelace/badges/hui-view-badges.ts index ee3d23e2ffa5..efbd85d4f5db 100644 --- a/src/panels/lovelace/badges/hui-view-badges.ts +++ b/src/panels/lovelace/badges/hui-view-badges.ts @@ -83,11 +83,11 @@ export class HuiViewBadges extends LitElement { private _badgeMoved(ev) { ev.stopPropagation(); - const { oldIndex, newIndex, oldPath, newPath } = ev.detail; + const { oldIndex, newIndex } = ev.detail; const newConfig = moveBadge( this.lovelace!.config, - [...oldPath, oldIndex] as [number, number, number], - [...newPath, newIndex] as [number, number, number] + [this.viewIndex!, oldIndex], + [this.viewIndex!, newIndex] ); this.lovelace!.saveConfig(newConfig); } @@ -121,7 +121,6 @@ export class HuiViewBadges extends LitElement { @drag-end=${this._dragEnd} group="badge" draggable-selector="[data-sortable]" - .path=${[this.viewIndex]} .rollback=${false} .options=${BADGE_SORTABLE_OPTIONS} invert-swap diff --git a/src/panels/lovelace/sections/hui-grid-section.ts b/src/panels/lovelace/sections/hui-grid-section.ts index 2edc91094120..80cfed687585 100644 --- a/src/panels/lovelace/sections/hui-grid-section.ts +++ b/src/panels/lovelace/sections/hui-grid-section.ts @@ -4,8 +4,8 @@ import { property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { repeat } from "lit/directives/repeat"; import { styleMap } from "lit/directives/style-map"; -import "../../../components/ha-ripple"; import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-ripple"; import type { HaSortableOptions } from "../../../components/ha-sortable"; import { LovelaceSectionElement } from "../../../data/lovelace"; import { LovelaceCardConfig } from "../../../data/lovelace/config/card"; @@ -16,6 +16,7 @@ import { HuiCard } from "../cards/hui-card"; import { computeCardGridSize } from "../common/compute-card-grid-size"; import "../components/hui-card-edit-mode"; import { moveCard } from "../editor/config-util"; +import { LovelaceCardPath } from "../editor/lovelace-path"; import type { Lovelace } from "../types"; const CARD_SORTABLE_OPTIONS: HaSortableOptions = { @@ -68,14 +69,15 @@ export class GridSection extends LitElement implements LovelaceSectionElement { return html`
@@ -89,6 +91,11 @@ export class GridSection extends LitElement implements LovelaceSectionElement { const { rows, columns } = computeCardGridSize(layoutOptions); + const cardPath: LovelaceCardPath = [ + this.viewIndex!, + this.index!, + idx, + ]; return html`
${editMode ? html` ${card} @@ -141,15 +149,28 @@ export class GridSection extends LitElement implements LovelaceSectionElement { private _cardMoved(ev) { ev.stopPropagation(); - const { oldIndex, newIndex, oldPath, newPath } = ev.detail; + const { oldIndex, newIndex } = ev.detail; const newConfig = moveCard( this.lovelace!.config, - [...oldPath, oldIndex] as [number, number, number], - [...newPath, newIndex] as [number, number, number] + [this.viewIndex!, this.index!, oldIndex], + [this.viewIndex!, this.index!, newIndex] ); this.lovelace!.saveConfig(newConfig); } + private _cardAdded(ev) { + const { index, data } = ev.detail; + const oldPath = data as LovelaceCardPath; + const newPath = [this.viewIndex!, this.index!, index] as LovelaceCardPath; + const newConfig = moveCard(this.lovelace!.config, oldPath, newPath); + this.lovelace!.saveConfig(newConfig); + } + + private _cardRemoved(ev) { + ev.stopPropagation(); + // Do nothing, it's handle by the "card-added" event from the new parent. + } + private _dragStart() { this._dragging = true; } diff --git a/src/panels/lovelace/views/hui-sections-view.ts b/src/panels/lovelace/views/hui-sections-view.ts index 351ee8f2ee29..c85ca3e8efcf 100644 --- a/src/panels/lovelace/views/hui-sections-view.ts +++ b/src/panels/lovelace/views/hui-sections-view.ts @@ -175,8 +175,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement { const rowSpan = sectionConfig?.row_span || 1; - (section as any).itemPath = [idx]; - return html`
any> = T extends ( : never; export type Entries = [keyof T, T[keyof T]][]; - -export type ItemPath = (number | string)[];