diff --git a/src/common/util/array-move.ts b/src/common/util/array-move.ts new file mode 100644 index 0000000000..36a152a019 --- /dev/null +++ b/src/common/util/array-move.ts @@ -0,0 +1,53 @@ +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); +} + +export function nestedArrayMove( + obj: T | T[], + oldIndex: number, + newIndex: number, + oldPath?: ItemPath, + newPath?: ItemPath +): T | T[] { + const newObj = Array.isArray(obj) ? [...obj] : { ...obj }; + const from = oldPath ? findNestedItem(newObj, oldPath) : newObj; + const to = newPath ? findNestedItem(newObj, newPath, true) : newObj; + + if (!Array.isArray(from) || !Array.isArray(to)) { + return obj; + } + + 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 5850c912f3..c1edf4d266 100644 --- a/src/components/ha-selector/ha-selector-action.ts +++ b/src/components/ha-selector/ha-selector-action.ts @@ -24,8 +24,7 @@ export class HaActionSelector extends LitElement { .disabled=${this.disabled} .actions=${this.value || []} .hass=${this.hass} - .nested=${this.selector.action?.nested} - .reOrderMode=${this.selector.action?.reorder_mode} + .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 17461bb973..649986deef 100644 --- a/src/components/ha-selector/ha-selector-condition.ts +++ b/src/components/ha-selector/ha-selector-condition.ts @@ -24,8 +24,7 @@ export class HaConditionSelector extends LitElement { .disabled=${this.disabled} .conditions=${this.value || []} .hass=${this.hass} - .nested=${this.selector.condition?.nested} - .reOrderMode=${this.selector.condition?.reorder_mode} + .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 7a31fb8e7a..541aac3b44 100644 --- a/src/components/ha-selector/ha-selector-trigger.ts +++ b/src/components/ha-selector/ha-selector-trigger.ts @@ -24,8 +24,7 @@ export class HaTriggerSelector extends LitElement { .disabled=${this.disabled} .triggers=${this.value || []} .hass=${this.hass} - .nested=${this.selector.trigger?.nested} - .reOrderMode=${this.selector.trigger?.reorder_mode} + .path=${this.selector.trigger?.path} > `; } diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 119418355c..d3c1d0ab17 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -40,6 +40,8 @@ import "./ha-service-picker"; import "./ha-settings-row"; import "./ha-yaml-editor"; import type { HaYamlEditor } from "./ha-yaml-editor"; +import { nestedArrayMove } from "../common/util/array-move"; +import { ReorderModeMixin } from "../state/reorder-mode-mixin"; const attributeFilter = (values: any[], attribute: any) => { if (typeof attribute === "object") { @@ -75,7 +77,7 @@ interface ExtHassService extends Omit { } @customElement("ha-service-control") -export class HaServiceControl extends LitElement { +export class HaServiceControl extends ReorderModeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public value?: { @@ -439,6 +441,7 @@ export class HaServiceControl extends LitElement { allow-custom-entity >` : ""} + ${this._renderReorderModeAlert()} ${shouldRenderServiceDataYaml ? html`` : filteredFields?.map((dataField) => { + 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); + return dataField.selector && (!dataField.advanced || this.showAdvanced || @@ -488,7 +507,7 @@ export class HaServiceControl extends LitElement { (!this._value?.data || this._value.data[dataField.key] === undefined))} .hass=${this.hass} - .selector=${dataField.selector} + .selector=${enhancedSelector} .key=${dataField.key} @value-changed=${this._serviceDataChanged} .value=${this._value?.data @@ -496,12 +515,41 @@ export class HaServiceControl extends LitElement { : undefined} .placeholder=${dataField.default} .localizeValue=${this._localizeValueCallback} + @item-moved=${this._itemMoved} > ` : ""; })}`; } + private _renderReorderModeAlert() { + if (!this._reorderMode.active) { + return nothing; + } + return html` + + ${this.hass.localize( + "ui.panel.config.automation.editor.re_order_mode.description_all" + )} + + ${this.hass.localize( + "ui.panel.config.automation.editor.re_order_mode.exit" + )} + + + `; + } + + private async _exitReOrderMode() { + this._reorderMode.exit(); + } + private _localizeValueCallback = (key: string) => { if (!this._value?.service) { return ""; @@ -697,6 +745,22 @@ 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 aed068a83a..32c9cffe71 100644 --- a/src/components/ha-sortable.ts +++ b/src/components/ha-sortable.ts @@ -4,12 +4,15 @@ 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; }; } } @@ -21,6 +24,9 @@ export class HaSortable extends LitElement { @property({ type: Boolean }) public disabled = false; + @property({ type: Boolean }) + public path?: ItemPath; + @property({ type: Boolean, attribute: "no-style" }) public noStyle: boolean = false; @@ -30,6 +36,9 @@ export class HaSortable extends LitElement { @property({ type: String, attribute: "handle-selector" }) public handleSelector?: string; + @property({ type: String, attribute: "group" }) + public group?: string; + protected updated(changedProperties: PropertyValues) { if (changedProperties.has("disabled")) { if (this.disabled) { @@ -100,6 +109,7 @@ export class HaSortable extends LitElement { const options: SortableInstance.Options = { animation: 150, + swapThreshold: 0.75, onChoose: this._handleChoose, onEnd: this._handleEnd, }; @@ -110,27 +120,41 @@ export class HaSortable extends LitElement { if (this.handleSelector) { options.handle = this.handleSelector; } + if (this.draggableSelector) { + options.draggable = this.draggableSelector; + } + if (this.group) { + options.group = this.group; + } + this._sortable = new Sortable(container, options); } - private _handleEnd = (evt: SortableEvent) => { + private _handleEnd = async (evt: SortableEvent) => { // put back in original location if ((evt.item as any).placeholder) { (evt.item as any).placeholder.replaceWith(evt.item); delete (evt.item as any).placeholder; } - // if item was not moved, ignore + + 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 ( - evt.oldIndex === undefined || - evt.newIndex === undefined || - evt.oldIndex === evt.newIndex + oldIndex === undefined || + newIndex === undefined || + (oldIndex === newIndex && oldPath?.join(".") === newPath?.join(".")) ) { return; } fireEvent(this, "item-moved", { - oldIndex: evt.oldIndex!, - newIndex: evt.newIndex!, + oldIndex, + newIndex, + oldPath, + newPath, }); }; diff --git a/src/data/selector.ts b/src/data/selector.ts index cddf581020..3362e638ff 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -3,7 +3,7 @@ import { ensureArray } from "../common/array/ensure-array"; import { computeStateDomain } from "../common/entity/compute_state_domain"; import { supportsFeature } from "../common/entity/supports-feature"; import { UiAction } from "../panels/lovelace/components/hui-action-editor"; -import { HomeAssistant } from "../types"; +import { HomeAssistant, ItemPath } from "../types"; import { DeviceRegistryEntry, getDeviceIntegrationLookup, @@ -59,8 +59,7 @@ export type Selector = export interface ActionSelector { action: { - reorder_mode?: boolean; - nested?: boolean; + path?: ItemPath; } | null; } @@ -113,8 +112,7 @@ export interface ColorTempSelector { export interface ConditionSelector { condition: { - reorder_mode?: boolean; - nested?: boolean; + path?: ItemPath; } | null; } @@ -392,8 +390,7 @@ export interface TimeSelector { export interface TriggerSelector { trigger: { - reorder_mode?: boolean; - nested?: boolean; + path?: ItemPath; } | null; } 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 0e58c0e8b2..75c68f616c 100644 --- a/src/panels/config/automation/action/ha-automation-action-row.ts +++ b/src/panels/config/automation/action/ha-automation-action-row.ts @@ -57,7 +57,11 @@ import { showPromptDialog, } from "../../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../../resources/styles"; -import type { HomeAssistant } from "../../../../types"; +import { + ReorderMode, + reorderModeContext, +} from "../../../../state/reorder-mode-mixin"; +import type { HomeAssistant, ItemPath } from "../../../../types"; import { showToast } from "../../../../util/toast"; import "./types/ha-automation-action-activate_scene"; import "./types/ha-automation-action-choose"; @@ -129,7 +133,7 @@ export default class HaAutomationActionRow extends LitElement { @property({ type: Boolean }) public hideMenu = false; - @property({ type: Boolean }) public reOrderMode = false; + @property() public path?: ItemPath; @storage({ key: "automationClipboard", @@ -143,6 +147,10 @@ export default class HaAutomationActionRow extends LitElement { @consume({ context: fullEntitiesContext, subscribe: true }) _entityReg!: EntityRegistryEntry[]; + @state() + @consume({ context: reorderModeContext, subscribe: true }) + private _reorderMode?: ReorderMode; + @state() private _warnings?: string[]; @state() private _uiModeAvailable = true; @@ -176,9 +184,13 @@ export default class HaAutomationActionRow extends LitElement { } protected render() { + if (!this.action) return nothing; + const type = getType(this.action); const yamlMode = this._yamlMode; + const noReorderModeAvailable = this._reorderMode === undefined; + return html` ${this.action.enabled === false @@ -247,7 +259,12 @@ export default class HaAutomationActionRow extends LitElement { .path=${mdiRenameBox} > - + ${this.hass.localize( "ui.panel.config.automation.editor.actions.re_order" )} @@ -405,8 +422,8 @@ export default class HaAutomationActionRow extends LitElement { hass: this.hass, action: this.action, narrow: this.narrow, - reOrderMode: this.reOrderMode, disabled: this.disabled, + path: this.path, })} `} @@ -435,7 +452,7 @@ export default class HaAutomationActionRow extends LitElement { await this._renameAction(); break; case 2: - fireEvent(this, "re-order"); + this._reorderMode?.enter(); break; case 3: fireEvent(this, "duplicate"); @@ -640,6 +657,9 @@ export default class HaAutomationActionRow extends LitElement { 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; } diff --git a/src/panels/config/automation/action/ha-automation-action.ts b/src/panels/config/automation/action/ha-automation-action.ts index ba12089307..6a9dec5ecc 100644 --- a/src/panels/config/automation/action/ha-automation-action.ts +++ b/src/panels/config/automation/action/ha-automation-action.ts @@ -1,17 +1,23 @@ +import { consume } from "@lit-labs/context"; import { mdiArrowDown, mdiArrowUp, mdiDrag, mdiPlus } from "@mdi/js"; import deepClone from "deep-clone-simple"; import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit"; -import { customElement, property } from "lit/decorators"; +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 { nestedArrayMove } from "../../../../common/util/array-move"; import "../../../../components/ha-button"; -import "../../../../components/ha-svg-icon"; 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 } from "../../../../types"; +import { + ReorderMode, + reorderModeContext, +} from "../../../../state/reorder-mode-mixin"; +import { HomeAssistant, ItemPath } from "../../../../types"; import { PASTE_VALUE, showAddAutomationElementDialog, @@ -27,11 +33,13 @@ export default class HaAutomationAction extends LitElement { @property({ type: Boolean }) public disabled = false; - @property({ type: Boolean }) public nested = false; + @property() public path?: ItemPath; @property() public actions!: Action[]; - @property({ type: Boolean }) public reOrderMode = false; + @state() + @consume({ context: reorderModeContext, subscribe: true }) + private _reorderMode?: ReorderMode; @storage({ key: "automationClipboard", @@ -45,31 +53,18 @@ export default class HaAutomationAction extends LitElement { private _actionKeys = new WeakMap(); + private get nested() { + return this.path !== undefined; + } + protected render() { return html` - ${this.reOrderMode && !this.nested - ? html` - - ${this.hass.localize( - "ui.panel.config.automation.editor.re_order_mode.description_actions" - )} - - ${this.hass.localize( - "ui.panel.config.automation.editor.re_order_mode.exit" - )} - - - ` - : null}
${repeat( @@ -77,18 +72,17 @@ export default class HaAutomationAction extends LitElement { (action) => this._getKey(action), (action, idx) => html` - ${this.reOrderMode + ${this._reorderMode?.active ? html` ev.preventDefault(); @@ -47,9 +52,9 @@ export class HaChooseAction extends LitElement implements ActionElement { @property({ type: Boolean }) public disabled = false; - @property() public action!: ChooseAction; + @property({ attribute: false }) public path?: ItemPath; - @property({ type: Boolean }) public reOrderMode = false; + @property() public action!: ChooseAction; @state() private _showDefault = false; @@ -59,6 +64,10 @@ export class HaChooseAction extends LitElement implements ActionElement { @consume({ context: fullEntitiesContext, subscribe: true }) _entityReg!: EntityRegistryEntry[]; + @state() + @consume({ context: reorderModeContext, subscribe: true }) + private _reorderMode?: ReorderMode; + private _expandLast = false; public static get defaultConfig() { @@ -95,11 +104,14 @@ export class HaChooseAction extends LitElement implements ActionElement { protected render() { const action = this.action; + const noReorderModeAvailable = this._reorderMode === undefined; + return html`
${repeat( @@ -123,7 +135,7 @@ export class HaChooseAction extends LitElement implements ActionElement { ? "" : this._getDescription(option))} - ${this.reOrderMode + ${this._reorderMode?.active ? html` ${this.hass.localize( "ui.panel.config.automation.editor.actions.re_order" @@ -224,11 +240,15 @@ export class HaChooseAction extends LitElement implements ActionElement { )}: ( option.conditions )} - .reOrderMode=${this.reOrderMode} .disabled=${this.disabled} .hass=${this.hass} .idx=${idx} @@ -240,9 +260,13 @@ export class HaChooseAction extends LitElement implements ActionElement { )}: [ { @@ -60,20 +63,22 @@ export class HaRepeatAction extends LitElement implements ActionElement { name: "count", required: true, selector: template - ? ({ template: {} } as const) - : ({ number: { mode: "box", min: 1 } } as const), + ? { template: {} } + : { number: { mode: "box", min: 1 } }, }, - ] as const) + ] as const satisfies readonly HaFormSchema[]) : []), ...(type === "until" || type === "while" ? ([ { name: type, selector: { - condition: { nested: true, reorder_mode: reOrderMode }, + condition: { + path: [...(path ?? []), "repeat", type], + }, }, }, - ] as const) + ] as const satisfies readonly HaFormSchema[]) : []), ...(type === "for_each" ? ([ @@ -82,13 +87,17 @@ export class HaRepeatAction extends LitElement implements ActionElement { required: true, selector: { object: {} }, }, - ] as const) + ] as const satisfies readonly HaFormSchema[]) : []), { name: "sequence", - selector: { action: { nested: true, reorder_mode: reOrderMode } }, + selector: { + action: { + path: [...(path ?? []), "repeat", "sequence"], + }, + }, }, - ] as const + ] as const satisfies readonly HaFormSchema[] ); protected render() { @@ -97,11 +106,12 @@ export class HaRepeatAction extends LitElement implements ActionElement { const schema = this._schema( this.hass.localize, type ?? "count", - this.reOrderMode, "count" in action && typeof action.count === "string" ? isTemplate(action.count) - : false + : false, + this.path ); + const data = { ...action, type }; return html` `; diff --git a/src/panels/config/automation/blueprint-automation-editor.ts b/src/panels/config/automation/blueprint-automation-editor.ts index c2eedf5136..06bf397334 100644 --- a/src/panels/config/automation/blueprint-automation-editor.ts +++ b/src/panels/config/automation/blueprint-automation-editor.ts @@ -1,15 +1,16 @@ import "@material/mwc-button/mwc-button"; import { HassEntity } from "home-assistant-js-websocket"; -import { css, CSSResultGroup, html, LitElement } from "lit"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../../common/dom/fire_event"; +import { nestedArrayMove } from "../../../common/util/array-move"; +import "../../../components/ha-alert"; import "../../../components/ha-blueprint-picker"; import "../../../components/ha-card"; import "../../../components/ha-circular-progress"; import "../../../components/ha-markdown"; import "../../../components/ha-selector/ha-selector"; import "../../../components/ha-settings-row"; -import "../../../components/ha-alert"; import { BlueprintAutomationConfig } from "../../../data/automation"; import { BlueprintOrError, @@ -17,11 +18,12 @@ import { fetchBlueprints, } from "../../../data/blueprint"; import { haStyle } from "../../../resources/styles"; +import { ReorderModeMixin } from "../../../state/reorder-mode-mixin"; import { HomeAssistant } from "../../../types"; import "../ha-config-section"; @customElement("blueprint-automation-editor") -export class HaBlueprintAutomationEditor extends LitElement { +export class HaBlueprintAutomationEditor extends ReorderModeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean }) public isWide = false; @@ -76,6 +78,7 @@ export class HaBlueprintAutomationEditor extends LitElement { ${this.config.description ? html`

${this.config.description}

` : ""} + ${this._renderReorderModeAlert()} - html` + ([key, value]) => { + const selector = value?.selector ?? { text: undefined }; + const type = Object.keys(selector)[0]; + const enhancedSelector = [ + "action", + "condition", + "trigger", + ].includes(type) + ? { + [type]: { + ...selector[type], + path: [key], + }, + } + : selector; + + return html` ${value?.name || key} ${html``} - ` + `; + } ) : html`

${this.hass.localize( @@ -153,6 +173,34 @@ export class HaBlueprintAutomationEditor extends LitElement { `; } + private _renderReorderModeAlert() { + if (!this._reorderMode.active) { + return nothing; + } + return html` + + ${this.hass.localize( + "ui.panel.config.automation.editor.re_order_mode.description_all" + )} + + ${this.hass.localize( + "ui.panel.config.automation.editor.re_order_mode.exit" + )} + + + `; + } + + private async _exitReOrderMode() { + this._reorderMode.exit(); + } + private async _getBlueprints() { this._blueprints = await fetchBlueprints(this.hass, "automation"); } @@ -197,6 +245,29 @@ export class HaBlueprintAutomationEditor 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, + }, + }, + }); + } + private async _enable(): Promise { if (!this.hass || !this.stateObj) { return; @@ -259,6 +330,10 @@ export class HaBlueprintAutomationEditor extends LitElement { margin-bottom: 16px; display: block; } + ha-alert.re-order { + border-radius: var(--ha-card-border-radius, 12px); + overflow: hidden; + } `, ]; } diff --git a/src/panels/config/automation/condition/ha-automation-condition-editor.ts b/src/panels/config/automation/condition/ha-automation-condition-editor.ts index 81b4eec95b..3a838f03a0 100644 --- a/src/panels/config/automation/condition/ha-automation-condition-editor.ts +++ b/src/panels/config/automation/condition/ha-automation-condition-editor.ts @@ -7,7 +7,7 @@ import "../../../../components/ha-yaml-editor"; import type { Condition } from "../../../../data/automation"; import { expandConditionWithShorthand } from "../../../../data/automation"; import { haStyle } from "../../../../resources/styles"; -import type { HomeAssistant } from "../../../../types"; +import type { HomeAssistant, ItemPath } from "../../../../types"; import "./types/ha-automation-condition-and"; import "./types/ha-automation-condition-device"; import "./types/ha-automation-condition-not"; @@ -30,7 +30,7 @@ export default class HaAutomationConditionEditor extends LitElement { @property({ type: Boolean }) public yamlMode = false; - @property({ type: Boolean }) public reOrderMode = false; + @property() public path?: ItemPath; private _processedCondition = memoizeOne((condition) => expandConditionWithShorthand(condition) @@ -67,8 +67,8 @@ export default class HaAutomationConditionEditor extends LitElement { { hass: this.hass, condition: condition, - reOrderMode: this.reOrderMode, 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 1b51a9d278..a1a07c640f 100644 --- a/src/panels/config/automation/condition/ha-automation-condition-row.ts +++ b/src/panels/config/automation/condition/ha-automation-condition-row.ts @@ -39,8 +39,12 @@ import { showPromptDialog, } from "../../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../../resources/styles"; -import { HomeAssistant } from "../../../../types"; +import { HomeAssistant, ItemPath } from "../../../../types"; import "./ha-automation-condition-editor"; +import { + ReorderMode, + reorderModeContext, +} from "../../../../state/reorder-mode-mixin"; export interface ConditionElement extends LitElement { condition: Condition; @@ -81,10 +85,10 @@ export default class HaAutomationConditionRow extends LitElement { @property({ type: Boolean }) public hideMenu = false; - @property({ type: Boolean }) public reOrderMode = false; - @property({ type: Boolean }) public disabled = false; + @property() public path?: ItemPath; + @storage({ key: "automationClipboard", state: false, @@ -105,10 +109,17 @@ export default class HaAutomationConditionRow extends LitElement { @consume({ context: fullEntitiesContext, subscribe: true }) _entityReg!: EntityRegistryEntry[]; + @state() + @consume({ context: reorderModeContext, subscribe: true }) + private _reorderMode?: ReorderMode; + protected render() { if (!this.condition) { return nothing; } + + const noReorderModeAvailable = this._reorderMode === undefined; + return html` ${this.condition.enabled === false @@ -163,7 +174,12 @@ export default class HaAutomationConditionRow extends LitElement { > - + ${this.hass.localize( "ui.panel.config.automation.editor.conditions.re_order" )} @@ -297,7 +313,7 @@ export default class HaAutomationConditionRow extends LitElement { .disabled=${this.disabled} .hass=${this.hass} .condition=${this.condition} - .reOrderMode=${this.reOrderMode} + .path=${this.path} >
@@ -344,7 +360,7 @@ export default class HaAutomationConditionRow extends LitElement { await this._renameCondition(); break; case 2: - fireEvent(this, "re-order"); + this._reorderMode?.enter(); break; case 3: fireEvent(this, "duplicate"); @@ -547,6 +563,9 @@ export default class HaAutomationConditionRow extends LitElement { mwc-list-item[disabled] { --mdc-theme-text-primary-on-background: var(--disabled-text-color); } + mwc-list-item.hidden { + display: none; + } .testing { position: absolute; top: 0px; diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index c240c2ae6c..e40906cecc 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -1,3 +1,4 @@ +import { consume } from "@lit-labs/context"; import { mdiArrowDown, mdiArrowUp, mdiDrag, mdiPlus } from "@mdi/js"; import deepClone from "deep-clone-simple"; import { @@ -8,10 +9,11 @@ import { html, nothing, } from "lit"; -import { customElement, property } from "lit/decorators"; +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 { nestedArrayMove } from "../../../../common/util/array-move"; import "../../../../components/ha-button"; import "../../../../components/ha-button-menu"; import "../../../../components/ha-sortable"; @@ -20,7 +22,11 @@ import type { AutomationClipboard, Condition, } from "../../../../data/automation"; -import type { HomeAssistant } from "../../../../types"; +import { + ReorderMode, + reorderModeContext, +} from "../../../../state/reorder-mode-mixin"; +import type { HomeAssistant, ItemPath } from "../../../../types"; import { PASTE_VALUE, showAddAutomationElementDialog, @@ -36,9 +42,11 @@ export default class HaAutomationCondition extends LitElement { @property({ type: Boolean }) public disabled = false; - @property({ type: Boolean }) public nested = false; + @property() public path?: ItemPath; - @property({ type: Boolean }) public reOrderMode = false; + @state() + @consume({ context: reorderModeContext, subscribe: true }) + private _reorderMode?: ReorderMode; @storage({ key: "automationClipboard", @@ -89,35 +97,21 @@ export default class HaAutomationCondition extends LitElement { } } + private get nested() { + return this.path !== undefined; + } + protected render() { if (!Array.isArray(this.conditions)) { return nothing; } return html` - ${this.reOrderMode && !this.nested - ? html` - - ${this.hass.localize( - "ui.panel.config.automation.editor.re_order_mode.description_conditions" - )} - - ${this.hass.localize( - "ui.panel.config.automation.editor.re_order_mode.exit" - )} - - - ` - : null} -
${repeat( @@ -125,19 +119,18 @@ export default class HaAutomationCondition extends LitElement { (condition) => this._getKey(condition), (cond, idx) => html` - ${this.reOrderMode + ${this._reorderMode?.active ? html` `; } diff --git a/src/panels/config/automation/manual-automation-editor.ts b/src/panels/config/automation/manual-automation-editor.ts index 9be779d275..e8391ef9b4 100644 --- a/src/panels/config/automation/manual-automation-editor.ts +++ b/src/panels/config/automation/manual-automation-editor.ts @@ -5,6 +5,7 @@ import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { ensureArray } from "../../../common/array/ensure-array"; import { fireEvent } from "../../../common/dom/fire_event"; +import { nestedArrayMove } from "../../../common/util/array-move"; import "../../../components/ha-card"; import "../../../components/ha-icon-button"; import "../../../components/ha-markdown"; @@ -15,6 +16,7 @@ import { } from "../../../data/automation"; import { Action } from "../../../data/script"; import { haStyle } from "../../../resources/styles"; +import { ReorderModeMixin } from "../../../state/reorder-mode-mixin"; import type { HomeAssistant } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; import "./action/ha-automation-action"; @@ -22,7 +24,7 @@ import "./condition/ha-automation-condition"; import "./trigger/ha-automation-trigger"; @customElement("manual-automation-editor") -export class HaManualAutomationEditor extends LitElement { +export class HaManualAutomationEditor extends ReorderModeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean }) public isWide!: boolean; @@ -44,7 +46,7 @@ export class HaManualAutomationEditor extends LitElement { ${this.hass.localize("ui.panel.config.automation.editor.migrate")} ` - : ""} + : nothing} ${this.stateObj?.state === "off" ? html` @@ -92,12 +94,15 @@ export class HaManualAutomationEditor extends LitElement { )}

` : nothing} + ${this._renderReorderModeAlert("triggers")} @@ -132,12 +137,15 @@ export class HaManualAutomationEditor extends LitElement { )}

` : nothing} + ${this._renderReorderModeAlert("conditions")} @@ -170,12 +178,15 @@ export class HaManualAutomationEditor extends LitElement { )}

` : nothing} + ${this._renderReorderModeAlert("actions")} + ${this.hass.localize( + `ui.panel.config.automation.editor.re_order_mode.description_${type}` + )} + + ${this.hass.localize( + "ui.panel.config.automation.editor.re_order_mode.exit" + )} + +
+ `; + } + + private async _exitReOrderMode() { + this._reorderMode.exit(); + } + private _triggerChanged(ev: CustomEvent): void { ev.stopPropagation(); fireEvent(this, "value-changed", { @@ -207,6 +246,21 @@ 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; @@ -258,6 +312,12 @@ export class HaManualAutomationEditor extends LitElement { font-weight: normal; line-height: 0; } + ha-alert.re-order { + display: block; + margin-bottom: 16px; + border-radius: var(--ha-card-border-radius, 12px); + overflow: hidden; + } `, ]; } 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 5a4acb43c6..81bcd5a786 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts @@ -15,7 +15,14 @@ import { mdiStopCircleOutline, } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; -import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit"; +import { + CSSResultGroup, + LitElement, + PropertyValues, + css, + html, + nothing, +} from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { storage } from "../../../../common/decorators/storage"; @@ -44,7 +51,7 @@ import { showPromptDialog, } from "../../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../../resources/styles"; -import type { HomeAssistant } from "../../../../types"; +import type { HomeAssistant, ItemPath } from "../../../../types"; import "./types/ha-automation-trigger-calendar"; import "./types/ha-automation-trigger-device"; import "./types/ha-automation-trigger-event"; @@ -62,6 +69,10 @@ import "./types/ha-automation-trigger-time"; import "./types/ha-automation-trigger-time_pattern"; import "./types/ha-automation-trigger-webhook"; import "./types/ha-automation-trigger-zone"; +import { + ReorderMode, + reorderModeContext, +} from "../../../../state/reorder-mode-mixin"; export interface TriggerElement extends LitElement { trigger: Trigger; @@ -101,6 +112,8 @@ export default class HaAutomationTriggerRow extends LitElement { @property({ type: Boolean }) public disabled = false; + @property() public path?: ItemPath; + @state() private _warnings?: string[]; @state() private _yamlMode = false; @@ -125,9 +138,17 @@ export default class HaAutomationTriggerRow extends LitElement { @consume({ context: fullEntitiesContext, subscribe: true }) _entityReg!: EntityRegistryEntry[]; + @state() + @consume({ context: reorderModeContext, subscribe: true }) + private _reorderMode?: ReorderMode; + private _triggerUnsub?: Promise; protected render() { + if (!this.trigger) return nothing; + + const noReorderModeAvailable = this._reorderMode === undefined; + const supported = customElements.get(`ha-automation-trigger-${this.trigger.platform}`) !== undefined; @@ -181,7 +202,12 @@ export default class HaAutomationTriggerRow extends LitElement { > - + ${this.hass.localize( "ui.panel.config.automation.editor.triggers.re_order" )} @@ -357,6 +383,7 @@ export default class HaAutomationTriggerRow extends LitElement { hass: this.hass, trigger: this.trigger, disabled: this.disabled, + path: this.path, } )}
@@ -470,7 +497,7 @@ export default class HaAutomationTriggerRow extends LitElement { await this._renameTrigger(); break; case 1: - fireEvent(this, "re-order"); + this._reorderMode?.enter(); break; case 2: this._requestShowId = true; @@ -702,6 +729,9 @@ export default class HaAutomationTriggerRow extends LitElement { mwc-list-item[disabled] { --mdc-theme-text-primary-on-background: var(--disabled-text-color); } + mwc-list-item.hidden { + display: none; + } ha-textfield { display: block; margin-bottom: 24px; diff --git a/src/panels/config/automation/trigger/ha-automation-trigger.ts b/src/panels/config/automation/trigger/ha-automation-trigger.ts index 167a9ab253..b4a0119e21 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger.ts @@ -1,16 +1,22 @@ +import { consume } from "@lit-labs/context"; import { mdiArrowDown, mdiArrowUp, mdiDrag, mdiPlus } from "@mdi/js"; import deepClone from "deep-clone-simple"; import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit"; -import { customElement, property } from "lit/decorators"; +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 { nestedArrayMove } from "../../../../common/util/array-move"; import "../../../../components/ha-button"; import "../../../../components/ha-button-menu"; import "../../../../components/ha-sortable"; import "../../../../components/ha-svg-icon"; import { AutomationClipboard, Trigger } from "../../../../data/automation"; -import { HomeAssistant } from "../../../../types"; +import { + ReorderMode, + reorderModeContext, +} from "../../../../state/reorder-mode-mixin"; +import { HomeAssistant, ItemPath } from "../../../../types"; import { PASTE_VALUE, showAddAutomationElementDialog, @@ -26,9 +32,11 @@ export default class HaAutomationTrigger extends LitElement { @property({ type: Boolean }) public disabled = false; - @property({ type: Boolean }) public nested = false; + @property() public path?: ItemPath; - @property({ type: Boolean }) public reOrderMode = false; + @state() + @consume({ context: reorderModeContext, subscribe: true }) + private _reorderMode?: ReorderMode; @storage({ key: "automationClipboard", @@ -42,31 +50,18 @@ export default class HaAutomationTrigger extends LitElement { private _triggerKeys = new WeakMap(); + private get nested() { + return this.path !== undefined; + } + protected render() { return html` - ${this.reOrderMode && !this.nested - ? html` - - ${this.hass.localize( - "ui.panel.config.automation.editor.re_order_mode.description_triggers" - )} - - ${this.hass.localize( - "ui.panel.config.automation.editor.re_order_mode.exit" - )} - - - ` - : null}
${repeat( @@ -74,16 +69,16 @@ export default class HaAutomationTrigger extends LitElement { (trigger) => this._getKey(trigger), (trg, idx) => html` - ${this.reOrderMode + ${this._reorderMode?.active ? html` ` : ""} + ${this._renderReorderModeAlert()} `}
- ${this.config.use_blueprint.path ? blueprint && "error" in blueprint ? html`

@@ -98,8 +100,23 @@ export class HaBlueprintScriptEditor extends LitElement { ${blueprint?.metadata?.input && Object.keys(blueprint.metadata.input).length ? Object.entries(blueprint.metadata.input).map( - ([key, value]) => - html` + ([key, value]) => { + const selector = value?.selector ?? { text: undefined }; + const type = Object.keys(selector)[0]; + const enhancedSelector = [ + "action", + "condition", + "trigger", + ].includes(type) + ? { + [type]: { + ...selector[type], + path: [key], + }, + } + : selector; + + return html` ${value?.name || key} ${html``} - ` + `; + } ) : html`

${this.hass.localize( @@ -132,6 +151,34 @@ export class HaBlueprintScriptEditor extends LitElement { `; } + private _renderReorderModeAlert() { + if (!this._reorderMode.active) { + return nothing; + } + return html` + + ${this.hass.localize( + "ui.panel.config.automation.editor.re_order_mode.description_all" + )} + + ${this.hass.localize( + "ui.panel.config.automation.editor.re_order_mode.exit" + )} + + + `; + } + + private async _exitReOrderMode() { + this._reorderMode.exit(); + } + private async _getBlueprints() { this._blueprints = await fetchBlueprints(this.hass, "script"); } @@ -176,6 +223,29 @@ export class HaBlueprintScriptEditor 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, + }, + }, + }); + } + private _duplicate() { fireEvent(this, "duplicate"); } @@ -229,6 +299,10 @@ export class HaBlueprintScriptEditor extends LitElement { margin-bottom: 16px; display: block; } + ha-alert.re-order { + border-radius: var(--ha-card-border-radius, 12px); + overflow: hidden; + } `, ]; } diff --git a/src/panels/config/script/manual-script-editor.ts b/src/panels/config/script/manual-script-editor.ts index 78f7c1c5cd..1b6f615b63 100644 --- a/src/panels/config/script/manual-script-editor.ts +++ b/src/panels/config/script/manual-script-editor.ts @@ -3,10 +3,12 @@ import { mdiHelpCircle } from "@mdi/js"; import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; import { customElement, property, query } from "lit/decorators"; import { fireEvent } from "../../../common/dom/fire_event"; +import { nestedArrayMove } from "../../../common/util/array-move"; import "../../../components/ha-card"; import "../../../components/ha-icon-button"; import { Action, Fields, ScriptConfig } from "../../../data/script"; import { haStyle } from "../../../resources/styles"; +import { ReorderModeMixin } from "../../../state/reorder-mode-mixin"; import type { HomeAssistant } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; import "../automation/action/ha-automation-action"; @@ -14,7 +16,7 @@ import "./ha-script-fields"; import type HaScriptFields from "./ha-script-fields"; @customElement("manual-script-editor") -export class HaManualScriptEditor extends LitElement { +export class HaManualScriptEditor extends ReorderModeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean }) public isWide!: boolean; @@ -118,11 +120,15 @@ export class HaManualScriptEditor extends LitElement { + ${this._renderReorderModeAlert()} + + ${this.hass.localize( + "ui.panel.config.automation.editor.re_order_mode.description_all" + )} + + ${this.hass.localize( + "ui.panel.config.automation.editor.re_order_mode.exit" + )} + + + `; + } + + private async _exitReOrderMode() { + this._reorderMode.exit(); + } + private _fieldsChanged(ev: CustomEvent): void { ev.stopPropagation(); fireEvent(this, "value-changed", { @@ -144,6 +178,21 @@ 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, + }); + } + private _duplicate() { fireEvent(this, "duplicate"); } @@ -179,6 +228,12 @@ export class HaManualScriptEditor extends LitElement { .header a { color: var(--secondary-text-color); } + ha-alert.re-order { + display: block; + margin-bottom: 16px; + border-radius: var(--ha-card-border-radius, 12px); + overflow: hidden; + } `, ]; } diff --git a/src/state/reorder-mode-mixin.ts b/src/state/reorder-mode-mixin.ts new file mode 100644 index 0000000000..42c13bdb6e --- /dev/null +++ b/src/state/reorder-mode-mixin.ts @@ -0,0 +1,40 @@ +import { ContextProvider, createContext } from "@lit-labs/context"; +import { LitElement } from "lit"; +import { Constructor } from "../types"; + +export type ReorderMode = { + active: boolean; + enter: () => void; + exit: () => void; +}; +export const reorderModeContext = createContext("reorder-mode"); + +export const ReorderModeMixin = >( + superClass: T +) => + class extends superClass { + private _reorderModeProvider = new ContextProvider(this, { + context: reorderModeContext, + initialValue: { + active: false, + enter: () => { + this._reorderModeProvider.setValue({ + ...this._reorderModeProvider.value, + active: true, + }); + this.requestUpdate("_reorderMode"); + }, + exit: () => { + this._reorderModeProvider.setValue({ + ...this._reorderModeProvider.value, + active: false, + }); + this.requestUpdate("_reorderMode"); + }, + }, + }); + + get _reorderMode() { + return this._reorderModeProvider.value; + } + }; diff --git a/src/translations/en.json b/src/translations/en.json index acd757020c..3f17573f9c 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2444,6 +2444,7 @@ "description_triggers": "You are in re-order mode, you can re-order your triggers.", "description_conditions": "You are in re-order mode, you can re-order your conditions.", "description_actions": "You are in re-order mode, you can re-order your actions.", + "description_all": "You are in re-order mode, you can re-order your triggers, conditions and actions.", "exit": "Exit" }, "description": { diff --git a/src/types.ts b/src/types.ts index 7238b5db6d..cb0e1931f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -294,3 +294,5 @@ export type AsyncReturnType any> = T extends ( : never; export type Entries = [keyof T, T[keyof T]][]; + +export type ItemPath = (number | string)[];