mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-02 02:55:10 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bcdaecb77 |
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user