Compare commits

...
Author SHA1 Message Date
Wendelin 9bcdaecb77 feat: enhance automation editor with path management and undo/redo support
- Introduced AutomationPathController to manage paths in automation configurations.
- Updated ha-automation-sortable-list-mixin to include pathKey property for better path handling.
- Enhanced ManualEditorMixin to track sidebar row changes and manage undo/redo operations.
- Integrated pathKey attributes in automation trigger, condition, and action components for improved path tracking.
- Implemented ConfigChangeUndoRedoController to manage undo/redo functionality for script and automation configurations.
- Added tests for config change utilities and automation path management to ensure reliability and correctness.
2026-10-29 14:21:30 +01:00
30 changed files with 1917 additions and 18 deletions

No files matched your search

@@ -0,0 +1,312 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import {
applyConfigChanges,
diffConfigs,
invertConfigChanges,
type ConfigChange,
} from "../util/config-change";
import { pathStartsWith, type ConfigPath } from "../util/config-path";
import { afterNextRender } from "../util/render-status";
const UNDO_REDO_STACK_LIMIT = 75;
/**
* One undo step. `meta` is what the commit described the changes with, and
* is missing for changes made without a commit.
*/
export interface ConfigChangeStep<Meta = unknown> {
changes: ConfigChange[];
meta?: Meta;
}
const changedPaths = (changes: ConfigChange[]): ConfigPath[] =>
changes.flatMap((change) =>
change.op === "move" ? [change.from, change.path] : [change.path]
);
// Whether `changes` only touch values at, in or around the paths of `of`
const onlyTouches = (changes: ConfigChange[], of: ConfigChange[]) => {
const paths = changedPaths(of);
return changedPaths(changes).every((changed) =>
paths.some(
(path) => pathStartsWith(changed, path) || pathStartsWith(path, changed)
)
);
};
export interface ConfigChangeUndoRedoControllerConfig<ConfigType, Meta> {
stackLimit?: number;
currentConfig: () => ConfigType | undefined;
/**
* Applies the config after undo or redo, with the changes that led to it.
* On undo these are the inverted changes of the step, in reverse order.
*/
apply: (
config: ConfigType,
step: ConfigChangeStep<Meta>,
undo: boolean
) => void;
/** Called for every container copied while applying a change. */
onCopy?: (original: object, copy: object) => void;
}
/**
* Undo and redo for immutably updated configs. Instead of whole snapshots, each
* step stores the changes that were made, with their paths, so the host can
* point the user at what undo or redo touched.
*
* @template ConfigType The type of configuration to manage.
* @template Meta Extra state stored with each step, e.g. UI state to restore.
*/
export class ConfigChangeUndoRedoController<
ConfigType,
Meta = undefined,
> implements ReactiveController {
private _host: ReactiveControllerHost;
private _undoStack: ConfigChangeStep<Meta>[] = [];
private _redoStack: ConfigChangeStep<Meta>[] = [];
private readonly _stackLimit: number = UNDO_REDO_STACK_LIMIT;
private readonly _options: ConfigChangeUndoRedoControllerConfig<
ConfigType,
Meta
>;
// The config after the last commit, undo or redo
private _lastConfig?: ConfigType;
private _grouping = false;
// The step commits add to until endGroup()
private _groupStep?: ConfigChangeStep<Meta>;
// Whether the last undo may still be answered by an editor putting back
// what it removed
private _undoPending = false;
private _undoWithPreviousScheduled = false;
constructor(
host: ReactiveControllerHost,
options: ConfigChangeUndoRedoControllerConfig<ConfigType, Meta>
) {
if (options.stackLimit !== undefined) {
this._stackLimit = options.stackLimit;
}
this._options = options;
this._host = host;
host.addController(this);
}
hostConnected() {
window.addEventListener("undo-change", this._onUndoChange);
}
hostDisconnected() {
window.removeEventListener("undo-change", this._onUndoChange);
this._forgetUndo();
}
private _onUndoChange = (ev: Event) => {
ev.stopPropagation();
this.undo();
};
public get canUndo(): boolean {
return this._undoStack.length > 0;
}
public get canRedo(): boolean {
return this._redoStack.length > 0;
}
/**
* Records the difference between two configs and clears the redo stack.
* Configs without a difference are not recorded. `describe` is called with
* all changes of the step, to store state about them with it.
*/
public commit(
before: ConfigType,
after: ConfigType,
describe?: (changes: ConfigChange[]) => Meta
) {
// An editor put back what the last undo removed, e.g. defaults it fills
// in, maybe in several commits. That step can't be undone on its own, so
// it is undone together with the one before it.
const undone = this._redoStack[this._redoStack.length - 1];
if (
this._undoPending &&
undone &&
before === this._lastConfig &&
onlyTouches(diffConfigs(before, after), undone.changes)
) {
this._lastConfig = after;
this._scheduleUndoWithPrevious(undone);
return;
}
this._forgetUndo();
this._syncExternalChanges(before);
this._lastConfig = after;
this._record(before, after, describe);
}
public undo() {
const current = this._options.currentConfig();
this._syncExternalChanges(current);
const step = this._undoStack.pop();
if (!step || current === undefined) {
return;
}
this._redoStack.push(step);
this._rememberUndo();
this._applyStep(
current,
{ changes: invertConfigChanges(step.changes), meta: step.meta },
true
);
}
public redo() {
this._forgetUndo();
const current = this._options.currentConfig();
this._syncExternalChanges(current);
const step = this._redoStack.pop();
if (!step || current === undefined) {
return;
}
this._undoStack.push(step);
this._applyStep(current, step, false);
}
/**
* Records the commits until `endGroup()` as one step, e.g. for a drag
* between two lists, which report their changes one after the other.
*/
public startGroup() {
this._grouping = true;
this._groupStep = undefined;
}
public endGroup() {
this._grouping = false;
this._groupStep = undefined;
}
public reset() {
this._undoStack = [];
this._redoStack = [];
this._host.requestUpdate();
}
/**
* Records a change made without a commit, e.g. in the YAML editor or a
* dialog, as its own step. Without it, later undo steps would replay on a
* config they weren't recorded on and could hit another row.
*/
private _syncExternalChanges(config: ConfigType | undefined) {
const last = this._lastConfig;
this._lastConfig = config;
if (config === last || config === undefined || last === undefined) {
return;
}
this._record(last, config);
}
private _record(
before: ConfigType,
after: ConfigType,
describe?: (changes: ConfigChange[]) => Meta
) {
const changes = diffConfigs(before, after);
if (!changes.length) {
return;
}
const group = this._groupStep;
if (group && group === this._undoStack[this._undoStack.length - 1]) {
group.changes = [...group.changes, ...changes];
if (describe) {
group.meta = describe(group.changes);
}
} else {
if (this._undoStack.length >= this._stackLimit) {
this._undoStack.shift();
}
const step = { changes, meta: describe?.(changes) };
this._undoStack.push(step);
if (this._grouping) {
this._groupStep = step;
}
}
this._redoStack = [];
this._host.requestUpdate();
}
// Editors put defaults back on their own. Anything after the user's next
// input is a change of their own, even if it puts the same value back.
private _rememberUndo() {
this._forgetUndo();
this._undoPending = true;
window.addEventListener("pointerdown", this._forgetUndo, true);
window.addEventListener("keydown", this._forgetUndo, true);
}
private _forgetUndo = () => {
this._undoPending = false;
window.removeEventListener("pointerdown", this._forgetUndo, true);
window.removeEventListener("keydown", this._forgetUndo, true);
};
// Waits until the editors rendered the undo. One still updating could
// otherwise write an old value back while the next undo runs.
private _scheduleUndoWithPrevious(step: ConfigChangeStep<Meta>) {
if (this._undoWithPreviousScheduled) {
return;
}
this._undoWithPreviousScheduled = true;
afterNextRender(() => {
this._undoWithPreviousScheduled = false;
// Another undo or redo came first
if (this._redoStack[this._redoStack.length - 1] !== step) {
return;
}
this._redoStack.pop();
const previous = this._undoStack.pop();
if (!previous) {
this._host.requestUpdate();
return;
}
this._undoStack.push({
changes: [...previous.changes, ...step.changes],
meta: previous.meta,
});
this.undo();
});
}
private _applyStep(
current: ConfigType,
step: ConfigChangeStep<Meta>,
undo: boolean
) {
const config = applyConfigChanges(
current,
step.changes,
this._options.onCopy
);
this._lastConfig = config;
this._options.apply(config, step, undo);
this._host.requestUpdate();
}
}
declare global {
interface HASSDomEvents {
"undo-change": undefined;
}
}
+408
View File
@@ -0,0 +1,408 @@
import { deepEqual } from "./deep-equal";
import {
getAtPath,
pathStartsWith,
setAtPath,
type ConfigPath,
} from "./config-path";
/**
* One edit of a config as plain JSON, so it can be stored for undo, shown as
* a diff or proposed by someone else and applied later.
*
* Like JSON Patch, `add` and `remove` work on object keys and list items: in a
* list, `add` inserts at the index that ends `path` and later items shift.
* `remove` and `replace` keep the old value, so they can be inverted and
* checked against the config they are applied to. `move` removes the item at
* `from`, then adds it at `path`.
*
* Changes apply in order, each to the config the previous ones left.
*/
export type ConfigChange =
| { op: "add"; path: ConfigPath; value: unknown }
| { op: "remove"; path: ConfigPath; value: unknown }
| { op: "replace"; path: ConfigPath; before: unknown; after: unknown }
| { op: "move"; from: ConfigPath; path: ConfigPath };
// Key order counts, as the editors compare configs with it
const equal = (a: unknown, b: unknown) =>
deepEqual(a, b, { compareKeyOrder: true });
const isContainer = (value: unknown): value is object =>
typeof value === "object" && value !== null;
/** Whether removing and appending keys turns `from` into `to`. */
const keepsKeyOrder = (from: string[], to: string[]) => {
const kept = from.filter((key) => to.includes(key));
return [...kept, ...to.filter((key) => !from.includes(key))].every(
(key, index) => key === to[index]
);
};
const itemsEqual = (
a: unknown[],
aStart: number,
b: unknown[],
bStart: number,
count: number
) => {
for (let i = 0; i < count; i++) {
if (!equal(a[aStart + i], b[bStart + i])) {
return false;
}
}
return true;
};
const diffLists = (
before: unknown[],
after: unknown[],
path: ConfigPath,
changes: ConfigChange[]
) => {
const length = Math.min(before.length, after.length);
let first = 0;
while (first < length && equal(before[first], after[first])) {
first++;
}
if (first === length && before.length === after.length) {
return;
}
if (
after.length === before.length + 1 &&
itemsEqual(before, first, after, first + 1, before.length - first)
) {
changes.push({ op: "add", path: [...path, first], value: after[first] });
return;
}
if (
before.length === after.length + 1 &&
itemsEqual(before, first + 1, after, first, after.length - first)
) {
changes.push({
op: "remove",
path: [...path, first],
value: before[first],
});
return;
}
if (Math.abs(before.length - after.length) === 1) {
diffListsWithOneMore(before, after, path, changes);
return;
}
if (before.length !== after.length) {
changes.push({ op: "replace", path, before, after });
return;
}
let last = before.length - 1;
while (equal(before[last], after[last])) {
last--;
}
const count = last - first;
// A move shifts the items between its old and new index by one
if (
count > 0 &&
equal(after[last], before[first]) &&
itemsEqual(before, first + 1, after, first, count)
) {
changes.push({ op: "move", from: [...path, first], path: [...path, last] });
return;
}
if (
count > 0 &&
equal(after[first], before[last]) &&
itemsEqual(before, first, after, first + 1, count)
) {
changes.push({ op: "move", from: [...path, last], path: [...path, first] });
return;
}
for (let index = first; index <= last; index++) {
diffValues(before[index], after[index], [...path, index], changes);
}
};
const sameKind = (a: unknown, b: unknown) =>
isContainer(a) &&
isContainer(b) &&
Object.keys(a).sort().join() === Object.keys(b).sort().join();
/**
* One item was added or removed and others changed, as when a row is dragged
* into a block in the same list. The added or removed item is the one that
* leaves the fewest other items changed, and of those the fewest changed
* into another kind of item.
*/
const diffListsWithOneMore = (
before: unknown[],
after: unknown[],
path: ConfigPath,
changes: ConfigChange[]
) => {
const added = after.length > before.length;
const longer = added ? after : before;
const shorter = added ? before : after;
let index = 0;
let fewest = Infinity;
for (let candidate = 0; candidate < longer.length; candidate++) {
let score = 0;
shorter.forEach((item, i) => {
const counterpart = longer[i < candidate ? i : i + 1];
if (!equal(counterpart, item)) {
score += sameKind(counterpart, item) ? 2 : 3;
}
});
if (score < fewest) {
index = candidate;
fewest = score;
}
}
changes.push(
added
? { op: "add", path: [...path, index], value: after[index] }
: { op: "remove", path: [...path, index], value: before[index] }
);
// Compared at the indices of `after`, where they are after the first change
after.forEach((item, i) => {
if (added && i === index) {
return;
}
const counterpart = added
? before[i < index ? i : i - 1]
: before[i < index ? i : i + 1];
diffValues(counterpart, item, [...path, i], changes);
});
};
const diffValues = (
before: unknown,
after: unknown,
path: ConfigPath,
changes: ConfigChange[]
) => {
if (before === after) {
return;
}
if (Array.isArray(before) && Array.isArray(after)) {
diffLists(before, after, path, changes);
return;
}
if (
isContainer(before) &&
isContainer(after) &&
!Array.isArray(before) &&
!Array.isArray(after)
) {
const beforeKeys = Object.keys(before);
const afterKeys = Object.keys(after);
// Applying a change adds a key at the end, and undo adds removed keys
// back in reverse order. Where that would change the key order, the whole
// object is replaced instead.
const keysChanged =
afterKeys.filter((key) => !beforeKeys.includes(key)).length +
beforeKeys.filter((key) => !afterKeys.includes(key)).length;
// A key holding undefined is no change as a value, but the dirty check
// compares keys
const undefinedKeyChanged = [...beforeKeys, ...afterKeys].some(
(key) =>
key in before !== key in after &&
(before as Record<string, unknown>)[key] === undefined &&
(after as Record<string, unknown>)[key] === undefined
);
if (
keysChanged > 1 ||
undefinedKeyChanged ||
!keepsKeyOrder(beforeKeys, afterKeys) ||
!keepsKeyOrder(afterKeys, beforeKeys)
) {
changes.push({ op: "replace", path, before, after });
return;
}
for (const key of new Set([...beforeKeys, ...afterKeys])) {
diffValues(
(before as Record<string, unknown>)[key],
(after as Record<string, unknown>)[key],
[...path, key],
changes
);
}
return;
}
if (before === undefined) {
changes.push({ op: "add", path, value: after });
} else if (after === undefined) {
changes.push({ op: "remove", path, value: before });
} else if (!equal(before, after)) {
changes.push({ op: "replace", path, before, after });
}
};
/**
* The changes that turn `before` into `after`, each as small as possible.
* An insertion, removal or move of a single list item is found as such; other
* list changes are compared item by item, or replace the list.
*/
export const diffConfigs = (
before: unknown,
after: unknown
): ConfigChange[] => {
const changes: ConfigChange[] = [];
diffValues(before, after, [], changes);
return changes;
};
const isListItem = (root: unknown, path: ConfigPath) =>
Array.isArray(getAtPath(root, path.slice(0, -1)));
const updateList = <T>(
root: T,
path: ConfigPath,
update: (list: unknown[], index: number) => unknown[],
onCopy?: (original: object, copy: object) => void
): T => {
const listPath = path.slice(0, -1);
const list = getAtPath(root, listPath) as unknown[];
const updated = update(list, path[path.length - 1] as number);
onCopy?.(list, updated);
return setAtPath(root, listPath, updated, onCopy);
};
const add = <T>(
root: T,
path: ConfigPath,
value: unknown,
onCopy?: (original: object, copy: object) => void
) =>
isListItem(root, path)
? updateList(
root,
path,
(list, index) => [...list.slice(0, index), value, ...list.slice(index)],
onCopy
)
: setAtPath(root, path, value, onCopy);
const remove = <T>(
root: T,
path: ConfigPath,
onCopy?: (original: object, copy: object) => void
) =>
isListItem(root, path)
? updateList(
root,
path,
(list, index) => list.filter((_item, i) => i !== index),
onCopy
)
: setAtPath(root, path, undefined, onCopy);
/**
* Applies changes immutably. `onCopy` sees every copied container next to its
* original.
*/
export const applyConfigChanges = <T>(
root: T,
changes: ConfigChange[],
onCopy?: (original: object, copy: object) => void
): T =>
changes.reduce((config, change) => {
switch (change.op) {
case "add":
return add(config, change.path, change.value, onCopy);
case "remove":
return remove(config, change.path, onCopy);
case "replace":
return setAtPath(config, change.path, change.after, onCopy);
case "move":
return add(
remove(config, change.from, onCopy),
change.path,
getAtPath(config, change.from),
onCopy
);
}
return config;
}, root);
/** The changes that undo `changes`. */
export const invertConfigChanges = (changes: ConfigChange[]): ConfigChange[] =>
changes
.map((change): ConfigChange => {
switch (change.op) {
case "add":
return { op: "remove", path: change.path, value: change.value };
case "remove":
return { op: "add", path: change.path, value: change.value };
case "replace":
return {
op: "replace",
path: change.path,
before: change.after,
after: change.before,
};
case "move":
return { op: "move", from: change.path, path: change.from };
}
return change;
})
.reverse();
// Moves `path` along when an item is added to or removed from a list above it
const shift = (
path: ConfigPath,
itemPath: ConfigPath,
by: 1 | -1
): ConfigPath => {
const listPath = itemPath.slice(0, -1);
const index = itemPath[itemPath.length - 1];
const segment = path[listPath.length];
if (
typeof index !== "number" ||
typeof segment !== "number" ||
path.length <= listPath.length ||
!pathStartsWith(path, listPath) ||
(by === 1 ? segment < index : segment <= index)
) {
return path;
}
return [...listPath, segment + by, ...path.slice(listPath.length + 1)];
};
/**
* Where the value at `path` is after `changes`, or `undefined` when it was
* removed or is part of a replaced value.
*/
export const mapConfigPath = (
path: ConfigPath,
changes: ConfigChange[]
): ConfigPath | undefined => {
let mapped: ConfigPath | undefined = path;
for (const change of changes) {
if (!mapped) {
return undefined;
}
switch (change.op) {
case "add":
mapped = shift(mapped, change.path, 1);
break;
case "remove":
mapped = pathStartsWith(mapped, change.path)
? undefined
: shift(mapped, change.path, -1);
break;
case "replace":
if (
pathStartsWith(mapped, change.path) &&
mapped.length > change.path.length
) {
mapped = undefined;
}
break;
case "move":
mapped = pathStartsWith(mapped, change.from)
? [...change.path, ...mapped.slice(change.from.length)]
: shift(shift(mapped, change.from, -1), change.path, 1);
break;
}
}
return mapped;
};
+56
View File
@@ -0,0 +1,56 @@
export type ConfigPath = (string | number)[];
const isContainer = (value: unknown): value is object =>
typeof value === "object" && value !== null;
export const pathEquals = (a: ConfigPath, b: ConfigPath): boolean =>
a.length === b.length && a.every((segment, index) => segment === b[index]);
export const pathStartsWith = (path: ConfigPath, prefix: ConfigPath) =>
prefix.length <= path.length &&
prefix.every((segment, index) => path[index] === segment);
export const getAtPath = (root: unknown, path: ConfigPath): unknown =>
path.reduce<unknown>(
(node, segment) =>
isContainer(node)
? (node as Record<string, unknown>)[segment]
: undefined,
root
);
/**
* Immutably replaces the value at `path`, copying only the containers along it.
* An `undefined` value removes an object key instead of storing `undefined`.
* `onCopy` sees every copied container next to its original.
*/
export const setAtPath = <T>(
root: T,
path: ConfigPath,
value: unknown,
onCopy?: (original: object, copy: object) => void
): T => {
if (path.length === 0) {
return value as T;
}
const [segment, ...rest] = path;
const node: unknown = root;
const copy = (
Array.isArray(node) ? node.slice() : { ...(isContainer(node) ? node : {}) }
) as Record<string | number, unknown>;
const child = setAtPath(
isContainer(node) ? (node as Record<string, unknown>)[segment] : undefined,
rest,
value,
onCopy
);
if (child === undefined && !Array.isArray(node)) {
delete copy[segment];
} else {
copy[segment] = child;
}
if (isContainer(node)) {
onCopy?.(node, copy);
}
return copy as T;
};
@@ -21,6 +21,8 @@ export class HaActionSelector extends LitElement {
@property({ attribute: false }) public value?: Action;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -51,6 +53,7 @@ export class HaActionSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-action
.pathKey=${this.name}
.disabled=${this.disabled}
.actions=${this._actions(this.value)}
.hass=${this.hass}
@@ -16,6 +16,8 @@ export class HaConditionSelector extends LitElement {
@property({ attribute: false }) public value?: Condition;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -27,6 +29,7 @@ export class HaConditionSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-condition
.pathKey=${this.name}
.disabled=${this.disabled}
.conditions=${this.value || []}
.hass=${this.hass}
@@ -17,6 +17,8 @@ export class HaTriggerSelector extends LitElement {
@property({ attribute: false }) public value?: Trigger;
@property() public name?: string;
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -32,6 +34,7 @@ export class HaTriggerSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-automation-trigger
.pathKey=${this.name}
.disabled=${this.disabled}
.triggers=${this._triggers(this.value)}
.hass=${this.hass}
@@ -126,6 +126,7 @@ import "./types/ha-automation-action-wait_for_trigger";
import "./types/ha-automation-action-wait_template";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
export interface ActionElement extends LitElement {
action: Action;
@@ -175,6 +176,8 @@ export default class HaAutomationActionRow extends LitElement {
@property({ type: Boolean }) public root = false;
@property({ attribute: false }) public index!: number;
@property({ type: Boolean }) public first?: boolean;
@property({ type: Boolean }) public last?: boolean;
@@ -187,6 +190,11 @@ export default class HaAutomationActionRow extends LitElement {
@property({ type: Boolean, attribute: "sort-selected" })
public sortSelected = false;
protected readonly pathController = new AutomationPathController(this, {
segment: () => [this.index],
row: () => (this.optionsInSidebar ? this : undefined),
});
@storage({
key: "automationClipboard",
state: false,
@@ -43,6 +43,7 @@ export class HaChooseAction extends LitElement implements ActionElement {
return html`
<ha-automation-option
path-key="choose"
.options=${options}
.disabled=${this.disabled}
@value-changed=${this._optionsChanged}
@@ -46,6 +46,7 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-condition
path-key="if"
.conditions=${action.if ?? []}
.disabled=${this.disabled}
@value-changed=${this._ifChanged}
@@ -60,6 +61,7 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-action
path-key="then"
.actions=${action.then ?? []}
.disabled=${this.disabled}
@value-changed=${this._thenChanged}
@@ -73,6 +75,7 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-action
path-key="else"
.actions=${action.else || []}
.disabled=${this.disabled}
@value-changed=${this._elseChanged}
@@ -35,6 +35,7 @@ export class HaParallelAction extends LitElement implements ActionElement {
return html`
<ha-automation-action
path-key="parallel"
.actions=${action.parallel}
.narrow=${this.narrow}
.disabled=${this.disabled}
@@ -8,6 +8,7 @@ import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import "../ha-automation-action";
import type { ActionElement } from "../ha-automation-action-row";
import { AutomationPathController } from "../../automation-path";
import { isTemplate } from "../../../../../common/string/has-template";
import "../../../../../components/ha-form/ha-form";
@@ -40,6 +41,11 @@ export class HaRepeatAction extends LitElement implements ActionElement {
@property({ type: Boolean, attribute: "indent" }) public indent = false;
// The form edits the repeat options, so its fields are keyed below "repeat"
protected readonly pathController = new AutomationPathController(this, {
segment: () => ["repeat"],
});
@query("ha-form")
private _formElement?: HaForm;
@@ -35,6 +35,7 @@ export class HaSequenceAction extends LitElement implements ActionElement {
return html`
<ha-automation-action
path-key="sequence"
.actions=${action.sequence ?? []}
.narrow=${this.narrow}
.disabled=${this.disabled}
@@ -90,6 +90,7 @@ export class HaWaitForTriggerAction
${
this.indent || (!this.inSidebar && !this.indent)
? html`<ha-automation-trigger
path-key="wait_for_trigger"
class=${!this.inSidebar && !this.indent ? "expansion-panel" : ""}
.triggers=${ensureArray(this.action.wait_for_trigger)}
.hass=${this.hass}
@@ -0,0 +1,209 @@
import { ContextProvider, createContext } from "@lit/context";
import type { ReactiveElement } from "lit";
import {
consumeContext,
ContextController,
} from "../../../common/decorators/consume";
import type { ConfigChangeStep } from "../../../common/controllers/config-change-undo-redo-controller";
import type { ConfigChange } from "../../../common/util/config-change";
import { deepEqual } from "../../../common/util/deep-equal";
import { pathEquals, type ConfigPath } from "../../../common/util/config-path";
export const automationPathContext =
createContext<ConfigPath>("automation-path");
export interface AutomationPathRow extends HTMLElement {
readonly selected: boolean;
openSidebar(): void;
expand(): void;
}
const pathKey = (path: ConfigPath) => path.join("/");
/**
* Rows that open in the sidebar, by path. Lets the editor find the row a
* change belongs to after undo or redo.
*/
export class AutomationRowRegistry {
private _rows = new Map<
string,
{ path: ConfigPath; row: AutomationPathRow }
>();
public register(path: ConfigPath, row: AutomationPathRow) {
this._rows.set(pathKey(path), { path, row });
}
public unregister(path: ConfigPath, row: AutomationPathRow) {
const key = pathKey(path);
// A reordered row may already have taken over the path.
if (this._rows.get(key)?.row === row) {
this._rows.delete(key);
}
}
public pathOf(row: AutomationPathRow): ConfigPath | undefined {
return [...this._rows.values()].find((entry) => entry.row === row)?.path;
}
/** The rows containing `path`, outermost first. */
public rowsAlong(path: ConfigPath): AutomationPathRow[] {
return path
.map(
(_segment, index) =>
this._rows.get(pathKey(path.slice(0, index + 1)))?.row
)
.filter((row): row is AutomationPathRow => row !== undefined);
}
}
export const automationRowRegistryContext =
createContext<AutomationRowRegistry>("automation-row-registry");
/**
* Paths of the rows changes are about, most likely first. A removed row is
* pointed at by its list.
*/
export const getChangedRowPaths = (changes: ConfigChange[]): ConfigPath[] =>
changes.flatMap((change) => {
if (change.op === "remove") {
return [change.path.slice(0, -1)];
}
// Empty optional lists like "else" are removed from the config, so a
// list that came back is about its rows
if (change.op === "add" && Array.isArray(change.value)) {
return [[...change.path, 0], change.path];
}
// A replaced list, e.g. after pasting several rows, is about its first
// changed row
if (
change.op === "replace" &&
Array.isArray(change.before) &&
Array.isArray(change.after)
) {
const { before, after } = change;
const index = after.findIndex((item, i) => !deepEqual(item, before[i]));
return index === -1
? [change.path]
: [[...change.path, index], change.path];
}
if (change.op !== "move") {
return [change.path];
}
const from = change.from[change.from.length - 1] as number;
const to = change.path[change.path.length - 1] as number;
// Swapped neighbours can't be told apart
return Math.abs(from - to) === 1 &&
pathEquals(change.from.slice(0, -1), change.path.slice(0, -1))
? [change.path, change.from]
: [change.path];
});
/** Editor state stored with each undo step. */
export interface UndoStepMeta {
/**
* Path of the row the sidebar showed, when the change was about it: before
* the change, to show after undo, and after it, to show after redo.
*/
sidebarRow?: { undo: ConfigPath; redo?: ConfigPath };
}
/**
* The rows to show after undo or redo. The sidebar opens only for a change
* that was made while the sidebar showed its row.
*/
export const getUndoRevealTarget = (
{ changes, meta }: ConfigChangeStep<UndoStepMeta>,
undo: boolean
): { paths: ConfigPath[]; openSidebar: boolean } => {
const sidebarPath = undo ? meta?.sidebarRow?.undo : meta?.sidebarRow?.redo;
return sidebarPath
? { paths: [sidebarPath], openSidebar: true }
: { paths: getChangedRowPaths(changes), openSidebar: false };
};
interface AutomationPathControllerOptions {
/** Path segments this element adds to the path of its ancestor. */
segment: () => ConfigPath;
/** The host while it is a row that opens in the sidebar. */
row?: () => AutomationPathRow | undefined;
}
/**
* Consumes the path of the closest ancestor and provides it extended by the
* host's own segment to its descendants.
*/
export class AutomationPathController extends ContextController {
public path: ConfigPath = [];
@consumeContext({ context: automationPathContext, subscribe: true })
private _parentPath: ConfigPath = [];
@consumeContext({ context: automationRowRegistryContext })
private _registry?: AutomationRowRegistry;
private _registeredPath?: ConfigPath;
private _registeredRow?: AutomationPathRow;
private readonly _provider: ContextProvider<typeof automationPathContext>;
constructor(
host: ReactiveElement,
private readonly _options: AutomationPathControllerOptions
) {
super(host);
this._provider = new ContextProvider(host, {
context: automationPathContext,
initialValue: this.path,
});
}
protected contextUpdated() {
this.host.requestUpdate();
}
public hostUpdate() {
const path = [...this._parentPath, ...this._options.segment()];
if (!pathEquals(path, this.path)) {
this.path = path;
this._provider.setValue(path);
}
this._syncRegistration();
}
public hostConnected() {
// A row put back by a cancelled drag reconnects without an update
this.host.requestUpdate();
}
public hostDisconnected() {
this._unregister();
}
private _syncRegistration() {
if (!this._registry || !this._options.row) {
return;
}
const row = this.host.isConnected ? this._options.row() : undefined;
if (
this._registeredPath &&
(!row || !pathEquals(this._registeredPath, this.path))
) {
this._unregister();
}
if (row && !this._registeredPath) {
this._registry.register(this.path, row);
this._registeredPath = this.path;
this._registeredRow = row;
}
}
private _unregister() {
if (this._registeredPath && this._registeredRow) {
this._registry?.unregister(this._registeredPath, this._registeredRow);
}
this._registeredPath = undefined;
this._registeredRow = undefined;
}
}
@@ -96,6 +96,7 @@ import "./types/ha-automation-condition-trigger";
import "./types/ha-automation-condition-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
export interface ConditionElement extends LitElement {
condition: Condition;
@@ -113,6 +114,8 @@ export default class HaAutomationConditionRow extends LitElement {
@property({ type: Boolean }) public root = false;
@property({ attribute: false }) public index!: number;
@property({ type: Boolean }) public first?: boolean;
@property({ type: Boolean }) public last?: boolean;
@@ -124,6 +127,11 @@ export default class HaAutomationConditionRow extends LitElement {
@property({ type: Boolean, attribute: "sort-selected" })
public sortSelected = false;
protected readonly pathController = new AutomationPathController(this, {
segment: () => [this.index],
row: () => (this.optionsInSidebar ? this : undefined),
});
@state() private _collapsed = true;
@state() private _isNew = false;
@@ -29,6 +29,7 @@ export abstract class HaLogicalCondition
protected render() {
return html`
<ha-automation-condition
path-key="conditions"
.conditions=${this.condition.conditions || []}
@value-changed=${this._valueChanged}
.hass=${this.hass}
@@ -24,8 +24,12 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
import {
ConfigChangeUndoRedoController,
type ConfigChangeStep,
} from "../../../common/controllers/config-change-undo-redo-controller";
import { fireEvent } from "../../../common/dom/fire_event";
import { afterNextRender } from "../../../common/util/render-status";
import { isNavigationClick } from "../../../common/dom/is-navigation-click";
import { goBack, navigate } from "../../../common/navigate";
import { promiseTimeout } from "../../../common/util/promise-timeout";
@@ -73,7 +77,9 @@ import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { haStyle } from "../../../resources/styles";
import type { Entries, ValueChangedEvent } from "../../../types";
import { isMac } from "../../../util/is_mac";
import { getUndoRevealTarget, type UndoStepMeta } from "./automation-path";
import { showEditorToast } from "./editor-toast";
import { preserveAutomationRowKey } from "./ha-automation-sortable-list-mixin";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { showAutomationModeDialog } from "./automation-mode-dialog/show-dialog-automation-mode";
import { showAutomationSaveDialog } from "./automation-save-dialog/show-dialog-automation-save";
@@ -162,9 +168,13 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
checkValidation: () => this._checkValidation(),
};
private _undoRedoController = new UndoRedoController<AutomationConfig>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => this.config!,
private _undoRedoController = new ConfigChangeUndoRedoController<
AutomationConfig,
UndoStepMeta
>(this, {
apply: (config, step, undo) => this._applyUndoRedo(config, step, undo),
currentConfig: () => this.config,
onCopy: preserveAutomationRowKey,
});
public override get isDirtyState(): boolean {
@@ -515,6 +525,8 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
`
: html`
<manual-automation-editor
@drag-start=${this._dragStarted}
@drag-end=${this._dragEnded}
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
@@ -819,7 +831,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _updateConfig(config: AutomationConfig) {
if (this.config) {
this._undoRedoController.commit(this.config);
this._undoRedoController.commit(this.config, config, (changes) => ({
sidebarRow: this._manualEditor?.findSidebarRow(changes),
}));
}
this.config = config;
@@ -1056,6 +1070,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config!,
domain: "automation",
updateConfig: async (config, entityRegistryUpdate) => {
this._undoRedoController.commit(this.config!, config);
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState({
@@ -1077,6 +1092,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
showAutomationModeDialog(this, {
config: this.config!,
updateConfig: (config) => {
this._undoRedoController.commit(this.config!, config);
this.config = config;
this._updateDirtyState({
config,
@@ -1259,15 +1275,33 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._manualEditor?.deleteSelectedRow();
}
private _applyUndoRedo(config: AutomationConfig) {
this._manualEditor?.triggerCloseSidebar();
private _applyUndoRedo(
config: AutomationConfig,
step: ConfigChangeStep<UndoStepMeta>,
undo: boolean
) {
this.config = config;
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
const { paths, openSidebar } = getUndoRevealTarget(step, undo);
this._manualEditor?.revealPaths(paths, openSidebar);
}
private _dragStarted = () => {
this._undoRedoController.startGroup();
this._manualEditor?.dragStarted();
};
// A drag between lists changes both, each after its next render
private _dragEnded = () => {
afterNextRender(() => {
this._undoRedoController.endGroup();
this._manualEditor?.dragEnded();
});
};
private _undo() {
this._undoRedoController.undo();
}
@@ -7,6 +7,7 @@ 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";
import { AutomationPathController } from "./automation-path";
const rowKeys = new WeakMap<object, string>();
@@ -75,6 +76,13 @@ export const AutomationSortableListMixin = <T extends object>(
@property({ type: Boolean, attribute: "sidebar" }) public optionsInSidebar =
false;
/** Key of this list in the config of the row or editor rendering it. */
@property({ attribute: "path-key" }) public pathKey?: string;
protected readonly pathController = new AutomationPathController(this, {
segment: () => (this.pathKey === undefined ? [] : [this.pathKey]),
});
@state() protected rowSortSelected?: number;
@state()
@@ -1,3 +1,4 @@
import { provide } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import {
html,
@@ -19,6 +20,16 @@ import {
extractSearchParam,
removeSearchParam,
} from "../../../common/url/search-params";
import {
mapConfigPath,
type ConfigChange,
} from "../../../common/util/config-change";
import {
pathEquals,
pathStartsWith,
type ConfigPath,
} from "../../../common/util/config-path";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
@@ -33,8 +44,15 @@ import type {
ValueChangedEvent,
} from "../../../types";
import { showToast } from "../../../util/toast";
import {
AutomationRowRegistry,
automationRowRegistryContext,
type AutomationPathRow,
type UndoStepMeta,
} from "./automation-path";
import "./ha-automation-sidebar";
import type HaAutomationSidebar from "./ha-automation-sidebar";
import { scrollRowIntoView } from "./scroll-row-into-view";
export const SIDEBAR_DEFAULT_WIDTH = 500;
@@ -82,6 +100,18 @@ export const ManualEditorMixin = <TConfig>(
@query("ha-automation-sidebar")
protected sidebarElement?: HaAutomationSidebar;
@provide({ context: automationRowRegistryContext })
protected rowRegistry = new AutomationRowRegistry();
// Undo and redo can remove the row the sidebar edits
private _sidebarRow?: Element;
private _revealCount = 0;
// Lists show a drag before they report it, so during a drag the path the
// sidebar row had before it counts
private _dragSidebarPath?: ConfigPath;
declare protected collapsableElements?: NodeListOf<
Element & {
expandAll(): void;
@@ -192,6 +222,7 @@ export const ManualEditorMixin = <TConfig>(
// deselect previous selected row
this.sidebarConfig?.close?.();
this.sidebarConfig = ev.detail;
this._sidebarRow = ev.composedPath()[0] as Element;
// The sheet opens items at half height, so make room before rows scroll
this._bottomSheetHeight = undefined;
this.dispatchEvent(
@@ -243,6 +274,118 @@ export const ManualEditorMixin = <TConfig>(
}
}
/**
* The paths of the row the sidebar shows before and after the changes,
* when they are about it, e.g. it was edited or moved. Call it before the
* changes rendered, while the row is registered at its path from before.
*/
public dragStarted() {
this._dragSidebarPath =
this._sidebarRow &&
this.rowRegistry.pathOf(this._sidebarRow as AutomationPathRow);
}
public dragEnded() {
this._dragSidebarPath = undefined;
}
public findSidebarRow(changes: ConfigChange[]): UndoStepMeta["sidebarRow"] {
const row = this._sidebarRow as AutomationPathRow | undefined;
const path =
row && (this._dragSidebarPath ?? this.rowRegistry.pathOf(row));
if (!path) {
return undefined;
}
const isAbout = changes.some((change) => {
if (change.op === "move") {
// Swapped neighbours are found as the other one moving
const swapped =
pathEquals(change.path, path) &&
pathEquals(change.from.slice(0, -1), path.slice(0, -1)) &&
Math.abs(
(change.from[change.from.length - 1] as number) -
(path[path.length - 1] as number)
) === 1;
return swapped || pathStartsWith(change.from, path);
}
// An item added at its path is a new row before it
if (change.op === "add") {
return (
change.path.length > path.length &&
pathStartsWith(change.path, path)
);
}
// A replaced value around the row may still hold it unchanged
return (
pathStartsWith(change.path, path) ||
(change.op === "remove" && pathStartsWith(path, change.path))
);
});
return isAbout
? { undo: path, redo: mapConfigPath(path, changes) }
: undefined;
}
/**
* Shows the row a change from undo or redo is about, so the user sees
* what changed. Takes the most likely path first and keeps an already
* selected row, and its sidebar, open. Opens the sidebar only with
* `openSidebar`, so a change made with a closed sidebar doesn't open it.
*/
public async revealPaths(paths: ConfigPath[], openSidebar: boolean) {
const reveal = ++this._revealCount;
// Rows register their paths once they rendered the changed config
await nextRender();
// A later undo or redo has its own rows to show
if (reveal !== this._revealCount) {
return;
}
const candidates = paths
.map((path) => this.rowRegistry.rowsAlong(path))
.filter((rows) => rows.length);
const rows =
candidates.find((along) => along[along.length - 1].selected) ??
candidates[0];
const row = rows?.pop();
if (row && rows?.length) {
rows.forEach((ancestor) => ancestor.expand());
await nextRender();
if (reveal !== this._revealCount) {
return;
}
}
// Only the row itself, a parent block's sidebar would be about another
if (
row &&
openSidebar &&
!row.selected &&
pathEquals(this.rowRegistry.pathOf(row) ?? [], paths[0])
) {
row.openSidebar();
// On narrow screens openSidebar scrolls once the bottom sheet opened
if (!this.narrow) {
scrollRowIntoView(row);
}
return;
}
// Rows unregister when removed, and only registered rows refresh
// their sidebar when their config changes
if (
this.sidebarConfig &&
!this.rowRegistry.pathOf(this._sidebarRow as AutomationPathRow)
) {
// A removed row's close event can't reach the editor anymore
if (this._sidebarRow?.isConnected) {
this.triggerCloseSidebar();
} else {
this.handleCloseSidebar();
}
}
if (row) {
scrollRowIntoView(row);
}
}
public get canToggleSidebarYamlMode(): boolean {
return !!this.sidebarElement?.canToggleYamlMode;
}
@@ -259,6 +402,7 @@ export const ManualEditorMixin = <TConfig>(
protected handleCloseSidebar() {
this.sidebarConfig = undefined;
this._sidebarRow = undefined;
this._bottomSheetHeight = undefined;
this.dispatchEvent(
new CustomEvent("sidebar-config-changed", {
@@ -111,6 +111,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
<ha-automation-trigger
role="region"
path-key="triggers"
aria-labelledby="triggers-heading"
.triggers=${this.config.triggers || []}
.highlightedTriggers=${this.pastedConfig?.triggers}
@@ -149,6 +150,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
<ha-automation-condition
role="region"
path-key="conditions"
aria-labelledby="conditions-heading"
.conditions=${this.config.conditions || []}
.highlightedConditions=${this.pastedConfig?.conditions}
@@ -183,6 +185,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
<ha-automation-action
role="region"
path-key="actions"
aria-labelledby="actions-heading"
.actions=${this.config.actions || []}
.highlightedActions=${this.pastedConfig?.actions}
@@ -64,6 +64,7 @@ import {
} from "../styles";
import "../trigger/ha-automation-trigger-references";
import { getUiSupportWarnings, isConfigObject } from "../ui-support";
import { AutomationPathController } from "../automation-path";
@customElement("ha-automation-option-row")
export default class HaAutomationOptionRow extends LitElement {
@@ -89,6 +90,11 @@ export default class HaAutomationOptionRow extends LitElement {
@property({ type: Boolean, attribute: "sort-selected" })
public sortSelected = false;
protected readonly pathController = new AutomationPathController(this, {
segment: () => (this.defaultActions ? ["default"] : [this.index]),
row: () => (this.optionsInSidebar ? this : undefined),
});
@state() private _expanded = false;
@state() private _selected = false;
@@ -439,6 +445,7 @@ export default class HaAutomationOptionRow extends LitElement {
)}:
</h4>
<ha-automation-condition
path-key="conditions"
.conditions=${ensureArray<string | Condition>(
this.option.conditions
)}
@@ -457,6 +464,7 @@ export default class HaAutomationOptionRow extends LitElement {
)}:
</h4>
<ha-automation-action
.pathKey=${this.option ? "sequence" : undefined}
.actions=${
(this.option
? ensureArray(this.option.sequence) || []
@@ -119,6 +119,7 @@ import "./types/ha-automation-trigger-webhook";
import "./types/ha-automation-trigger-zone";
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
import { scrollRowIntoView } from "../scroll-row-into-view";
import { AutomationPathController } from "../automation-path";
export interface TriggerElement extends LitElement {
trigger: Trigger;
@@ -168,12 +169,19 @@ export default class HaAutomationTriggerRow extends LitElement {
@property({ type: Boolean, attribute: "sort-selected" })
public sortSelected = false;
protected readonly pathController = new AutomationPathController(this, {
segment: () => (this.index === undefined ? [] : [this.index]),
row: () => (this.optionsInSidebar ? this : undefined),
});
@state() private _yamlMode = false;
@state() private _triggered = false;
@state() private _selected = false;
private _sidebarTrigger?: Trigger;
@state() private _isNew = false;
@state() private _warnings?: string[];
@@ -723,6 +731,16 @@ export default class HaAutomationTriggerRow extends LitElement {
if (changedProps.has("trigger")) {
this._subscribeTrigger();
}
// Undo and redo bypass the sidebar's save callback. Refresh its snapshot
// unless it already has this trigger, so sidebar edits keep their focus.
if (
changedProps.has("trigger") &&
this._selected &&
this.optionsInSidebar &&
this.trigger !== this._sidebarTrigger
) {
this.openSidebar();
}
}
public connectedCallback(): void {
@@ -824,8 +842,10 @@ export default class HaAutomationTriggerRow extends LitElement {
public openSidebar(trigger?: Trigger): void {
trigger = trigger || this.trigger;
this._sidebarTrigger = trigger;
fireEvent(this, "open-sidebar", {
save: (value) => {
this._sidebarTrigger = value;
fireEvent(this, "value-changed", { value });
},
close: (focus?: boolean) => {
@@ -26,6 +26,7 @@ export class HaTriggerList extends LitElement implements TriggerElement {
return html`
<ha-automation-trigger
path-key="triggers"
.triggers=${triggers}
.hass=${this.hass}
.disabled=${this.disabled}
+49 -11
View File
@@ -22,8 +22,12 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
import {
ConfigChangeUndoRedoController,
type ConfigChangeStep,
} from "../../../common/controllers/config-change-undo-redo-controller";
import { fireEvent } from "../../../common/dom/fire_event";
import { afterNextRender } from "../../../common/util/render-status";
import { goBack, navigate } from "../../../common/navigate";
import { slugify } from "../../../common/string/slugify";
import { promiseTimeout } from "../../../common/util/promise-timeout";
@@ -64,7 +68,12 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { Entries } from "../../../types";
import { isMac } from "../../../util/is_mac";
import {
getUndoRevealTarget,
type UndoStepMeta,
} from "../automation/automation-path";
import { showEditorToast } from "../automation/editor-toast";
import { preserveAutomationRowKey } from "../automation/ha-automation-sortable-list-mixin";
import { showAutomationModeDialog } from "../automation/automation-mode-dialog/show-dialog-automation-mode";
import { showAutomationSaveDialog } from "../automation/automation-save-dialog/show-dialog-automation-save";
import { showAutomationSaveTimeoutDialog } from "../automation/automation-save-timeout-dialog/show-dialog-automation-save-timeout";
@@ -102,9 +111,13 @@ export class HaScriptEditor extends SubscribeMixin(
checkValidation: () => this._checkValidation(),
};
private _undoRedoController = new UndoRedoController<ScriptConfig>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => this.config!,
private _undoRedoController = new ConfigChangeUndoRedoController<
ScriptConfig,
UndoStepMeta
>(this, {
apply: (config, step, undo) => this._applyUndoRedo(config, step, undo),
currentConfig: () => this.config,
onCopy: preserveAutomationRowKey,
});
public override get isDirtyState(): boolean {
@@ -443,6 +456,8 @@ export class HaScriptEditor extends SubscribeMixin(
`
: html`
<manual-script-editor
@drag-start=${this._dragStarted}
@drag-end=${this._dragEnded}
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
@@ -667,7 +682,13 @@ export class HaScriptEditor extends SubscribeMixin(
private _valueChanged(ev) {
if (this.config) {
this._undoRedoController.commit(this.config);
this._undoRedoController.commit(
this.config,
ev.detail.value,
(changes) => ({
sidebarRow: this._manualEditor?.findSidebarRow(changes),
})
);
}
this.config = ev.detail.value;
@@ -757,10 +778,7 @@ export class HaScriptEditor extends SubscribeMixin(
return;
}
if (this.config) {
this._undoRedoController.commit(this.config);
}
// Committed to the undo stack by the value-changed it fires
this._manualEditor?.addFields();
this._updateDirtyState({
config: this.config!,
@@ -917,6 +935,7 @@ export class HaScriptEditor extends SubscribeMixin(
config: this.config!,
domain: "script",
updateConfig: async (config, entityRegistryUpdate) => {
this._undoRedoController.commit(this.config!, config);
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState({
@@ -940,6 +959,7 @@ export class HaScriptEditor extends SubscribeMixin(
showAutomationModeDialog(this, {
config: this.config!,
updateConfig: (config) => {
this._undoRedoController.commit(this.config!, config);
this.config = config;
this._updateDirtyState({
config,
@@ -1120,15 +1140,33 @@ export class HaScriptEditor extends SubscribeMixin(
this._manualEditor?.deleteSelectedRow();
}
private _applyUndoRedo(config: ScriptConfig) {
this._manualEditor?.triggerCloseSidebar();
private _applyUndoRedo(
config: ScriptConfig,
step: ConfigChangeStep<UndoStepMeta>,
undo: boolean
) {
this.config = config;
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
const { paths, openSidebar } = getUndoRevealTarget(step, undo);
this._manualEditor?.revealPaths(paths, openSidebar);
}
private _dragStarted = () => {
this._undoRedoController.startGroup();
this._manualEditor?.dragStarted();
};
// A drag between lists changes both, each after its next render
private _dragEnded = () => {
afterNextRender(() => {
this._undoRedoController.endGroup();
this._manualEditor?.dragEnded();
});
};
private _undo() {
this._undoRedoController.undo();
}
@@ -151,6 +151,7 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
<ha-automation-action
role="region"
path-key="sequence"
aria-labelledby="sequence-heading"
.actions=${this.config.sequence || []}
.highlightedActions=${this.pastedConfig?.sequence}
@@ -0,0 +1,206 @@
import type { ReactiveControllerHost } from "@lit/reactive-element/reactive-controller";
import { describe, expect, it, vi } from "vitest";
import {
ConfigChangeUndoRedoController,
type ConfigChangeStep,
} from "../../../src/common/controllers/config-change-undo-redo-controller";
import { nextRender } from "../../../src/common/util/render-status";
interface Config {
alias?: string;
actions: { delay: number; alias?: string; enabled?: boolean }[];
}
const createHost = (): ReactiveControllerHost => ({
addController: vi.fn(),
removeController: vi.fn(),
requestUpdate: vi.fn(),
updateComplete: Promise.resolve(true),
});
const setup = (initial: Config) => {
let config = initial;
const applied: ConfigChangeStep[] = [];
const controller = new ConfigChangeUndoRedoController<Config>(createHost(), {
currentConfig: () => config,
apply: (next, step) => {
config = next;
applied.push(step);
},
});
const edit = (next: Config) => {
controller.commit(config, next);
config = next;
};
// A change that bypasses the history, e.g. from the YAML editor
const replace = (next: Config) => {
config = next;
};
return { controller, edit, replace, applied, current: () => config };
};
// Undo replays stored changes on the current config instead of restoring
// snapshots, so other changes must survive and stale paths must not apply.
describe("ConfigChangeUndoRedoController", () => {
it("undoes and redoes only the recorded change", () => {
const { controller, edit, applied, current } = setup({
actions: [{ delay: 1 }, { delay: 2 }],
});
const second = current().actions[1];
edit({ ...current(), actions: [{ delay: 5 }, second] });
edit({ ...current() });
expect(controller.canUndo).toBe(true);
controller.undo();
expect(current().actions).toEqual([{ delay: 1 }, { delay: 2 }]);
expect(current().actions[1]).toBe(second);
expect(applied[0].changes).toEqual([
{ op: "replace", path: ["actions", 0, "delay"], before: 5, after: 1 },
]);
expect(controller.canUndo).toBe(false);
controller.redo();
expect(current().actions[0]).toEqual({ delay: 5 });
expect(controller.canRedo).toBe(false);
});
it("replays structural and value changes in sequence", () => {
const { controller, edit, current } = setup({ actions: [] });
edit({ ...current(), actions: [{ delay: 1 }] });
edit({ ...current(), actions: [{ delay: 2 }] });
controller.undo();
controller.undo();
expect(current().actions).toEqual([]);
controller.redo();
controller.redo();
expect(current().actions).toEqual([{ delay: 2 }]);
expect(controller.canUndo).toBe(true);
});
it("records changes made outside the history as their own step", () => {
const { controller, edit, replace, applied, current } = setup({
actions: [{ delay: 1 }, { delay: 3 }],
});
edit({ ...current(), actions: [{ delay: 2 }, current().actions[1]] });
// Reordered, so the recorded path now points at the other action
replace({ ...current(), actions: [{ delay: 3 }, { delay: 2 }] });
replace({ ...current(), alias: "Renamed" });
edit({ ...current(), actions: [{ delay: 3 }, { delay: 4 }] });
controller.undo();
expect(current()).toEqual({
alias: "Renamed",
actions: [{ delay: 3 }, { delay: 2 }],
});
// The reorder and rename were made together, so they undo together
controller.undo();
expect(applied[1].changes).toEqual([
{ op: "remove", path: ["alias"], value: "Renamed" },
{ op: "move", from: ["actions", 1], path: ["actions", 0] },
]);
expect(current()).toEqual({ actions: [{ delay: 2 }, { delay: 3 }] });
controller.undo();
expect(current()).toEqual({ actions: [{ delay: 1 }, { delay: 3 }] });
controller.redo();
controller.redo();
controller.redo();
expect(current()).toEqual({
alias: "Renamed",
actions: [{ delay: 3 }, { delay: 4 }],
});
});
it("records a drag between two lists as one step", () => {
const { controller, edit, current } = setup({
actions: [{ delay: 1 }, { delay: 2 }],
});
const [first, second] = current().actions;
controller.startGroup();
edit({ ...current(), actions: [first, second, first] });
edit({ ...current(), actions: [second, first] });
controller.endGroup();
controller.undo();
expect(current().actions).toEqual([{ delay: 1 }, { delay: 2 }]);
expect(controller.canUndo).toBe(false);
});
it("undoes a step an editor puts back together with the previous one", async () => {
const { controller, edit, current } = setup({ actions: [] });
edit({ ...current(), actions: [{ delay: 0 }] });
// An editor fills in its defaults right after the action was added
edit({ ...current(), actions: [{ delay: 0, alias: "Wait" }] });
controller.undo();
// ... and again after undo removed them
edit({ ...current(), actions: [{ delay: 0, alias: "Wait" }] });
await nextRender();
expect(current().actions).toEqual([]);
expect(controller.canUndo).toBe(false);
controller.redo();
expect(current().actions).toEqual([{ delay: 0, alias: "Wait" }]);
});
it("undoes defaults an editor fills in over several commits", async () => {
const { controller, edit, current } = setup({ actions: [] });
edit({ ...current(), actions: [{ delay: 0 }] });
edit({ ...current(), actions: [{ delay: 0, alias: "Wait" }] });
edit({
...current(),
actions: [{ delay: 0, alias: "Wait", enabled: true }],
});
// The editor fills its defaults in again, one after the other
const refill = () => {
const [action] = current().actions;
if (!action) {
return;
}
edit({
...current(),
actions: [
"alias" in action
? { ...action, enabled: true }
: { ...action, alias: "Wait" },
],
});
};
controller.undo();
refill();
await nextRender();
refill();
await nextRender();
refill();
expect(current().actions).toEqual([]);
expect(controller.canUndo).toBe(false);
controller.redo();
expect(current().actions).toEqual([
{ delay: 0, alias: "Wait", enabled: true },
]);
});
it("records the undone value put back by the user as a new step", () => {
const { controller, edit, current } = setup({ actions: [{ delay: 1 }] });
edit({ ...current(), actions: [{ delay: 2 }] });
edit({ ...current(), actions: [{ delay: 3 }] });
controller.undo();
window.dispatchEvent(new Event("pointerdown"));
edit({ ...current(), actions: [{ delay: 3 }] });
expect(controller.canRedo).toBe(false);
controller.undo();
expect(current().actions).toEqual([{ delay: 2 }]);
});
});
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import {
applyConfigChanges,
diffConfigs,
invertConfigChanges,
} from "../../../src/common/util/config-change";
import { deepEqual } from "../../../src/common/util/deep-equal";
// Undo replays computed changes on deep automation trees. Random edits catch
// trees and edit combinations the hand-written cases miss.
const random = (seed: number) => () => {
seed = (seed * 1103515245 + 12345) % 2147483648;
return seed / 2147483648;
};
type Node = Record<string, unknown>;
const createAction = (rand: () => number, depth: number): Node => {
const kind = Math.floor(rand() * 4);
if (depth > 3 || kind === 0) {
return { delay: Math.floor(rand() * 10), alias: rand() > 0.5 ? "A" : "B" };
}
const list = () =>
Array.from({ length: Math.floor(rand() * 4) }, () =>
createAction(rand, depth + 1)
);
if (kind === 1) {
return { if: [{ condition: "state" }], then: list(), else: list() };
}
if (kind === 2) {
return { choose: [{ conditions: [], sequence: list() }], default: list() };
}
return { parallel: list() };
};
const lists = (node: unknown, found: unknown[][] = []): unknown[][] => {
if (Array.isArray(node)) {
found.push(node);
node.forEach((item) => lists(item, found));
} else if (node && typeof node === "object") {
Object.values(node).forEach((value) => lists(value, found));
}
return found;
};
const objects = (node: unknown, found: Node[] = []): Node[] => {
if (Array.isArray(node)) {
node.forEach((item) => objects(item, found));
} else if (node && typeof node === "object") {
found.push(node as Node);
Object.values(node).forEach((value) => objects(value, found));
}
return found;
};
// Edits a deep copy in place, like the YAML editor, which creates new objects
const edit = (rand: () => number, config: Node): Node => {
const copy = structuredClone(config);
const edits = 1 + Math.floor(rand() * 3);
for (let i = 0; i < edits; i++) {
const allLists = lists(copy);
const allObjects = objects(copy);
const list = allLists[Math.floor(rand() * allLists.length)];
const object = allObjects[Math.floor(rand() * allObjects.length)];
switch (Math.floor(rand() * 7)) {
case 0:
list.splice(Math.floor(rand() * (list.length + 1)), 0, {
delay: 99,
});
break;
case 1:
list.splice(Math.floor(rand() * list.length), 1);
break;
case 2: {
const [item] = list.splice(Math.floor(rand() * list.length), 1);
if (item !== undefined) {
list.splice(Math.floor(rand() * (list.length + 1)), 0, item);
}
break;
}
case 3:
object.alias = `alias ${Math.floor(rand() * 5)}`;
break;
case 4:
delete object[Object.keys(object)[0]];
break;
case 5: {
// Key order only
const entries = Object.entries(object).reverse();
Object.keys(object).forEach((key) => delete object[key]);
entries.forEach(([key, value]) => {
object[key] = value;
});
break;
}
default:
object.enabled = rand() > 0.5;
}
}
return copy;
};
const equal = (a: unknown, b: unknown) =>
deepEqual(a, b, { compareKeyOrder: true });
describe("config changes on random automations", () => {
it("apply and invert to exactly the edited and original config", () => {
const rand = random(42);
for (let run = 0; run < 500; run++) {
let config: Node = {
alias: "Random",
triggers: [{ trigger: "sun" }],
actions: Array.from({ length: 1 + Math.floor(rand() * 4) }, () =>
createAction(rand, 0)
),
};
const history: { before: Node; after: Node }[] = [];
for (let step = 0; step < 5; step++) {
const after = edit(rand, config);
const changes = diffConfigs(config, after);
const applied = applyConfigChanges(config, changes);
expect(equal(applied, after), JSON.stringify({ config, after })).toBe(
true
);
// The configs undo and redo work on are the replayed ones
history.push({ before: config, after: applied });
config = applied;
}
// Undo all steps one after the other, then redo them
for (const { before, after } of [...history].reverse()) {
const changes = invertConfigChanges(diffConfigs(before, after));
config = applyConfigChanges(config, changes);
expect(equal(config, before), "undo").toBe(true);
}
for (const { before, after } of history) {
config = applyConfigChanges(config, diffConfigs(before, after));
expect(equal(config, after), "redo").toBe(true);
}
}
});
});
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import {
applyConfigChanges,
diffConfigs,
invertConfigChanges,
mapConfigPath,
} from "../../../src/common/util/config-change";
// Undo replays these changes on the current config, so a wrong change
// silently undoes more or less than the user did.
describe("config changes", () => {
const light = { action: "light.turn_on", data: { brightness: 10 } };
const wait = { delay: 5 };
const notify = { action: "notify.notify" };
const config = {
alias: "Evening",
triggers: [{ trigger: "sun" }],
actions: [light, wait, notify],
};
it("finds the smallest changes", () => {
expect(diffConfigs(config, { ...config })).toEqual([]);
expect(
diffConfigs(config, {
...config,
description: "Lights on",
actions: [{ ...light, data: { brightness: 20 } }, wait, notify],
})
).toEqual([
{
op: "replace",
path: ["actions", 0, "data", "brightness"],
before: 10,
after: 20,
},
{ op: "add", path: ["description"], value: "Lights on" },
]);
expect(
diffConfigs(config, { ...config, actions: [light, wait, wait, notify] })
).toEqual([{ op: "add", path: ["actions", 2], value: wait }]);
expect(
diffConfigs(config, { ...config, actions: [light, notify] })
).toEqual([{ op: "remove", path: ["actions", 1], value: wait }]);
// Dragged into a block of the same list
const block = { if: [], then: [light] };
expect(
diffConfigs(
{ actions: [wait, block, notify] },
{ actions: [wait, { ...block, then: [notify, light] }] }
)
).toEqual([
{ op: "remove", path: ["actions", 2], value: notify },
{ op: "add", path: ["actions", 1, "then", 0], value: notify },
]);
expect(
diffConfigs(
{ actions: [block, wait, notify] },
{ actions: [{ ...block, then: [notify, light] }, wait] }
)
).toEqual([
{ op: "remove", path: ["actions", 2], value: notify },
{ op: "add", path: ["actions", 0, "then", 0], value: notify },
]);
// Compared by content, as the YAML editor creates new objects
expect(
diffConfigs(
config,
structuredClone({ ...config, actions: [wait, notify, light] })
)
).toEqual([{ op: "move", from: ["actions", 0], path: ["actions", 2] }]);
});
it("applies and inverts changes", () => {
const changed = {
...config,
triggers: [],
actions: [notify, light, { ...wait, delay: 6 }],
};
const changes = diffConfigs(config, changed);
expect(applyConfigChanges(config, changes)).toEqual(changed);
expect(applyConfigChanges(changed, invertConfigChanges(changes))).toEqual(
config
);
const moved = applyConfigChanges(config, [
{ op: "move", from: ["actions", 2], path: ["actions", 0] },
]);
expect(moved.actions).toEqual([notify, light, wait]);
expect(moved.triggers).toBe(config.triggers);
// A key holding undefined still counts, like in the dirty check
const mode = { mode: "single" };
const withMax = { mode: "restart", max: undefined };
expect(
Object.keys(
applyConfigChanges(
withMax,
invertConfigChanges(diffConfigs(mode, withMax))
)
)
).toEqual(["mode"]);
// Undo puts a removed key back where it was
const { alias: _alias, ...withoutAlias } = config;
expect(diffConfigs(config, withoutAlias)).toEqual([
{ op: "replace", path: [], before: config, after: withoutAlias },
]);
const restored = applyConfigChanges(
withoutAlias,
invertConfigChanges(diffConfigs(config, withoutAlias))
);
expect(Object.keys(restored)).toEqual(Object.keys(config));
const reordered = {
...config,
actions: [{ data: light.data, action: light.action }, wait, notify],
};
expect(
Object.keys(
applyConfigChanges(config, diffConfigs(config, reordered)).actions[0]
)
).toEqual(["data", "action"]);
});
it("follows a path through changes", () => {
const row = ["actions", 1, "then", 2];
expect(
mapConfigPath(row, [
{ op: "add", path: ["actions", 0], value: wait },
{ op: "remove", path: ["actions", 2, "then", 0], value: wait },
{ op: "add", path: ["actions", 2, "alias"], value: "If" },
])
).toEqual(["actions", 2, "then", 1]);
expect(
mapConfigPath(row, [
{ op: "move", from: ["actions", 1], path: ["actions", 3] },
])
).toEqual(["actions", 3, "then", 2]);
// The neighbour moving down moves the row up
expect(
mapConfigPath(
["actions", 1],
[{ op: "move", from: ["actions", 0], path: ["actions", 1] }]
)
).toEqual(["actions", 0]);
expect(
mapConfigPath(row, [{ op: "remove", path: ["actions", 1], value: wait }])
).toBeUndefined();
expect(
mapConfigPath(row, [
{ op: "replace", path: ["actions"], before: [], after: [] },
])
).toBeUndefined();
});
});
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { setAtPath } from "../../../src/common/util/config-path";
describe("setAtPath", () => {
it("copies only the containers along the path", () => {
const untouched = { trigger: "sun" };
const config = {
triggers: [untouched],
actions: [{ delay: 5, alias: "Wait" }],
};
const copies: [object, object][] = [];
const updated = setAtPath(config, ["actions", 0, "delay"], 6, (o, c) =>
copies.push([o, c])
);
expect(updated).toEqual({
triggers: [untouched],
actions: [{ delay: 6, alias: "Wait" }],
});
expect(updated.triggers).toBe(config.triggers);
expect(copies.map(([original]) => original)).toEqual([
config.actions[0],
config.actions,
config,
]);
const removed = setAtPath(config, ["actions", 0, "alias"], undefined);
expect(removed.actions[0]).toEqual({ delay: 5 });
expect("alias" in removed.actions[0]).toBe(false);
});
});
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { ConfigChange } from "../../../../src/common/util/config-change";
import {
getChangedRowPaths,
getUndoRevealTarget,
} from "../../../../src/panels/config/automation/automation-path";
// Decides which row undo or redo shows, so the user sees what changed.
describe("getChangedRowPaths", () => {
it("points at the rows that changed", () => {
const list = ["actions", 0, "then"];
expect(
getChangedRowPaths([
{ op: "add", path: [...list, 1], value: {} },
{ op: "replace", path: [...list, 0, "delay"], before: 1, after: 2 },
])
).toEqual([
[...list, 1],
[...list, 0, "delay"],
]);
expect(
getChangedRowPaths([{ op: "remove", path: [...list, 1], value: {} }])
).toEqual([list]);
expect(
getChangedRowPaths([{ op: "add", path: list, value: [{}] }])
).toEqual([[...list, 0], list]);
expect(
getChangedRowPaths([
{
op: "replace",
path: list,
before: [{ delay: 1 }],
after: [{ delay: 1 }, { delay: 2 }, { delay: 3 }],
},
])
).toEqual([[...list, 1], list]);
expect(
getChangedRowPaths([
{ op: "move", from: [...list, 0], path: [...list, 2] },
])
).toEqual([[...list, 2]]);
// Swapped neighbours, either could be the one that was moved
expect(
getChangedRowPaths([
{ op: "move", from: [...list, 0], path: [...list, 1] },
])
).toEqual([
[...list, 1],
[...list, 0],
]);
});
it("opens the sidebar only on the row it showed", () => {
const changes: ConfigChange[] = [
{ op: "move", from: ["actions", 1], path: ["actions", 0] },
];
const meta = { sidebarRow: { undo: ["actions", 1], redo: ["actions", 0] } };
expect(getUndoRevealTarget({ changes, meta }, true)).toEqual({
paths: [["actions", 1]],
openSidebar: true,
});
expect(getUndoRevealTarget({ changes, meta }, false)).toEqual({
paths: [["actions", 0]],
openSidebar: true,
});
// Without a sidebar row, the changed rows are only shown
expect(getUndoRevealTarget({ changes }, false)).toEqual({
paths: [
["actions", 0],
["actions", 1],
],
openSidebar: false,
});
});
});