mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-30 01:24:19 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f11dc4580 | ||
|
|
27489f188e | ||
|
|
97fffc3b43 | ||
|
|
a30193b8ef | ||
|
|
cf063dc19f | ||
|
|
5db829b7be | ||
|
|
421f8b1986 | ||
|
|
a140eedc6e | ||
|
|
2933568658 | ||
|
|
29faeaf7fa | ||
|
|
95ba532ceb | ||
|
|
352ecb9d6d | ||
|
|
3f73049a3d | ||
|
|
50ad0c6cd0 | ||
|
|
78f95bbb06 |
No files matched your search
@@ -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,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
@@ -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",
|
||||
|
||||
Generated
+10
-10
@@ -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]: {}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
'© <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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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> `;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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";
|
||||
@@ -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());
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user