Compare commits

..
Author SHA1 Message Date
Paul Bottein 5f11dc4580 Replace hide-zoom-controls with a zoom slider mode on charts (#55468) 2026-10-29 17:12:40 +01:00
Simon Lamon 27489f188e date-range-picker migrate list (#55471) 2026-10-29 17:57:24 +02:00
Simon Lamon 97fffc3b43 ha-config-person migrate list (#55473) 2026-10-29 17:56:23 +02:00
renovate[bot] a30193b8ef Update dependency marked to v18.1.2 (#55470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 17:50:42 +02:00
Aidan Timson cf063dc19f Update the PR template for the contributors docs (#55459) 2026-10-29 17:05:36 +02:00
Simon Lamon 5db829b7be pick-auth-provider migrate list (#55462) 2026-10-29 17:04:51 +02:00
Maarten Lakerveld 421f8b1986 Load the legacy Intl polyfills once per page and bundle PluralRules with its data (#55452)
Each legacy entry bundle carries its own copy of intl-polyfill.ts, so the
module-level guard only stopped re-runs within one bundle. With the bundles
loading in parallel, the second bundle could probe Intl.PluralRules after the
first had installed the polyfill but before its locale data chunk arrived. The
FormatJS should-polyfill probe calls select(), which throws on a data-less
polyfill, rejecting the top-level await so the entry never evaluated. On the
companion app this surfaces as ExternalBusTimeout on old WebViews.

Share one promise per page on globalThis so every bundle waits for the same
run, and import the PluralRules polyfill and its English data from one module
so Intl.PluralRules is never installed without data.
2026-10-29 17:02:23 +02:00
Simon Lamon a140eedc6e Clean up ha-radio-list-item (#55454) 2026-10-29 17:00:59 +02:00
renovate[bot] 2933568658 Update dependency home-assistant-js-websocket to v9.8.1 (#55450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 16:59:26 +02:00
Simon Lamon 29faeaf7fa Remove incorrect type usage from date-range-picker (#55460) 2026-10-29 16:58:55 +02:00
Simon Lamon 95ba532ceb Removes incorrect type from cast (#55461) 2026-10-29 16:58:33 +02:00
Guillaume Rischard 352ecb9d6d Simplify OpenStreetMap attribution (#55447)
As per https://osmfoundation.org/wiki/Licence/Attribution_Guidelines:

> Attribution must be to “OpenStreetMap”.
[...]
> The historical forms of attribution “© OpenStreetMap contributors” or “© OpenStreetMap” are acceptable.
2026-10-29 15:27:49 +01:00
Erwin Douna 3f73049a3d Fade out energy period loading spinner (#55431) 2026-10-29 15:06:53 +01:00
Petar Petrov 50ad0c6cd0 Name new disk mounts after the partition's volume label (#55441) 2026-10-29 14:58:56 +01:00
Paul Bottein 78f95bbb06 Show a toast with a link to the dashboard after adding to it (#55444) 2026-10-29 14:36:46 +01:00
53 changed files with 238 additions and 2069 deletions

No files matched your search

+5 -3
View File
@@ -51,8 +51,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue or discussion:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to [documentation][docs-repository] pull request:
- Link to [contributors documentation][contributors-repository] pull request:
- Link to backend pull request:
## Checklist
@@ -63,7 +63,8 @@
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
understanding the code before submitting a PR. Please follow our AI policy:
https://contributors.home-assistant.io/start/ai-policy/
-->
- [ ] I understand the code I am submitting and can explain how it works.
@@ -105,4 +106,5 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[contributors-repository]: https://github.com/home-assistant/contributors.home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
+1 -2
View File
@@ -1,4 +1,3 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list";
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
@@ -215,7 +214,7 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
+2 -2
View File
@@ -101,7 +101,7 @@
"fullcalendar": "7.1.3",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.8.0",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
"js-yaml": "5.5.0",
@@ -110,7 +110,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.16.0",
"marked": "18.1.1",
"marked": "18.1.2",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
+10 -10
View File
@@ -364,8 +364,8 @@ importers:
specifier: 1.7.3
version: 1.7.3
home-assistant-js-websocket:
specifier: 9.8.0
version: 9.8.0
specifier: 9.8.1
version: 9.8.1
idb-keyval:
specifier: 6.3.0
version: 6.3.0
@@ -391,8 +391,8 @@ importers:
specifier: 6.16.0
version: 6.16.0
marked:
specifier: 18.1.1
version: 18.1.1
specifier: 18.1.2
version: 18.1.2
memoize-one:
specifier: 6.0.0
version: 6.0.0
@@ -5205,8 +5205,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==}
[email protected].0:
resolution: {integrity: sha512-Nl5asoKQJwJ5a8N0U72jkSAUdO+hsY/T+Upp0VaZMUSt6A2gtdyvKfBIEYbpnYK3NP83Q/YGaC5XYkO5c08Img==}
[email protected].1:
resolution: {integrity: sha512-WvovbrSoYLTKfh6aY5SxSWPPy50/mLWLCihQL8G2N+2e3mTrg3QTQCAp4R32/LwbkRBDGj0XNs6JFj8e2IYEUw==}
[email protected]:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
@@ -5907,8 +5907,8 @@ packages:
engines: {node: '>= 18'}
hasBin: true
[email protected].1:
resolution: {integrity: sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==}
[email protected].2:
resolution: {integrity: sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==}
engines: {node: '>= 20'}
hasBin: true
@@ -12962,7 +12962,7 @@ snapshots:
[email protected]: {}
[email protected].0: {}
[email protected].1: {}
[email protected]:
dependencies:
@@ -13689,7 +13689,7 @@ snapshots:
[email protected]: {}
[email protected].1: {}
[email protected].2: {}
[email protected]: {}
+10 -12
View File
@@ -4,8 +4,8 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-next";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import type { AuthProvider } from "../data/auth";
declare global {
@@ -32,21 +32,19 @@ export class HaPickAuthProvider extends LitElement {
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list>
<ha-list-base>
${this.authProviders.map(
(provider) => html`
<ha-list-item
hasMeta
role="button"
<ha-list-item-button
.auth_provider=${provider}
@click=${this._handlePick}
>
${provider.name}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<span slot="headline">${provider.name}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -80,9 +78,9 @@ export class HaPickAuthProvider extends LitElement {
background: var(--card-background-color);
padding: 0 15px;
}
ha-list {
ha-list-base {
margin: 16px -16px 0;
--mdc-list-side-padding: 24px;
--ha-row-item-padding-inline: 24px;
}
`;
}
@@ -1,312 +0,0 @@
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;
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
'<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
-408
View File
@@ -1,408 +0,0 @@
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
@@ -1,56 +0,0 @@
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;
};
+12 -6
View File
@@ -89,6 +89,8 @@ export const DEFAULT_CHART_WIDTH = 500;
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
export type ZoomSliderMode = "auto" | "always" | "never";
const DEFERRED_PROPS = [
"options",
"data",
@@ -163,9 +165,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "small-controls", type: Boolean })
public smallControls?: boolean;
// For charts zoomed from outside, like the synced History charts
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
// "auto" shows the zoom slider once zoomed. "never" hides every zoom
// control, for charts zoomed from outside like the synced History charts.
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
// For charts sized to their content, like timelines, which grow to fit the
// zoom slider instead of giving up plot height
@@ -617,7 +620,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
>
${
(this._isZoomed || this._isGraphRoamed) &&
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
!showZoomSlider
? html`<ha-icon-button
class="zoom-reset"
@@ -1064,9 +1067,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _showZoomSlider() {
const [start, end] = this._zoomRange;
return (
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
this._hasZoomableXAxis() &&
(this._zoomSliderHeld || start !== 0 || end !== 100)
(this.zoomSlider === "always" ||
this._zoomSliderHeld ||
start !== 0 ||
end !== 100)
);
}
@@ -24,6 +24,7 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import {
@@ -105,8 +106,8 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: LineSeriesOption[] = [];
@@ -159,7 +160,7 @@ export class StateHistoryChartLine extends LitElement {
@dataset-unhidden=${this._datasetUnhidden}
@chart-zoom=${this._handleDataZoom}
.expandLegend=${this.expandLegend}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
.clickLabelForMoreInfo=${this.clickForMoreInfo}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
@@ -22,6 +22,7 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -68,8 +69,8 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public chartIndex?;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: CustomSeriesOption[] = [];
@@ -105,7 +106,7 @@ export class StateHistoryChartTimeline extends LitElement {
grow-for-zoom-slider
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
></ha-chart-base>
`;
}
+2 -2
View File
@@ -218,7 +218,7 @@ export class StateHistoryCharts extends LitElement {
@chart-zoom-with-index=${this._handleTimelineSync}
.height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-line>
</div> `;
}
@@ -237,7 +237,7 @@ export class StateHistoryCharts extends LitElement {
.clickForMoreInfo=${this.clickForMoreInfo}
@y-width-changed=${this._yWidthChanged}
@chart-zoom-with-index=${this._handleTimelineSync}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-timeline>
</div> `;
};
+21 -12
View File
@@ -1,6 +1,5 @@
import { TZDate } from "@date-fns/tz";
import type { ContextType } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
@@ -17,6 +16,7 @@ import {
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
@@ -32,8 +32,8 @@ import type { HaBaseTimeInput } from "../ha-base-time-input";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-list";
import "../ha-list-item";
import "../item/ha-list-item-button";
import "../list/ha-list-base";
import "../ha-time-input";
import type { HaTimeInput } from "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
@@ -166,11 +166,19 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
return html`
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges!).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
<ha-list-base>
${Object.entries(this.ranges!).map(
([name, range], index) => html`
<ha-list-item-button
.range=${range}
.index=${index}
@click=${this._setDateRange}
>
<span slot="headline">${name}</span>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -343,11 +351,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._saveDateRangePreset(chip.range, chip.index);
}
private _setDateRange(ev: HASSDomEvent<ActionDetail>) {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
this._saveDateRangePreset(dateRange, ev.detail.index);
private _setDateRange(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { range: [Date, Date]; index: number }
>
) {
this._saveDateRangePreset(ev.currentTarget.range, ev.currentTarget.index);
}
private _saveDateRangePreset(range: [Date, Date], index: number) {
-52
View File
@@ -1,52 +0,0 @@
import { css } from "lit";
import { RadioListItemBase } from "@material/mwc-list/mwc-radio-list-item-base";
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import { customElement } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
@customElement("ha-radio-list-item")
export class HaRadioListItem extends RadioListItemBase {
async onChange(event) {
super.onChange(event);
fireEvent(this, event.type);
}
static override styles = [
styles,
controlStyles,
css`
:host {
--mdc-theme-secondary: var(--primary-color);
}
:host([graphic="avatar"]) .mdc-deprecated-list-item__graphic,
:host([graphic="medium"]) .mdc-deprecated-list-item__graphic,
:host([graphic="large"]) .mdc-deprecated-list-item__graphic,
:host([graphic="control"]) .mdc-deprecated-list-item__graphic {
margin-inline-end: var(--mdc-list-item-graphic-margin, 16px);
margin-inline-start: 0px;
direction: var(--direction);
}
.mdc-deprecated-list-item__meta {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--radio-list-item-graphic-margin-top);
}
:host([graphic="icon"]) .mdc-deprecated-list-item__graphic {
margin-inline-start: 0;
margin-inline-end: var(--mdc-list-item-graphic-margin, 32px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-radio-list-item": HaRadioListItem;
}
}
@@ -21,8 +21,6 @@ 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;
@@ -53,7 +51,6 @@ 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,8 +16,6 @@ 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;
@@ -29,7 +27,6 @@ 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,8 +17,6 @@ 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;
@@ -34,7 +32,6 @@ 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,7 +126,6 @@ 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;
@@ -176,8 +175,6 @@ 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;
@@ -190,11 +187,6 @@ 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,7 +43,6 @@ 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,7 +46,6 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-condition
path-key="if"
.conditions=${action.if ?? []}
.disabled=${this.disabled}
@value-changed=${this._ifChanged}
@@ -61,7 +60,6 @@ export class HaIfAction extends LitElement implements ActionElement {
)}:
</h4>
<ha-automation-action
path-key="then"
.actions=${action.then ?? []}
.disabled=${this.disabled}
@value-changed=${this._thenChanged}
@@ -75,7 +73,6 @@ 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,7 +35,6 @@ 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,7 +8,6 @@ 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";
@@ -41,11 +40,6 @@ 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,7 +35,6 @@ 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,7 +90,6 @@ 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}
@@ -1,209 +0,0 @@
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,7 +96,6 @@ 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;
@@ -114,8 +113,6 @@ 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;
@@ -127,11 +124,6 @@ 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,7 +29,6 @@ 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,12 +24,8 @@ 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 {
ConfigChangeUndoRedoController,
type ConfigChangeStep,
} from "../../../common/controllers/config-change-undo-redo-controller";
import { UndoRedoController } from "../../../common/controllers/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";
@@ -77,9 +73,7 @@ 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";
@@ -168,13 +162,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
checkValidation: () => this._checkValidation(),
};
private _undoRedoController = new ConfigChangeUndoRedoController<
AutomationConfig,
UndoStepMeta
>(this, {
apply: (config, step, undo) => this._applyUndoRedo(config, step, undo),
currentConfig: () => this.config,
onCopy: preserveAutomationRowKey,
private _undoRedoController = new UndoRedoController<AutomationConfig>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => this.config!,
});
public override get isDirtyState(): boolean {
@@ -525,8 +515,6 @@ 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}
@@ -831,9 +819,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _updateConfig(config: AutomationConfig) {
if (this.config) {
this._undoRedoController.commit(this.config, config, (changes) => ({
sidebarRow: this._manualEditor?.findSidebarRow(changes),
}));
this._undoRedoController.commit(this.config);
}
this.config = config;
@@ -1070,7 +1056,6 @@ 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({
@@ -1092,7 +1077,6 @@ 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,
@@ -1275,33 +1259,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._manualEditor?.deleteSelectedRow();
}
private _applyUndoRedo(
config: AutomationConfig,
step: ConfigChangeStep<UndoStepMeta>,
undo: boolean
) {
private _applyUndoRedo(config: AutomationConfig) {
this._manualEditor?.triggerCloseSidebar();
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,7 +7,6 @@ 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>();
@@ -76,13 +75,6 @@ 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,4 +1,3 @@
import { provide } from "@lit/context";
import { mdiContentSave } from "@mdi/js";
import {
html,
@@ -20,16 +19,6 @@ 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";
@@ -44,15 +33,8 @@ 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;
@@ -100,18 +82,6 @@ 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;
@@ -222,7 +192,6 @@ 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(
@@ -274,118 +243,6 @@ 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;
}
@@ -402,7 +259,6 @@ export const ManualEditorMixin = <TConfig>(
protected handleCloseSidebar() {
this.sidebarConfig = undefined;
this._sidebarRow = undefined;
this._bottomSheetHeight = undefined;
this.dispatchEvent(
new CustomEvent("sidebar-config-changed", {
@@ -111,7 +111,6 @@ 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}
@@ -150,7 +149,6 @@ 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}
@@ -185,7 +183,6 @@ 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,7 +64,6 @@ 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 {
@@ -90,11 +89,6 @@ 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;
@@ -445,7 +439,6 @@ export default class HaAutomationOptionRow extends LitElement {
)}:
</h4>
<ha-automation-condition
path-key="conditions"
.conditions=${ensureArray<string | Condition>(
this.option.conditions
)}
@@ -464,7 +457,6 @@ export default class HaAutomationOptionRow extends LitElement {
)}:
</h4>
<ha-automation-action
.pathKey=${this.option ? "sequence" : undefined}
.actions=${
(this.option
? ensureArray(this.option.sequence) || []
@@ -119,7 +119,6 @@ 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;
@@ -169,19 +168,12 @@ 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[];
@@ -731,16 +723,6 @@ 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 {
@@ -842,10 +824,8 @@ 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,7 +26,6 @@ export class HaTriggerList extends LitElement implements TriggerElement {
return html`
<ha-automation-trigger
path-key="triggers"
.triggers=${triggers}
.hass=${this.hass}
.disabled=${this.disabled}
+16 -16
View File
@@ -5,9 +5,10 @@ import { customElement, property, state } from "lit/decorators";
import { stringCompare } from "../../../common/string/compare";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-base";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import "../../../components/user/ha-person-badge";
import type { Person } from "../../../data/person";
import {
@@ -92,23 +93,22 @@ export class HaConfigPerson extends LitElement {
</span>
<ha-card outlined class="storage">
<ha-list>
<ha-list-base>
${this._storageItems.map(
(entry) => html`
<ha-list-item
graphic="avatar"
<ha-list-item-button
@click=${this._openEditEntry}
.entry=${entry}
>
<ha-person-badge
.person=${entry}
slot="graphic"
slot="start"
></ha-person-badge>
<span>${entry.name}</span>
</ha-list-item>
<span slot="headline">${entry.name}</span>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
${
this._storageItems.length === 0
? html`
@@ -138,19 +138,19 @@ export class HaConfigPerson extends LitElement {
this._configItems.length > 0
? html`
<ha-card outlined header="Configuration.yaml persons">
<ha-list>
<ha-list-base>
${this._configItems.map(
(entry) => html`
<ha-list-item graphic="avatar">
<ha-list-item-base>
<ha-person-badge
.person=${entry}
slot="graphic"
slot="start"
></ha-person-badge>
<span>${entry.name}</span>
</ha-list-item>
<span slot="headline">${entry.name}</span>
</ha-list-item-base>
`
)}
</ha-list>
</ha-list-base>
</ha-card>
`
: nothing
@@ -299,7 +299,7 @@ export class HaConfigPerson extends LitElement {
align-items: center;
justify-content: space-around;
}
ha-list:has(+ .empty) {
ha-list-base:has(+ .empty) {
display: none;
}
`;
+11 -49
View File
@@ -22,12 +22,8 @@ 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 {
ConfigChangeUndoRedoController,
type ConfigChangeStep,
} from "../../../common/controllers/config-change-undo-redo-controller";
import { UndoRedoController } from "../../../common/controllers/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";
@@ -68,12 +64,7 @@ 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";
@@ -111,13 +102,9 @@ export class HaScriptEditor extends SubscribeMixin(
checkValidation: () => this._checkValidation(),
};
private _undoRedoController = new ConfigChangeUndoRedoController<
ScriptConfig,
UndoStepMeta
>(this, {
apply: (config, step, undo) => this._applyUndoRedo(config, step, undo),
currentConfig: () => this.config,
onCopy: preserveAutomationRowKey,
private _undoRedoController = new UndoRedoController<ScriptConfig>(this, {
apply: (config) => this._applyUndoRedo(config),
currentConfig: () => this.config!,
});
public override get isDirtyState(): boolean {
@@ -456,8 +443,6 @@ 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}
@@ -682,13 +667,7 @@ export class HaScriptEditor extends SubscribeMixin(
private _valueChanged(ev) {
if (this.config) {
this._undoRedoController.commit(
this.config,
ev.detail.value,
(changes) => ({
sidebarRow: this._manualEditor?.findSidebarRow(changes),
})
);
this._undoRedoController.commit(this.config);
}
this.config = ev.detail.value;
@@ -778,7 +757,10 @@ export class HaScriptEditor extends SubscribeMixin(
return;
}
// Committed to the undo stack by the value-changed it fires
if (this.config) {
this._undoRedoController.commit(this.config);
}
this._manualEditor?.addFields();
this._updateDirtyState({
config: this.config!,
@@ -935,7 +917,6 @@ 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({
@@ -959,7 +940,6 @@ export class HaScriptEditor extends SubscribeMixin(
showAutomationModeDialog(this, {
config: this.config!,
updateConfig: (config) => {
this._undoRedoController.commit(this.config!, config);
this.config = config;
this._updateDirtyState({
config,
@@ -1140,33 +1120,15 @@ export class HaScriptEditor extends SubscribeMixin(
this._manualEditor?.deleteSelectedRow();
}
private _applyUndoRedo(
config: ScriptConfig,
step: ConfigChangeStep<UndoStepMeta>,
undo: boolean
) {
private _applyUndoRedo(config: ScriptConfig) {
this._manualEditor?.triggerCloseSidebar();
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,7 +151,6 @@ 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}
@@ -73,6 +73,10 @@ const mountPartitionLabel = ({
return drive ? `${drive} — ${identity}, ${size}` : `${identity}, ${size}`;
};
// Supervisor accepts only ASCII letters, digits and underscores.
const mountNameFromLabel = (label?: string): string =>
(label ?? "").replace(/^\W+|\W+$/g, "").replace(/\W+/g, "_");
const requiredFieldsFilled = (
schema: readonly HaFormSchema[],
data?: MountFormData
@@ -305,6 +309,8 @@ class ViewMountDialog extends DirtyStateProviderMixin<
private _originalType?: SupervisorMountType;
private _suggestedName?: string;
@state() private _diskIdentity?: string;
public connectedCallback(): void {
@@ -529,7 +535,9 @@ class ViewMountDialog extends DirtyStateProviderMixin<
private _valueChanged(ev: CustomEvent) {
this._validationError = {};
this._validationWarning = {};
const previous = this._data;
this._data = ev.detail.value;
this._suggestName(previous);
if (this._data?.name && !/^\w+$/.test(this._data.name)) {
this._validationError.name = "invalid_name";
}
@@ -556,6 +564,32 @@ class ViewMountDialog extends DirtyStateProviderMixin<
);
}
// Name a new disk mount after the partition's volume label, unless the user
// typed a name of their own.
private _suggestName(previous?: MountFormData) {
const data = this._data;
if (data?.name !== previous?.name) {
this._suggestedName = undefined;
}
if (
this._existing ||
data?.type !== SupervisorMountType.DISK ||
(data.device === previous?.device &&
previous?.type === SupervisorMountType.DISK) ||
(data.name && data.name !== this._suggestedName)
) {
return;
}
const name = mountNameFromLabel(
this._partitions?.find((entry) => entry.partition.device === data.device)
?.partition.label
);
if (name !== (data.name ?? "")) {
data.name = name;
}
this._suggestedName = name;
}
private async _connectMount(ev) {
const progressButton = ev.target as HaProgressButton;
this._error = undefined;
@@ -28,7 +28,7 @@ import type { ContextType } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import {
@@ -78,7 +78,6 @@ import type {
HomeAssistantConfig,
HomeAssistantInternationalization,
} from "../../../types";
import { animationStyles } from "../../../resources/theme/animations.globals";
const RANGE_KEYS: DateRange[] = [
"today",
@@ -151,6 +150,10 @@ export class HuiEnergyPeriodSelector extends LitElement {
@state() private _loading = false;
@state() private _loadingLeaving = false;
@query(".loading-indicator") private _loadingIndicator?: HTMLElement;
private _loadingTimer?: ReturnType<typeof setTimeout>;
private _resizeObserver?: ResizeObserver;
@@ -379,10 +382,15 @@ export class HuiEnergyPeriodSelector extends LitElement {
: nothing
}
${
this._loading
this._loading || this._loadingLeaving
? html`<ha-spinner
class="loading-indicator"
class=${classMap({
"loading-indicator": true,
leaving: this._loadingLeaving,
})}
size="small"
@transitionend=${this._loadingTransitionDone}
@transitioncancel=${this._loadingTransitionDone}
></ha-spinner>`
: nothing
}
@@ -727,6 +735,10 @@ export class HuiEnergyPeriodSelector extends LitElement {
private _updateDates(energyData: EnergyData): void {
clearTimeout(this._loadingTimer);
if (this._loading) {
this._loadingLeaving = true;
this._removeLoadingIndicatorWithoutTransition();
}
this._loading = false;
this._compare = energyData.startCompare !== undefined;
this._startDate = energyData.start;
@@ -759,10 +771,30 @@ export class HuiEnergyPeriodSelector extends LitElement {
// Basically to ensure there's no "flash loading" when data is loaded quickly
clearTimeout(this._loadingTimer);
this._loadingTimer = setTimeout(() => {
this._loadingLeaving = false;
this._loading = true;
}, 200);
}
private async _removeLoadingIndicatorWithoutTransition() {
await this.updateComplete;
// No transition starts when the spinner was still at opacity 0
this._removeIdleLoadingIndicator();
}
private _loadingTransitionDone(ev: TransitionEvent) {
if (ev.propertyName === "opacity") {
this._removeIdleLoadingIndicator();
}
}
private _removeIdleLoadingIndicator() {
// Reversing the fade-in cancels a transition while the fade-out runs
if (!this._loadingIndicator?.getAnimations().length) {
this._loadingLeaving = false;
}
}
private _getDatePickerPlacement = memoizeOne(
(
verticalOpeningDirection: VerticalOpeningDirection | undefined,
@@ -778,7 +810,6 @@ export class HuiEnergyPeriodSelector extends LitElement {
);
static styles = css`
${animationStyles}
:host {
display: block;
}
@@ -843,7 +874,15 @@ export class HuiEnergyPeriodSelector extends LitElement {
position: absolute;
right: var(--ha-space-2);
align-items: center;
animation: fade-in var(--ha-animation-duration-normal) ease-in-out;
transition: opacity var(--ha-animation-duration-normal) ease-in-out;
}
@starting-style {
.loading-indicator {
opacity: 0;
}
}
.loading-indicator.leaving {
opacity: 0;
}
.date-actions .overflow {
display: flex;
@@ -1,3 +1,4 @@
import { navigate } from "../../../common/navigate";
import type { LovelacePanelConfig } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@@ -13,8 +14,10 @@ import {
} from "../../../data/lovelace/config/types";
import { isStrategyView } from "../../../data/lovelace/config/view";
import { fetchDashboards } from "../../../data/lovelace/dashboard";
import { getPanelTitleFromUrlPath, LOVELACE_PANEL } from "../../../data/panel";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import { SECTIONS_VIEW_LAYOUT } from "../views/const";
import { getViewType } from "../views/get-view-type";
import type { SuggestCardDialogParams } from "./card-editor/show-suggest-card-dialog";
@@ -144,7 +147,23 @@ export const addEntitiesToLovelaceView = async (
await saveConfig(hass!, viewUrlPath, newConfig);
} catch (_err: any) {
alert(hass.localize("ui.panel.lovelace.add_entities.saving_failed"));
return;
}
const dashboardPath = viewUrlPath ?? LOVELACE_PANEL;
const viewPath = newConfig.views[viewIndex].path || viewIndex;
const dashboardTitle = getPanelTitleFromUrlPath(hass, dashboardPath);
showToast(element, {
message: dashboardTitle
? {
translationKey: "ui.panel.lovelace.add_entities.added",
args: { dashboard: dashboardTitle },
}
: { translationKey: "ui.panel.lovelace.add_entities.added_unnamed" },
action: {
text: { translationKey: "ui.panel.lovelace.add_entities.open" },
action: () => navigate(`/${dashboardPath}/${viewPath}`),
},
});
};
const view = config.views[viewIndex];
@@ -20,7 +20,6 @@ import {
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card";
import type { DeviceSectionCategory } from "../../strategies/device/device-section-strategy";
import {
@@ -253,7 +252,6 @@ export class HuiDialogAddDeviceCard
addCards(this._params.lovelaceConfig, this._params.path, this._cardConfig)
);
this._saving = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
}
@@ -17,7 +17,6 @@ import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/vie
import { isStrategyView } from "../../../../data/lovelace/config/view";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card";
import "../../sections/hui-section";
import { getViewType } from "../../views/get-view-type";
@@ -282,7 +281,6 @@ export class HuiDialogSuggestCard extends LitElement {
);
await this._params!.saveConfig(newConfig);
this._saving = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
}
}
@@ -7,6 +7,7 @@ import "../../../../components/ha-button";
import "../../../../components/ha-svg-icon";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import { computeUnusedEntities } from "../../common/compute-unused-entities";
import {
computeCards,
@@ -111,6 +112,11 @@ export class HuiUnusedEntities extends LitElement {
this._selectedEntities = ev.detail.selectedEntities;
}
private _saveConfig = async (config: LovelaceConfig): Promise<void> => {
await this.lovelace.saveConfig(config);
showSaveSuccessToast(this, this.hass);
};
private _addToLovelaceView(): void {
const cardConfig = computeCards(this.hass, this._selectedEntities, {});
const sectionConfig = computeSection(this._selectedEntities, {});
@@ -118,7 +124,7 @@ export class HuiUnusedEntities extends LitElement {
if (this.lovelace.config.views.length === 1) {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this.lovelace.saveConfig,
saveConfig: this._saveConfig,
path: ["views", 0],
entities: this._selectedEntities,
cardConfig,
@@ -132,7 +138,7 @@ export class HuiUnusedEntities extends LitElement {
viewSelectedCallback: (_urlPath, _selectedDashConfig, viewIndex) => {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this.lovelace.saveConfig,
saveConfig: this._saveConfig,
path: ["views", viewIndex],
entities: this._selectedEntities,
cardConfig,
@@ -0,0 +1,10 @@
// Install the Intl.PluralRules polyfill together with its locale data.
// FormatJS replaces Intl.PluralRules as soon as polyfill-force runs, and a
// data-less polyfill throws from select(). Keeping both imports in one module
// closes that gap for every caller, including the should-polyfill probes
// that other entry bundles run.
import "@formatjs/intl-pluralrules/polyfill-force";
// Locale data for plural rules breaks current JSON conversions as it includes functions,
// so only import English to avoid huge bundles
// TODO: Setup JS imports instead of JSON fetches
import "@formatjs/intl-pluralrules/locale-data/en";
+10 -15
View File
@@ -14,8 +14,6 @@ import {
polyfillTimeZoneData,
} from "./locale-data-polyfill";
let polyfilled = false;
const _polyfillTimeZoneData = polyfillTimeZoneData;
// The DateTimeFormat and DurationFormat polyfills call BigInt() when formatting
@@ -47,10 +45,6 @@ const setDefaultTimeZone = () => {
};
const polyfillIntl = async () => {
if (polyfilled) {
return;
}
polyfilled = true;
const locale = getLocalLanguage();
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillGetCanonicalLocales()) {
@@ -79,14 +73,10 @@ const polyfillIntl = async () => {
polyfills.push(import("@formatjs/intl-numberformat/polyfill-force"));
}
if (shouldPolyfillPluralRules(locale)) {
polyfills.push(
import("@formatjs/intl-pluralrules/polyfill-force").then(
// Locale data for plural rules breaks current JSON conversions as it includes functions,
// so only import English to avoid huge bundles
// TODO: Setup JS imports instead of JSON fetches
() => import("@formatjs/intl-pluralrules/locale-data/en")
)
);
// The polyfill and its English data are one chunk, so Intl.PluralRules
// is never installed without data. Its should-polyfill probe calls
// select(), which throws on a data-less polyfill.
polyfills.push(import("./intl-pluralrules"));
}
if (shouldPolyfillRelativeTimeFormat(locale)) {
polyfills.push(import("@formatjs/intl-relativetimeformat/polyfill-force"));
@@ -100,4 +90,9 @@ const polyfillIntl = async () => {
);
};
await polyfillIntl();
// Each legacy entry bundle (core, app, authorize, onboarding) carries its own
// copy of this module, and the bundles load in parallel. A module-level flag
// only stops re-runs within one bundle, so a second bundle could probe
// Intl.* while the first was still loading locale data. Share one promise
// per page instead, so every bundle waits for the same run.
await (globalThis.__haIntlPolyfill ??= polyfillIntl());
+4 -1
View File
@@ -10233,7 +10233,10 @@
"add_entities": {
"yaml_unsupported": "You cannot use this function when using your dashboard in YAML mode.",
"generated_unsupported": "You can only use this function when you have taken control of your dashboard.",
"saving_failed": "Saving dashboard configuration failed."
"saving_failed": "Saving dashboard configuration failed.",
"added": "Added to {dashboard}",
"added_unnamed": "Added to the dashboard",
"open": "Open"
},
"views": {
"unnamed_view": "Unnamed view",
+2
View File
@@ -41,6 +41,8 @@ declare global {
var __HASS_URL__: string;
// Read by intl-polyfill before a polyfill replaces Intl.DateTimeFormat
var __haNativeTimeZone: string | undefined;
// Shared by every legacy entry bundle so the Intl polyfills load once
var __haIntlPolyfill: Promise<void> | undefined;
// Handshake between the app entry and the extra modules in index.html
var __haScopedRegistryReady: boolean | undefined;
var __haLoadExtraModules: (() => void) | undefined;
@@ -1,206 +0,0 @@
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 }]);
});
});
@@ -1,142 +0,0 @@
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
@@ -1,162 +0,0 @@
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
@@ -1,31 +0,0 @@
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);
});
});
@@ -1,77 +0,0 @@
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,
});
});
});