1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00

Extract AutomationSortableListMixin to deduplicate automation list components (#29977)

* Initial plan

* Extract shared automation rows logic into AutomationRowsMixin

Create ha-automation-rows-mixin.ts with common list manipulation methods
(moveUp, moveDown, move, itemMoved, itemAdded, itemRemoved, itemChanged,
duplicateItem, insertAfter, handleDragKeydown, stopSortSelection, getKey)
and shared properties (hass, disabled, narrow, optionsInSidebar,
_rowSortSelected, _clipboard).

Refactor ha-automation-trigger, ha-automation-condition,
ha-automation-action, and ha-automation-option to use the mixin,
eliminating significant code duplication.

Co-authored-by: wendevlin <12148533+wendevlin@users.noreply.github.com>

* Address review feedback: rename to AutomationSortableListMixin, remove _ prefix from protected members, throw in setHighlightedItems

- Rename AutomationRowsMixin to AutomationSortableListMixin
- Rename file to ha-automation-sortable-list-mixin.ts
- Remove _ prefix from all protected methods/properties
- Make setHighlightedItems throw Not implemented error
- Update all 4 component files with new references

Co-authored-by: wendevlin <12148533+wendevlin@users.noreply.github.com>

* Add missing this.items = items in itemChanged to avoid UI jump

The original _conditionChanged had this.conditions = conditions before
fireEvent to update local state and avoid UI jumps. This was lost during
the mixin extraction.

Co-authored-by: MindFreeze <5219205+MindFreeze@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wendevlin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: MindFreeze <5219205+MindFreeze@users.noreply.github.com>
This commit is contained in:
Copilot
2026-03-12 09:19:57 +00:00
committed by GitHub
parent a356c153f8
commit 3260c48130
5 changed files with 334 additions and 781 deletions

View File

@@ -3,13 +3,10 @@ import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { customElement, property, queryAll } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { nextRender } from "../../../../common/util/render-status";
import "../../../../components/ha-button";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
@@ -17,57 +14,41 @@ import {
ACTION_BUILDING_BLOCKS,
VIRTUAL_ACTIONS,
} from "../../../../data/action";
import {
getValueFromDynamic,
isDynamic,
type AutomationClipboard,
} from "../../../../data/automation";
import { getValueFromDynamic, isDynamic } from "../../../../data/automation";
import type { Action } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import {
PASTE_VALUE,
showAddAutomationElementDialog,
} from "../show-add-automation-element-dialog";
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
import { automationRowsStyles } from "../styles";
import type HaAutomationActionRow from "./ha-automation-action-row";
import { getAutomationActionType } from "./ha-automation-action-row";
@customElement("ha-automation-action")
export default class HaAutomationAction extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public disabled = false;
export default class HaAutomationAction extends AutomationSortableListMixin<Action>(
LitElement
) {
@property({ type: Boolean }) public root = false;
@property({ attribute: false }) public actions!: Action[];
@property({ attribute: false }) public highlightedActions?: Action[];
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
@state() private _rowSortSelected?: number;
@state()
@storage({
key: "automationClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
public _clipboard?: AutomationClipboard;
@queryAll("ha-automation-action-row")
private _actionRowElements?: HaAutomationActionRow[];
private _focusLastActionOnChange = false;
protected get items(): Action[] {
return this.actions;
}
private _focusActionIndexOnChange?: number;
protected set items(items: Action[]) {
this.actions = items;
}
private _actionKeys = new WeakMap<Action, string>();
protected setHighlightedItems(items: Action[]) {
this.highlightedActions = items;
}
protected render() {
return html`
@@ -77,14 +58,14 @@ export default class HaAutomationAction extends LitElement {
.disabled=${this.disabled}
group="actions"
invert-swap
@item-moved=${this._actionMoved}
@item-added=${this._actionAdded}
@item-removed=${this._actionRemoved}
@item-moved=${this.itemMoved}
@item-added=${this.itemAdded}
@item-removed=${this.itemRemoved}
>
<div class="rows ${!this.optionsInSidebar ? "no-sidebar" : ""}">
${repeat(
this.actions,
(action) => this._getKey(action),
(action) => this.getKey(action),
(action, idx) => html`
<ha-automation-action-row
.root=${this.root}
@@ -95,26 +76,26 @@ export default class HaAutomationAction extends LitElement {
.action=${action}
.narrow=${this.narrow}
.disabled=${this.disabled}
@duplicate=${this._duplicateAction}
@insert-after=${this._insertAfter}
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._actionChanged}
@duplicate=${this.duplicateItem}
@insert-after=${this.insertAfter}
@move-down=${this.moveDown}
@move-up=${this.moveUp}
@value-changed=${this.itemChanged}
.hass=${this.hass}
.highlight=${this.highlightedActions?.includes(action)}
.optionsInSidebar=${this.optionsInSidebar}
.sortSelected=${this._rowSortSelected === idx}
@stop-sort-selection=${this._stopSortSelection}
.sortSelected=${this.rowSortSelected === idx}
@stop-sort-selection=${this.stopSortSelection}
>
${!this.disabled
? html`
<div
tabindex="0"
class="handle ${this._rowSortSelected === idx
class="handle ${this.rowSortSelected === idx
? "active"
: ""}"
slot="icons"
@keydown=${this._handleDragKeydown}
@keydown=${this.handleDragKeydown}
@click=${stopPropagation}
.index=${idx}
>
@@ -150,17 +131,16 @@ export default class HaAutomationAction extends LitElement {
if (
changedProps.has("actions") &&
(this._focusLastActionOnChange ||
this._focusActionIndexOnChange !== undefined)
(this.focusLastItemOnChange || this.focusItemIndexOnChange !== undefined)
) {
const mode = this._focusLastActionOnChange ? "new" : "moved";
const mode = this.focusLastItemOnChange ? "new" : "moved";
const row = this.shadowRoot!.querySelector<HaAutomationActionRow>(
`ha-automation-action-row:${mode === "new" ? "last-of-type" : `nth-of-type(${this._focusActionIndexOnChange! + 1})`}`
`ha-automation-action-row:${mode === "new" ? "last-of-type" : `nth-of-type(${this.focusItemIndexOnChange! + 1})`}`
)!;
this._focusLastActionOnChange = false;
this._focusActionIndexOnChange = undefined;
this.focusLastItemOnChange = false;
this.focusItemIndexOnChange = undefined;
row.updateComplete.then(() => {
// on new condition open the settings in the sidebar, except for building blocks
@@ -234,158 +214,10 @@ export default class HaAutomationAction extends LitElement {
elClass ? { ...elClass.defaultConfig } : { [action]: {} }
);
}
this._focusLastActionOnChange = true;
this.focusLastItemOnChange = true;
fireEvent(this, "value-changed", { value: actions });
};
private _getKey(action: Action) {
if (!this._actionKeys.has(action)) {
this._actionKeys.set(action, Math.random().toString());
}
return this._actionKeys.get(action)!;
}
private async _moveUp(ev) {
ev.stopPropagation();
const index = (ev.target as any).index;
if (!(ev.target as HaAutomationActionRow).first) {
const newIndex = index - 1;
this._move(index, newIndex);
if (this._rowSortSelected === index) {
this._rowSortSelected = newIndex;
}
ev.target.focus();
}
}
private async _moveDown(ev) {
ev.stopPropagation();
const index = (ev.target as any).index;
if (!(ev.target as HaAutomationActionRow).last) {
const newIndex = index + 1;
this._move(index, newIndex);
if (this._rowSortSelected === index) {
this._rowSortSelected = newIndex;
}
ev.target.focus();
}
}
private _move(oldIndex: number, newIndex: number) {
const actions = this.actions.concat();
const item = actions.splice(oldIndex, 1)[0];
actions.splice(newIndex, 0, item);
this.actions = actions;
fireEvent(this, "value-changed", { value: actions });
}
private _actionMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
this._move(oldIndex, newIndex);
}
private async _actionAdded(ev: CustomEvent): Promise<void> {
ev.stopPropagation();
const { index, data } = ev.detail;
const item = ev.detail.item as HaAutomationActionRow;
const selected = item.selected;
let actions = [
...this.actions.slice(0, index),
data,
...this.actions.slice(index),
];
// Add action locally to avoid UI jump
this.actions = actions;
if (selected) {
this._focusActionIndexOnChange = actions.length === 1 ? 0 : index;
}
await nextRender();
if (this.actions !== actions) {
// Ensure action is added even after update
actions = [
...this.actions.slice(0, index),
data,
...this.actions.slice(index),
];
if (selected) {
this._focusActionIndexOnChange = actions.length === 1 ? 0 : index;
}
}
fireEvent(this, "value-changed", { value: actions });
}
private async _actionRemoved(ev: CustomEvent): Promise<void> {
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) {
ev.stopPropagation();
const actions = [...this.actions];
const newValue = ev.detail.value;
const index = (ev.target as any).index;
if (newValue === null) {
actions.splice(index, 1);
} else {
// Store key on new value.
const key = this._getKey(actions[index]);
this._actionKeys.set(newValue, key);
actions[index] = newValue;
}
fireEvent(this, "value-changed", { value: actions });
}
private _duplicateAction(ev: CustomEvent) {
ev.stopPropagation();
const index = (ev.target as any).index;
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.actions.toSpliced(
index + 1,
0,
deepClone(this.actions[index])
),
});
}
private _insertAfter(ev: CustomEvent) {
ev.stopPropagation();
const index = (ev.target as any).index;
const inserted = ensureArray(ev.detail.value);
this.highlightedActions = inserted;
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.actions.toSpliced(index + 1, 0, ...inserted),
});
}
private _handleDragKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._rowSortSelected =
this._rowSortSelected === undefined
? (ev.target as any).index
: undefined;
}
}
private _stopSortSelection() {
this._rowSortSelected = undefined;
}
static styles = automationRowsStyles;
}

View File

@@ -8,18 +8,14 @@ import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { nextRender } from "../../../../common/util/render-status";
import "../../../../components/ha-button";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import {
getValueFromDynamic,
isDynamic,
type AutomationClipboard,
type Condition,
} from "../../../../data/automation";
import type { ConditionDescriptions } from "../../../../data/condition";
@@ -29,58 +25,46 @@ import {
} from "../../../../data/condition";
import { subscribeLabFeature } from "../../../../data/labs";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import {
PASTE_VALUE,
showAddAutomationElementDialog,
} from "../show-add-automation-element-dialog";
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
import { automationRowsStyles } from "../styles";
import "./ha-automation-condition-row";
import type HaAutomationConditionRow from "./ha-automation-condition-row";
@customElement("ha-automation-condition")
export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
export default class HaAutomationCondition extends AutomationSortableListMixin<Condition>(
SubscribeMixin(LitElement)
) {
@property({ attribute: false }) public conditions!: Condition[];
@property({ attribute: false }) public highlightedConditions?: Condition[];
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean }) public root = false;
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
@state() private _rowSortSelected?: number;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@storage({
key: "automationClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
public _clipboard?: AutomationClipboard;
@queryAll("ha-automation-condition-row")
private _conditionRowElements?: HaAutomationConditionRow[];
private _focusLastConditionOnChange = false;
private _focusConditionIndexOnChange?: number;
private _conditionKeys = new WeakMap<Condition, string>();
// @ts-ignore
@state() private _newTriggersAndConditions = false;
private _unsub?: Promise<UnsubscribeFunc>;
// @ts-ignore
@state() private _newTriggersAndConditions = false;
protected get items(): Condition[] {
return this.conditions;
}
protected set items(items: Condition[]) {
this.conditions = items;
}
protected setHighlightedItems(items: Condition[]) {
this.highlightedConditions = items;
}
public disconnectedCallback() {
super.disconnectedCallback();
@@ -155,17 +139,17 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
value: updatedConditions,
});
} else if (
this._focusLastConditionOnChange ||
this._focusConditionIndexOnChange !== undefined
this.focusLastItemOnChange ||
this.focusItemIndexOnChange !== undefined
) {
const mode = this._focusLastConditionOnChange ? "new" : "moved";
const mode = this.focusLastItemOnChange ? "new" : "moved";
const row = this.shadowRoot!.querySelector<HaAutomationConditionRow>(
`ha-automation-condition-row:${mode === "new" ? "last-of-type" : `nth-of-type(${this._focusConditionIndexOnChange! + 1})`}`
`ha-automation-condition-row:${mode === "new" ? "last-of-type" : `nth-of-type(${this.focusItemIndexOnChange! + 1})`}`
)!;
this._focusLastConditionOnChange = false;
this._focusConditionIndexOnChange = undefined;
this.focusLastItemOnChange = false;
this.focusItemIndexOnChange = undefined;
row.updateComplete.then(() => {
// on new condition open the settings in the sidebar, except for building blocks
@@ -217,14 +201,14 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
.disabled=${this.disabled}
group="conditions"
invert-swap
@item-moved=${this._conditionMoved}
@item-added=${this._conditionAdded}
@item-removed=${this._conditionRemoved}
@item-moved=${this.itemMoved}
@item-added=${this.itemAdded}
@item-removed=${this.itemRemoved}
>
<div class="rows ${!this.optionsInSidebar ? "no-sidebar" : ""}">
${repeat(
this.conditions.filter((c) => typeof c === "object"),
(condition) => this._getKey(condition),
(condition) => this.getKey(condition),
(cond, idx) => html`
<ha-automation-condition-row
.root=${this.root}
@@ -237,26 +221,26 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
.conditionDescriptions=${this._conditionDescriptions}
.disabled=${this.disabled}
.narrow=${this.narrow}
@duplicate=${this._duplicateCondition}
@insert-after=${this._insertAfter}
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._conditionChanged}
@duplicate=${this.duplicateItem}
@insert-after=${this.insertAfter}
@move-down=${this.moveDown}
@move-up=${this.moveUp}
@value-changed=${this.itemChanged}
.hass=${this.hass}
.highlight=${this.highlightedConditions?.includes(cond)}
.optionsInSidebar=${this.optionsInSidebar}
.sortSelected=${this._rowSortSelected === idx}
@stop-sort-selection=${this._stopSortSelection}
.sortSelected=${this.rowSortSelected === idx}
@stop-sort-selection=${this.stopSortSelection}
>
${!this.disabled
? html`
<div
tabindex="0"
class="handle ${this._rowSortSelected === idx
class="handle ${this.rowSortSelected === idx
? "active"
: ""}"
slot="icons"
@keydown=${this._handleDragKeydown}
@keydown=${this.handleDragKeydown}
@click=${stopPropagation}
.index=${idx}
>
@@ -321,159 +305,10 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
...(target?.entity_id ? { entity_id: target.entity_id } : {}),
});
}
this._focusLastConditionOnChange = true;
this.focusLastItemOnChange = true;
fireEvent(this, "value-changed", { value: conditions });
};
private _getKey(condition: Condition) {
if (!this._conditionKeys.has(condition)) {
this._conditionKeys.set(condition, Math.random().toString());
}
return this._conditionKeys.get(condition)!;
}
private _moveUp(ev) {
ev.stopPropagation();
const index = (ev.target as any).index;
if (!(ev.target as HaAutomationConditionRow).first) {
const newIndex = index - 1;
this._move(index, newIndex);
if (this._rowSortSelected === index) {
this._rowSortSelected = newIndex;
}
ev.target.focus();
}
}
private _moveDown(ev) {
ev.stopPropagation();
const index = (ev.target as any).index;
if (!(ev.target as HaAutomationConditionRow).last) {
const newIndex = index + 1;
this._move(index, newIndex);
if (this._rowSortSelected === index) {
this._rowSortSelected = newIndex;
}
ev.target.focus();
}
}
private _move(oldIndex: number, newIndex: number) {
const conditions = this.conditions.concat();
const item = conditions.splice(oldIndex, 1)[0];
conditions.splice(newIndex, 0, item);
this.conditions = conditions;
fireEvent(this, "value-changed", { value: conditions });
}
private _conditionMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
this._move(oldIndex, newIndex);
}
private async _conditionAdded(ev: CustomEvent): Promise<void> {
ev.stopPropagation();
const { index, data } = ev.detail;
const item = ev.detail.item as HaAutomationConditionRow;
const selected = item.selected;
let conditions = [
...this.conditions.slice(0, index),
data,
...this.conditions.slice(index),
];
// Add condition locally to avoid UI jump
this.conditions = conditions;
if (selected) {
this._focusConditionIndexOnChange = conditions.length === 1 ? 0 : index;
}
await nextRender();
if (this.conditions !== conditions) {
// Ensure condition is added even after update
conditions = [
...this.conditions.slice(0, index),
data,
...this.conditions.slice(index),
];
if (selected) {
this._focusConditionIndexOnChange = conditions.length === 1 ? 0 : index;
}
}
fireEvent(this, "value-changed", { value: conditions });
}
private async _conditionRemoved(ev: CustomEvent): Promise<void> {
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) {
ev.stopPropagation();
const conditions = [...this.conditions];
const newValue = ev.detail.value;
const index = (ev.target as any).index;
if (newValue === null) {
conditions.splice(index, 1);
} else {
// Store key on new value.
const key = this._getKey(conditions[index]);
this._conditionKeys.set(newValue, key);
conditions[index] = newValue;
}
this.conditions = conditions;
fireEvent(this, "value-changed", { value: conditions });
}
private _duplicateCondition(ev: CustomEvent) {
ev.stopPropagation();
const index = (ev.target as any).index;
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.conditions.toSpliced(
index + 1,
0,
deepClone(this.conditions[index])
),
});
}
private _insertAfter(ev: CustomEvent) {
ev.stopPropagation();
const index = (ev.target as any).index;
const inserted = ensureArray(ev.detail.value);
this.highlightedConditions = inserted;
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.conditions.toSpliced(index + 1, 0, ...inserted),
});
}
private _handleDragKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._rowSortSelected =
this._rowSortSelected === undefined
? (ev.target as any).index
: undefined;
}
}
private _stopSortSelection() {
this._rowSortSelected = undefined;
}
static styles = automationRowsStyles;
}

View File

@@ -0,0 +1,198 @@
import deepClone from "deep-clone-simple";
import type { LitElement } from "lit";
import { property, state } from "lit/decorators";
import { ensureArray } from "../../../common/array/ensure-array";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import { nextRender } from "../../../common/util/render-status";
import type { AutomationClipboard } from "../../../data/automation";
import type { Constructor, HomeAssistant } from "../../../types";
export const AutomationSortableListMixin = <T extends object>(
superClass: Constructor<LitElement>
) => {
class AutomationSortableListClass extends superClass {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
@state() protected rowSortSelected?: number;
@state()
@storage({
key: "automationClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
public _clipboard?: AutomationClipboard;
protected focusLastItemOnChange = false;
protected focusItemIndexOnChange?: number;
private _itemKeys = new WeakMap<T, string>();
protected get items(): T[] {
throw new Error("Not implemented");
}
protected set items(_items: T[]) {
throw new Error("Not implemented");
}
protected getKey(item: T): string {
if (!this._itemKeys.has(item)) {
this._itemKeys.set(item, Math.random().toString());
}
return this._itemKeys.get(item)!;
}
protected moveUp(ev) {
ev.stopPropagation();
const index = (ev.target as any).index;
if (!(ev.target as any).first) {
const newIndex = index - 1;
this.move(index, newIndex);
if (this.rowSortSelected === index) {
this.rowSortSelected = newIndex;
}
ev.target.focus();
}
}
protected moveDown(ev) {
ev.stopPropagation();
const index = (ev.target as any).index;
if (!(ev.target as any).last) {
const newIndex = index + 1;
this.move(index, newIndex);
if (this.rowSortSelected === index) {
this.rowSortSelected = newIndex;
}
ev.target.focus();
}
}
protected move(oldIndex: number, newIndex: number) {
const items = this.items.concat();
const item = items.splice(oldIndex, 1)[0];
items.splice(newIndex, 0, item);
this.items = items;
fireEvent(this, "value-changed", { value: items });
}
protected itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
this.move(oldIndex, newIndex);
}
protected async itemAdded(ev: CustomEvent): Promise<void> {
ev.stopPropagation();
const { index, data } = ev.detail;
const selected = (ev.detail.item as any).selected;
let items = [
...this.items.slice(0, index),
data,
...this.items.slice(index),
];
// Add item locally to avoid UI jump
this.items = items;
if (selected) {
this.focusItemIndexOnChange = items.length === 1 ? 0 : index;
}
await nextRender();
if (this.items !== items) {
// Ensure item is added even after update
items = [
...this.items.slice(0, index),
data,
...this.items.slice(index),
];
if (selected) {
this.focusItemIndexOnChange = items.length === 1 ? 0 : index;
}
}
fireEvent(this, "value-changed", { value: items });
}
protected async itemRemoved(ev: CustomEvent): Promise<void> {
ev.stopPropagation();
const { index } = ev.detail;
const item = this.items[index];
// Remove item locally to avoid UI jump
this.items = this.items.filter((i) => i !== item);
await nextRender();
// Ensure item is removed even after update
const items = this.items.filter((i) => i !== item);
fireEvent(this, "value-changed", { value: items });
}
protected itemChanged(ev: CustomEvent) {
ev.stopPropagation();
const items = [...this.items];
const newValue = ev.detail.value;
const index = (ev.target as any).index;
if (newValue === null) {
items.splice(index, 1);
} else {
// Store key on new value.
const key = this.getKey(items[index]);
this._itemKeys.set(newValue, key);
items[index] = newValue;
}
this.items = items;
fireEvent(this, "value-changed", { value: items });
}
protected duplicateItem(ev: CustomEvent) {
ev.stopPropagation();
const index = (ev.target as any).index;
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.items.toSpliced(index + 1, 0, deepClone(this.items[index])),
});
}
protected insertAfter(ev: CustomEvent) {
ev.stopPropagation();
const index = (ev.target as any).index;
const inserted = ensureArray(ev.detail.value);
this.setHighlightedItems(inserted);
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.items.toSpliced(index + 1, 0, ...inserted),
});
}
protected setHighlightedItems(_items: T[]) {
throw new Error("Not implemented");
}
protected handleDragKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this.rowSortSelected =
this.rowSortSelected === undefined
? (ev.target as any).index
: undefined;
}
}
protected stopSortSelection() {
this.rowSortSelected = undefined;
}
}
return AutomationSortableListClass;
};

View File

@@ -1,58 +1,38 @@
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
import { customElement, property, queryAll } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
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 type { Option } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
import { automationRowsStyles } from "../styles";
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;
export default class HaAutomationOption extends AutomationSortableListMixin<Option>(
LitElement
) {
@property({ attribute: false }) public options!: Option[];
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
@property({ type: Boolean, attribute: "show-default" })
public showDefaultActions = false;
@state() private _rowSortSelected?: number;
@state()
@storage({
key: "automationClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
public _clipboard?: AutomationClipboard;
@queryAll("ha-automation-option-row")
private _optionRowElements?: HaAutomationOptionRow[];
private _focusLastOptionOnChange = false;
protected get items(): Option[] {
return this.options;
}
private _focusOptionIndexOnChange?: number;
private _optionsKeys = new WeakMap<Option, string>();
protected set items(items: Option[]) {
this.options = items;
}
protected render() {
return html`
@@ -62,14 +42,14 @@ export default class HaAutomationOption extends LitElement {
.disabled=${this.disabled}
group="options"
invert-swap
@item-moved=${this._optionMoved}
@item-added=${this._optionAdded}
@item-removed=${this._optionRemoved}
@item-moved=${this.itemMoved}
@item-added=${this.itemAdded}
@item-removed=${this.itemRemoved}
>
<div class="rows ${!this.optionsInSidebar ? "no-sidebar" : ""}">
${repeat(
this.options,
(option) => this._getKey(option),
(option) => this.getKey(option),
(option, idx) => html`
<ha-automation-option-row
.sortableData=${option}
@@ -79,24 +59,24 @@ export default class HaAutomationOption extends LitElement {
.option=${option}
.narrow=${this.narrow}
.disabled=${this.disabled}
@duplicate=${this._duplicateOption}
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._optionChanged}
@duplicate=${this.duplicateItem}
@move-down=${this.moveDown}
@move-up=${this.moveUp}
@value-changed=${this.itemChanged}
.hass=${this.hass}
.optionsInSidebar=${this.optionsInSidebar}
.sortSelected=${this._rowSortSelected === idx}
@stop-sort-selection=${this._stopSortSelection}
.sortSelected=${this.rowSortSelected === idx}
@stop-sort-selection=${this.stopSortSelection}
>
${!this.disabled
? html`
<div
tabindex="0"
class="handle ${this._rowSortSelected === idx
class="handle ${this.rowSortSelected === idx
? "active"
: ""}"
slot="icons"
@keydown=${this._handleDragKeydown}
@keydown=${this.handleDragKeydown}
@click=${stopPropagation}
.index=${idx}
>
@@ -145,17 +125,16 @@ export default class HaAutomationOption extends LitElement {
if (
changedProps.has("options") &&
(this._focusLastOptionOnChange ||
this._focusOptionIndexOnChange !== undefined)
(this.focusLastItemOnChange || this.focusItemIndexOnChange !== undefined)
) {
const mode = this._focusLastOptionOnChange ? "new" : "moved";
const mode = this.focusLastItemOnChange ? "new" : "moved";
const row = this.shadowRoot!.querySelector<HaAutomationOptionRow>(
`ha-automation-option-row:${mode === "new" ? "last-of-type" : `nth-of-type(${this._focusOptionIndexOnChange! + 1})`}`
`ha-automation-option-row:${mode === "new" ? "last-of-type" : `nth-of-type(${this.focusItemIndexOnChange! + 1})`}`
)!;
this._focusLastOptionOnChange = false;
this._focusOptionIndexOnChange = undefined;
this.focusLastItemOnChange = false;
this.focusItemIndexOnChange = undefined;
row.updateComplete.then(() => {
if (this.narrow) {
@@ -188,140 +167,14 @@ export default class HaAutomationOption extends LitElement {
private _addOption = () => {
const options = this.options.concat({ conditions: [], sequence: [] });
this._focusLastOptionOnChange = true;
this.focusLastItemOnChange = 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;
if (!(ev.target as HaAutomationOptionRow).first) {
const newIndex = index - 1;
this._move(index, newIndex);
if (this._rowSortSelected === index) {
this._rowSortSelected = newIndex;
}
ev.target.focus();
}
}
private _moveDown(ev) {
ev.stopPropagation();
const index = (ev.target as any).index;
if (!(ev.target as HaAutomationOptionRow).last) {
const newIndex = index + 1;
this._move(index, newIndex);
if (this._rowSortSelected === index) {
this._rowSortSelected = newIndex;
}
ev.target.focus();
}
}
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<void> {
ev.stopPropagation();
const { index, data } = ev.detail;
const item = ev.detail.item as HaAutomationOptionRow;
const selected = item.selected;
const options = [
...this.options.slice(0, index),
data,
...this.options.slice(index),
];
// Add option locally to avoid UI jump
this.options = options;
if (selected) {
this._focusOptionIndexOnChange = options.length === 1 ? 0 : index;
}
await nextRender();
fireEvent(this, "value-changed", { value: this.options });
}
private async _optionRemoved(ev: CustomEvent): Promise<void> {
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", {
// @ts-expect-error Requires library bump to ES2023
value: this.options.toSpliced(
index + 1,
0,
deepClone(this.options[index])
),
});
}
private _showDefaultActions = () => {
fireEvent(this, "show-default-actions");
};
private _handleDragKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._rowSortSelected =
this._rowSortSelected === undefined
? (ev.target as any).index
: undefined;
}
}
private _stopSortSelection() {
this._rowSortSelected = undefined;
}
static styles = [
automationRowsStyles,
css`

View File

@@ -8,18 +8,14 @@ import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { nextRender } from "../../../../common/util/render-status";
import "../../../../components/ha-button";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import {
getValueFromDynamic,
isDynamic,
type AutomationClipboard,
type Trigger,
type TriggerList,
} from "../../../../data/automation";
@@ -27,56 +23,44 @@ import { subscribeLabFeature } from "../../../../data/labs";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import {
PASTE_VALUE,
showAddAutomationElementDialog,
} from "../show-add-automation-element-dialog";
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
import { automationRowsStyles } from "../styles";
import "./ha-automation-trigger-row";
import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
@customElement("ha-automation-trigger")
export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
export default class HaAutomationTrigger extends AutomationSortableListMixin<Trigger>(
SubscribeMixin(LitElement)
) {
@property({ attribute: false }) public triggers!: Trigger[];
@property({ attribute: false }) public highlightedTriggers?: Trigger[];
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
@property({ type: Boolean }) public root = false;
@state() private _rowSortSelected?: number;
@state()
@storage({
key: "automationClipboard",
state: true,
subscribe: true,
storage: "sessionStorage",
})
public _clipboard?: AutomationClipboard;
private _focusLastTriggerOnChange = false;
private _focusTriggerIndexOnChange?: number;
private _triggerKeys = new WeakMap<Trigger, string>();
private _unsub?: Promise<UnsubscribeFunc>;
@state() private _triggerDescriptions: TriggerDescriptions = {};
// @ts-ignore
@state() private _newTriggersAndConditions = false;
private _unsub?: Promise<UnsubscribeFunc>;
protected get items(): Trigger[] {
return this.triggers;
}
protected set items(items: Trigger[]) {
this.triggers = items;
}
protected setHighlightedItems(items: Trigger[]) {
this.highlightedTriggers = items;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe();
@@ -133,14 +117,14 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
.disabled=${this.disabled}
group="triggers"
invert-swap
@item-moved=${this._triggerMoved}
@item-added=${this._triggerAdded}
@item-removed=${this._triggerRemoved}
@item-moved=${this.itemMoved}
@item-added=${this.itemAdded}
@item-removed=${this.itemRemoved}
>
<div class="rows ${!this.optionsInSidebar ? "no-sidebar" : ""}">
${repeat(
this.triggers,
(trigger) => this._getKey(trigger),
(trigger) => this.getKey(trigger),
(trg, idx) => html`
<ha-automation-trigger-row
.sortableData=${trg}
@@ -149,28 +133,28 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
.last=${idx === this.triggers.length - 1}
.trigger=${trg}
.triggerDescriptions=${this._triggerDescriptions}
@duplicate=${this._duplicateTrigger}
@insert-after=${this._insertAfter}
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._triggerChanged}
@duplicate=${this.duplicateItem}
@insert-after=${this.insertAfter}
@move-down=${this.moveDown}
@move-up=${this.moveUp}
@value-changed=${this.itemChanged}
.hass=${this.hass}
.disabled=${this.disabled}
.narrow=${this.narrow}
.highlight=${this.highlightedTriggers?.includes(trg)}
.optionsInSidebar=${this.optionsInSidebar}
.sortSelected=${this._rowSortSelected === idx}
@stop-sort-selection=${this._stopSortSelection}
.sortSelected=${this.rowSortSelected === idx}
@stop-sort-selection=${this.stopSortSelection}
>
${!this.disabled
? html`
<div
tabindex="0"
class="handle ${this._rowSortSelected === idx
class="handle ${this.rowSortSelected === idx
? "active"
: ""}"
slot="icons"
@keydown=${this._handleDragKeydown}
@keydown=${this.handleDragKeydown}
@click=${stopPropagation}
.index=${idx}
>
@@ -237,7 +221,7 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
...(target?.entity_id ? { entity_id: target.entity_id } : {}),
});
}
this._focusLastTriggerOnChange = true;
this.focusLastItemOnChange = true;
fireEvent(this, "value-changed", { value: triggers });
};
@@ -246,15 +230,14 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
if (
changedProps.has("triggers") &&
(this._focusLastTriggerOnChange ||
this._focusTriggerIndexOnChange !== undefined)
(this.focusLastItemOnChange || this.focusItemIndexOnChange !== undefined)
) {
const row = this.shadowRoot!.querySelector<HaAutomationTriggerRow>(
`ha-automation-trigger-row:${this._focusLastTriggerOnChange ? "last-of-type" : `nth-of-type(${this._focusTriggerIndexOnChange! + 1})`}`
`ha-automation-trigger-row:${this.focusLastItemOnChange ? "last-of-type" : `nth-of-type(${this.focusItemIndexOnChange! + 1})`}`
)!;
this._focusLastTriggerOnChange = false;
this._focusTriggerIndexOnChange = undefined;
this.focusLastItemOnChange = false;
this.focusItemIndexOnChange = undefined;
row.updateComplete.then(() => {
if (this.optionsInSidebar) {
@@ -283,154 +266,6 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
});
}
private _getKey(action: Trigger) {
if (!this._triggerKeys.has(action)) {
this._triggerKeys.set(action, Math.random().toString());
}
return this._triggerKeys.get(action)!;
}
private _moveUp(ev) {
ev.stopPropagation();
const index = (ev.target as any).index;
if (!(ev.target as HaAutomationTriggerRow).first) {
const newIndex = index - 1;
this._move(index, newIndex);
if (this._rowSortSelected === index) {
this._rowSortSelected = newIndex;
}
ev.target.focus();
}
}
private _moveDown(ev) {
ev.stopPropagation();
const index = (ev.target as any).index;
if (!(ev.target as HaAutomationTriggerRow).last) {
const newIndex = index + 1;
this._move(index, newIndex);
if (this._rowSortSelected === index) {
this._rowSortSelected = newIndex;
}
ev.target.focus();
}
}
private _move(oldIndex: number, newIndex: number) {
const triggers = this.triggers.concat();
const item = triggers.splice(oldIndex, 1)[0];
triggers.splice(newIndex, 0, item);
this.triggers = triggers;
fireEvent(this, "value-changed", { value: triggers });
}
private _triggerMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
this._move(oldIndex, newIndex);
}
private async _triggerAdded(ev: CustomEvent): Promise<void> {
ev.stopPropagation();
const { index, data } = ev.detail;
const item = ev.detail.item as HaAutomationTriggerRow;
const selected = item.selected;
let triggers = [
...this.triggers.slice(0, index),
data,
...this.triggers.slice(index),
];
// Add trigger locally to avoid UI jump
this.triggers = triggers;
if (selected) {
this._focusTriggerIndexOnChange = triggers.length === 1 ? 0 : index;
}
await nextRender();
if (this.triggers !== triggers) {
// Ensure trigger is added even after update
triggers = [
...this.triggers.slice(0, index),
data,
...this.triggers.slice(index),
];
if (selected) {
this._focusTriggerIndexOnChange = triggers.length === 1 ? 0 : index;
}
}
fireEvent(this, "value-changed", { value: triggers });
}
private async _triggerRemoved(ev: CustomEvent): Promise<void> {
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) {
ev.stopPropagation();
const triggers = [...this.triggers];
const newValue = ev.detail.value;
const index = (ev.target as any).index;
if (newValue === null) {
triggers.splice(index, 1);
} else {
// Store key on new value.
const key = this._getKey(triggers[index]);
this._triggerKeys.set(newValue, key);
triggers[index] = newValue;
}
fireEvent(this, "value-changed", { value: triggers });
}
private _duplicateTrigger(ev: CustomEvent) {
ev.stopPropagation();
const index = (ev.target as any).index;
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.triggers.toSpliced(
index + 1,
0,
deepClone(this.triggers[index])
),
});
}
private _insertAfter(ev: CustomEvent) {
ev.stopPropagation();
const index = (ev.target as any).index;
const inserted = ensureArray(ev.detail.value);
this.highlightedTriggers = inserted;
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.triggers.toSpliced(index + 1, 0, ...inserted),
});
}
private _handleDragKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._rowSortSelected =
this._rowSortSelected === undefined
? (ev.target as any).index
: undefined;
}
}
private _stopSortSelection() {
this._rowSortSelected = undefined;
}
static styles = automationRowsStyles;
}