Handle automation and dashboard drag and drop at the element level (#22300)

* Handle drag and drop at action, condition, trigger level

* Clean item path

* Clean item path

* Fix selectors

* Clean selector config

* Remove enhancedSelector

* Add option row component

* Fix DnD inside option sequence or condition

* Add comments

* Remove item path logic from the dashboard too

* Fix floor/area drag and drop

* Avoid UI jump in area dashboard

* Remove unused import

* Add comment
This commit is contained in:
Paul Bottein 2024-10-30 09:44:38 +01:00 committed by GitHub
parent 51f89b00c1
commit bc11c0b3ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 909 additions and 907 deletions

View File

@ -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<A>(
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<T = any>(
array: T[],
oldIndex: number,
newIndex: number
): T[] {
const newArray = [...array];
const [item] = newArray.splice(oldIndex, 1);
newArray.splice(newIndex, 0, item);
return newArray;
}

View File

@ -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}
></ha-automation-action>
`;
}

View File

@ -24,7 +24,6 @@ export class HaConditionSelector extends LitElement {
.disabled=${this.disabled}
.conditions=${this.value || []}
.hass=${this.hass}
.path=${this.selector.condition?.path}
></ha-automation-condition>
`;
}

View File

@ -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}
></ha-automation-trigger>
`;
}

View File

@ -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}
></ha-selector>
</ha-settings-row>`
: "";
@ -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) {

View File

@ -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 = () => {

View File

@ -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 ||

View File

@ -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[];
}

View File

@ -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 {

View File

@ -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<this>): 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 {
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-moved=${this._areaMoved}
@item-added=${this._areaAdded}
group="floor"
.options=${SORT_OPTIONS}
.path=${[floor.floor_id]}
.floor=${floor.floor_id}
>
<div class="areas">
${floor.areas.map((area) => this._renderArea(area))}
@ -194,7 +207,7 @@ export class HaConfigAreasDashboard extends LitElement {
</ha-sortable>
</div>`
)}
${areasAndFloors?.unassisgnedAreas.length
${areasAndFloors?.unassignedAreas.length
? html`<div class="floor">
<div class="header">
<h2>
@ -206,13 +219,13 @@ export class HaConfigAreasDashboard extends LitElement {
<ha-sortable
handle-selector="a"
draggable-selector="a"
@item-moved=${this._areaMoved}
@item-added=${this._areaAdded}
group="floor"
.options=${SORT_OPTIONS}
.path=${UNASSIGNED_PATH}
.floor=${UNASSIGNED_FLOOR}
>
<div class="areas">
${areasAndFloors?.unassisgnedAreas.map((area) =>
${areasAndFloors?.unassignedAreas.map((area) =>
this._renderArea(area)
)}
</div>
@ -246,7 +259,10 @@ export class HaConfigAreasDashboard extends LitElement {
}
private _renderArea(area) {
return html`<a href=${`/config/areas/area/${area.area_id}`}>
return html`<a
href=${`/config/areas/area/${area.area_id}`}
.sortableData=${area}
>
<ha-card outlined>
<div
style=${styleMap({
@ -309,26 +325,23 @@ export class HaConfigAreasDashboard extends LitElement {
});
}
private async _areaMoved(ev) {
const areasAndFloors = this._processAreas(
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.hass.floors
);
let area: AreaRegistryEntry;
if (ev.detail.oldPath === UNASSIGNED_PATH) {
area = areasAndFloors.unassisgnedAreas[ev.detail.oldIndex];
} else {
const oldFloor = areasAndFloors.floors!.find(
(floor) => 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<AreaRegistryEntry>((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,
});
}

View File

@ -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,
})}
</div>
`}

View File

@ -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`
<ha-sortable
handle-selector=".handle"
draggable-selector="ha-automation-action-row"
.disabled=${!this._showReorder || this.disabled}
@item-moved=${this._actionMoved}
group="actions"
.path=${this.path}
invert-swap
@item-moved=${this._actionMoved}
@item-added=${this._actionAdded}
@item-removed=${this._actionRemoved}
>
<div class="actions">
${repeat(
@ -90,7 +85,7 @@ export default class HaAutomationAction extends LitElement {
(action) => this._getKey(action),
(action, idx) => html`
<ha-automation-action-row
.path=${[...(this.path ?? []), idx]}
.sortableData=${action}
.index=${idx}
.first=${idx === 0}
.last=${idx === this.actions.length - 1}
@ -225,28 +220,44 @@ export default class HaAutomationAction extends LitElement {
this._move(index, newIndex);
}
private _move(
oldIndex: number,
newIndex: number,
oldPath?: ItemPath,
newPath?: ItemPath
) {
const actions = nestedArrayMove(
this.actions,
oldIndex,
newIndex,
oldPath,
newPath
);
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 {
if (this.nested) return;
ev.stopPropagation();
const { oldIndex, newIndex, oldPath, newPath } = ev.detail;
this._move(oldIndex, newIndex, oldPath, newPath);
const { oldIndex, newIndex } = ev.detail;
this._move(oldIndex, newIndex);
}
private async _actionAdded(ev: CustomEvent): Promise<void> {
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<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) {

View File

@ -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;
const options = action.choose ? ensureArray(action.choose) : [];
return html`
<ha-sortable
handle-selector=".handle"
draggable-selector=".option"
.disabled=${!this._showReorder || this.disabled}
group="choose-options"
.path=${[...(this.path ?? []), "choose"]}
invert-swap
>
<div class="options">
${repeat(
action.choose ? ensureArray(action.choose) : [],
(option) => option,
(option, idx) => html`
<div class="option">
<ha-card>
<ha-expansion-panel
.index=${idx}
leftChevron
@expanded-changed=${this._expandedChanged}
>
<h3 slot="header">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: idx + 1 }
)}:
${option.alias ||
(this._expandedStates[idx]
? ""
: this._getDescription(option))}
</h3>
${this._showReorder && !this.disabled
? html`
<div class="handle" slot="icons">
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
</div>
`
: nothing}
<ha-button-menu
slot="icons"
.idx=${idx}
@action=${this._handleAction}
@click=${preventDefault}
@closed=${stopPropagation}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.rename"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiRenameBox}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item
graphic="icon"
.disabled=${this.disabled || idx === 0}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_up"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiArrowUp}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item
graphic="icon"
.disabled=${this.disabled ||
idx === ensureArray(this.action.choose).length - 1}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiArrowDown}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item
class="warning"
graphic="icon"
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.remove_option"
)}
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</mwc-list-item>
</ha-button-menu>
<div class="card-content">
<h4>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.conditions"
)}:
</h4>
<ha-automation-condition
.path=${[
...(this.path ?? []),
"choose",
idx,
"conditions",
]}
.conditions=${ensureArray<string | Condition>(
option.conditions
)}
.disabled=${this.disabled}
.hass=${this.hass}
.idx=${idx}
@value-changed=${this._conditionChanged}
></ha-automation-condition>
<h4>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.sequence"
)}:
</h4>
<ha-automation-action
.path=${[
...(this.path ?? []),
"choose",
idx,
"sequence",
]}
.actions=${ensureArray(option.sequence) || []}
.disabled=${this.disabled}
.hass=${this.hass}
.idx=${idx}
@value-changed=${this._actionChanged}
></ha-automation-action>
</div>
</ha-expansion-panel>
</ha-card>
</div>
`
)}
<div class="buttons">
<ha-button
outlined
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.add_option"
)}
.disabled=${this.disabled}
@click=${this._addOption}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
</div>
</div>
</ha-sortable>
<ha-automation-option
.options=${options}
.disabled=${this.disabled}
@value-changed=${this._optionsChanged}
.hass=${this.hass}
></ha-automation-option>
${this._showDefault || action.default
? html`
@ -303,190 +44,39 @@ export class HaChooseAction extends LitElement implements ActionElement {
)}:
</h2>
<ha-automation-action
.path=${[...(this.path ?? []), "default"]}
.actions=${ensureArray(action.default) || []}
.disabled=${this.disabled}
@value-changed=${this._defaultChanged}
.hass=${this.hass}
></ha-automation-action>
`
: html`<div class="link-button-row">
<button
class="link"
@click=${this._addDefault}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.add_default"
)}
</button>
</div>`}
: html`
<div class="link-button-row">
<button
class="link"
@click=${this._addDefault}
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.add_default"
)}
</button>
</div>
`}
`;
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
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<ActionDetail>): Promise<void> {
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;
}
`,
];
}

View File

@ -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 {
)}*:
</h3>
<ha-automation-condition
.path=${[...(this.path ?? []), "if"]}
.conditions=${action.if}
.disabled=${this.disabled}
@value-changed=${this._ifChanged}
@ -51,7 +48,6 @@ export class HaIfAction extends LitElement implements ActionElement {
)}*:
</h3>
<ha-automation-action
.path=${[...(this.path ?? []), "then"]}
.actions=${action.then}
.disabled=${this.disabled}
@value-changed=${this._thenChanged}
@ -65,7 +61,6 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h3>
<ha-automation-action
.path=${[...(this.path ?? []), "else"]}
.actions=${action.else || []}
.disabled=${this.disabled}
@value-changed=${this._elseChanged}

View File

@ -4,7 +4,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-textfield";
import { Action, ParallelAction } 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";
@ -14,8 +14,6 @@ export class HaParallelAction extends LitElement implements ActionElement {
@property({ type: Boolean }) public disabled = false;
@property({ attribute: false }) public path?: ItemPath;
@property({ attribute: false }) public action!: ParallelAction;
public static get defaultConfig(): ParallelAction {
@ -29,7 +27,6 @@ export class HaParallelAction extends LitElement implements ActionElement {
return html`
<ha-automation-action
.path=${[...(this.path ?? []), "parallel"]}
.actions=${action.parallel}
.disabled=${this.disabled}
@value-changed=${this._actionsChanged}

View File

@ -5,7 +5,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-textfield";
import { RepeatAction } 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";
@ -29,19 +29,12 @@ export class HaRepeatAction extends LitElement implements ActionElement {
@property({ attribute: false }) public action!: RepeatAction;
@property({ type: Array }) public path?: ItemPath;
public static get defaultConfig(): RepeatAction {
return { repeat: { count: 2, sequence: [] } };
}
private _schema = memoizeOne(
(
localize: LocalizeFunc,
type: string,
template: boolean,
path?: ItemPath
) =>
(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 };

View File

@ -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`
<ha-automation-action
.path=${this._getMemoizedPath(this.path)}
.actions=${action.sequence}
.disabled=${this.disabled}
@value-changed=${this._actionsChanged}

View File

@ -8,7 +8,7 @@ import "../../../../../components/ha-duration-input";
import "../../../../../components/ha-formfield";
import "../../../../../components/ha-textfield";
import { WaitForTriggerAction } from "../../../../../data/script";
import { HomeAssistant, ItemPath } from "../../../../../types";
import { HomeAssistant } from "../../../../../types";
import "../../trigger/ha-automation-trigger";
import { ActionElement, handleChangeEvent } from "../ha-automation-action-row";
@ -23,8 +23,6 @@ export class HaWaitForTriggerAction
@property({ type: Boolean }) public disabled = false;
@property({ attribute: false }) public path?: ItemPath;
public static get defaultConfig(): WaitForTriggerAction {
return { wait_for_trigger: [] };
}
@ -55,7 +53,6 @@ export class HaWaitForTriggerAction
></ha-switch>
</ha-formfield>
<ha-automation-trigger
.path=${[...(this.path ?? []), "wait_for_trigger"]}
.triggers=${ensureArray(this.action.wait_for_trigger)}
.hass=${this.hass}
.disabled=${this.disabled}

View File

@ -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, ItemPath } from "../../../../types";
import type { HomeAssistant } from "../../../../types";
import "./types/ha-automation-condition-and";
import "./types/ha-automation-condition-device";
import "./types/ha-automation-condition-not";
@ -30,8 +30,6 @@ export default class HaAutomationConditionEditor extends LitElement {
@property({ type: Boolean }) public yamlMode = false;
@property({ type: Array }) public path?: ItemPath;
private _processedCondition = memoizeOne((condition) =>
expandConditionWithShorthand(condition)
);
@ -68,7 +66,6 @@ export default class HaAutomationConditionEditor extends LitElement {
hass: this.hass,
condition: condition,
disabled: this.disabled,
path: this.path,
}
)}
</div>

View File

@ -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}
></ha-automation-condition-editor>
</div>
</ha-expansion-panel>

View File

@ -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}
>
<div class="conditions">
${repeat(
@ -139,7 +134,7 @@ export default class HaAutomationCondition extends LitElement {
(condition) => this._getKey(condition),
(cond, idx) => html`
<ha-automation-condition-row
.path=${[...(this.path ?? []), idx]}
.sortableData=${cond}
.index=${idx}
.first=${idx === 0}
.last=${idx === this.conditions.length - 1}
@ -248,28 +243,44 @@ export default class HaAutomationCondition extends LitElement {
this._move(index, newIndex);
}
private _move(
oldIndex: number,
newIndex: number,
oldPath?: ItemPath,
newPath?: ItemPath
) {
const conditions = nestedArrayMove(
this.conditions,
oldIndex,
newIndex,
oldPath,
newPath
);
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 {
if (this.nested) return;
ev.stopPropagation();
const { oldIndex, newIndex, oldPath, newPath } = ev.detail;
this._move(oldIndex, newIndex, oldPath, newPath);
const { oldIndex, newIndex } = ev.detail;
this._move(oldIndex, newIndex);
}
private async _conditionAdded(ev: CustomEvent): Promise<void> {
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<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) {

View File

@ -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`
<ha-automation-condition
.path=${[...(this.path ?? []), "conditions"]}
.conditions=${this.condition.conditions || []}
@value-changed=${this._valueChanged}
.hass=${this.hass}

View File

@ -12,7 +12,6 @@ import {
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";
@ -132,7 +131,6 @@ export class HaManualAutomationEditor extends LitElement {
.triggers=${this.config.triggers || []}
.path=${["triggers"]}
@value-changed=${this._triggerChanged}
@item-moved=${this._itemMoved}
.hass=${this.hass}
.disabled=${this.disabled}
></ha-automation-trigger>
@ -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}
></ha-automation-condition>
@ -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<void> {
if (!this.hass || !this.stateObj) {
return;

View File

@ -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<Condition | string>(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`
<ha-card outlined>
<ha-expansion-panel
leftChevron
@expanded-changed=${this._expandedChanged}
id="option"
>
<h3 slot="header">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option",
{ number: this.index + 1 }
)}:
${this.option.alias ||
(this._expanded ? "" : this._getDescription())}
</h3>
<slot name="icons" slot="icons"></slot>
<ha-button-menu
slot="icons"
@action=${this._handleAction}
@click=${preventDefault}
@closed=${stopPropagation}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.rename"
)}
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</mwc-list-item>
<mwc-list-item graphic="icon" .disabled=${this.disabled}>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item
graphic="icon"
.disabled=${this.disabled || this.first}
>
${this.hass.localize("ui.panel.config.automation.editor.move_up")}
<ha-svg-icon slot="graphic" .path=${mdiArrowUp}></ha-svg-icon>
</mwc-list-item>
<mwc-list-item
graphic="icon"
.disabled=${this.disabled || this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="graphic" .path=${mdiArrowDown}></ha-svg-icon>
</mwc-list-item>
<mwc-list-item
class="warning"
graphic="icon"
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.remove_option"
)}
<ha-svg-icon
class="warning"
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</mwc-list-item>
</ha-button-menu>
<div class="card-content">
<h4>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.conditions"
)}:
</h4>
<ha-automation-condition
.conditions=${ensureArray<string | Condition>(
this.option.conditions
)}
.disabled=${this.disabled}
.hass=${this.hass}
@value-changed=${this._conditionChanged}
></ha-automation-condition>
<h4>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.sequence"
)}:
</h4>
<ha-automation-action
.actions=${ensureArray(this.option.sequence) || []}
.disabled=${this.disabled}
.hass=${this.hass}
@value-changed=${this._actionChanged}
></ha-automation-action>
</div>
</ha-expansion-panel>
</ha-card>
`;
}
private async _handleAction(ev: CustomEvent<ActionDetail>) {
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<void> {
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;
}
}

View File

@ -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<Option, string>();
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`
<ha-sortable
handle-selector=".handle"
draggable-selector="ha-automation-option-row"
.disabled=${!this._showReorder || this.disabled}
group="options"
invert-swap
@item-moved=${this._optionMoved}
@item-added=${this._optionAdded}
@item-removed=${this._optionRemoved}
>
<div class="options">
${repeat(
this.options,
(option) => this._getKey(option),
(option, idx) => html`
<ha-automation-option-row
.sortableData=${option}
.index=${idx}
.first=${idx === 0}
.last=${idx === this.options.length - 1}
.option=${option}
.narrow=${this.narrow}
.disabled=${this.disabled}
@duplicate=${this._duplicateOption}
@move-down=${this._moveDown}
@move-up=${this._moveUp}
@value-changed=${this._optionChanged}
.hass=${this.hass}
>
${this._showReorder && !this.disabled
? html`
<div class="handle" slot="icons">
<ha-svg-icon .path=${mdiDrag}></ha-svg-icon>
</div>
`
: nothing}
</ha-automation-option-row>
`
)}
<div class="buttons">
<ha-button
outlined
.disabled=${this.disabled}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.add_option"
)}
@click=${this._addOption}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
</div>
</div>
</ha-sortable>
`;
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("options") && this._focusLastOptionOnChange) {
this._focusLastOptionOnChange = false;
const row = this.shadowRoot!.querySelector<HaAutomationOptionRow>(
"ha-automation-option-row:last-of-type"
)!;
row.updateComplete.then(() => {
row.expand();
row.scrollIntoView();
row.focus();
});
}
}
public expandAll() {
const rows = this.shadowRoot!.querySelectorAll<HaAutomationOptionRow>(
"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<void> {
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<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", {
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;
}
}

View File

@ -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,
})}
</div>
`}

View File

@ -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`
<ha-sortable
handle-selector=".handle"
draggable-selector="ha-automation-trigger-row"
.disabled=${!this._showReorder || this.disabled}
@item-moved=${this._triggerMoved}
group="triggers"
.path=${this.path}
invert-swap
@item-moved=${this._triggerMoved}
@item-added=${this._triggerAdded}
@item-removed=${this._triggerRemoved}
>
<div class="triggers">
${repeat(
@ -92,7 +87,7 @@ export default class HaAutomationTrigger extends LitElement {
(trigger) => this._getKey(trigger),
(trg, idx) => html`
<ha-automation-trigger-row
.path=${[...(this.path ?? []), idx]}
.sortableData=${trg}
.index=${idx}
.first=${idx === 0}
.last=${idx === this.triggers.length - 1}
@ -210,28 +205,44 @@ export default class HaAutomationTrigger extends LitElement {
this._move(index, newIndex);
}
private _move(
oldIndex: number,
newIndex: number,
oldPath?: ItemPath,
newPath?: ItemPath
) {
const triggers = nestedArrayMove(
this.triggers,
oldIndex,
newIndex,
oldPath,
newPath
);
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 {
if (this.nested) return;
ev.stopPropagation();
const { oldIndex, newIndex, oldPath, newPath } = ev.detail;
this._move(oldIndex, newIndex, oldPath, newPath);
const { oldIndex, newIndex } = ev.detail;
this._move(oldIndex, newIndex);
}
private async _triggerAdded(ev: CustomEvent): Promise<void> {
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<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) {

View File

@ -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`
<ha-automation-trigger
.path=${[...(this.path ?? []), "triggers"]}
.triggers=${triggers}
.hass=${this.hass}
.disabled=${this.disabled}

View File

@ -2,7 +2,6 @@ import "@material/mwc-button/mwc-button";
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-blueprint-picker";
import "../../../components/ha-card";
import "../../../components/ha-circular-progress";
@ -158,15 +157,6 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
border: boolean
) {
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`<ha-settings-row
.narrow=${this.narrow}
class=${border ? "border" : ""}
@ -180,7 +170,7 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
></ha-markdown>
${html`<ha-selector
.hass=${this.hass}
.selector=${enhancedSelector}
.selector=${selector}
.key=${key}
.disabled=${this.disabled}
.required=${value?.default === undefined}
@ -190,7 +180,6 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
? this._config.use_blueprint.input[key]
: value?.default}
@value-changed=${this._inputChanged}
@item-moved=${this._itemMoved}
></ha-selector>`}
</ha-settings-row>`;
}
@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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`
<ha-sortable
.disabled=${!editMode}
@item-moved=${this._cardMoved}
@drag-start=${this._dragStart}
@drag-end=${this._dragEnd}
group="card"
draggable-selector=".card"
.path=${[this.viewIndex, this.index]}
.rollback=${false}
.options=${CARD_SORTABLE_OPTIONS}
@item-moved=${this._cardMoved}
@item-added=${this._cardAdded}
@item-removed=${this._cardRemoved}
invert-swap
>
<div class="container ${classMap({ "edit-mode": editMode })}">
@ -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`
<div
style=${styleMap({
@ -100,13 +107,14 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
"fit-rows": typeof layoutOptions?.grid_rows === "number",
"full-width": columns === "full",
})}"
.sortableData=${cardPath}
>
${editMode
? html`
<hui-card-edit-mode
.hass=${this.hass}
.lovelace=${this.lovelace}
.path=${[this.viewIndex, this.index, idx]}
.lovelace=${this.lovelace!}
.path=${cardPath}
.hiddenOverlay=${this._dragging}
>
${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;
}

View File

@ -175,8 +175,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
const rowSpan = sectionConfig?.row_span || 1;
(section as any).itemPath = [idx];
return html`
<div
class="section"

View File

@ -307,5 +307,3 @@ export type AsyncReturnType<T extends (...args: any) => any> = T extends (
: never;
export type Entries<T> = [keyof T, T[keyof T]][];
export type ItemPath = (number | string)[];