mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-29 10:26:12 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a83a2d3227 | ||
|
|
8061ccbee9 | ||
|
|
459c501510 | ||
|
|
177e5afd4c | ||
|
|
c91b9dfa69 | ||
|
|
9537ada261 |
@@ -150,6 +150,11 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
return super.isDirtyState || !!this.yamlErrors;
|
||||
}
|
||||
|
||||
protected override resetEditorState() {
|
||||
super.resetEditorState();
|
||||
this._undoRedoController.reset();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
@@ -657,6 +662,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
);
|
||||
|
||||
const oldAutomationId = changedProps.get("automationId");
|
||||
this.resetOnItemSwitch(oldAutomationId, this.automationId);
|
||||
this.resetOnItemSwitch(changedProps.get("entityId"), this.entityId);
|
||||
|
||||
if (
|
||||
changedProps.has("automationId") &&
|
||||
this.automationId &&
|
||||
@@ -704,7 +712,11 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
}
|
||||
|
||||
if (changedProps.has("entityId") && this.entityId) {
|
||||
const generation = ++this.loadGeneration;
|
||||
getAutomationStateConfig(this.hass, this.entityId).then((c) => {
|
||||
if (generation !== this.loadGeneration) {
|
||||
return;
|
||||
}
|
||||
this.config = normalizeAutomationConfig(c.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
@@ -919,6 +931,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
|
||||
private async _takeControl() {
|
||||
const config = this.config as BlueprintAutomationConfig;
|
||||
const generation = this.loadGeneration;
|
||||
|
||||
try {
|
||||
const result = await substituteBlueprint(
|
||||
@@ -927,6 +940,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
config.use_blueprint.path,
|
||||
config.use_blueprint.input || {}
|
||||
);
|
||||
if (generation !== this.loadGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newConfig = {
|
||||
...normalizeAutomationConfig(result.substituted_config),
|
||||
@@ -1050,6 +1066,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
|
||||
await this._saveAutomation(id);
|
||||
if (!this.automationId) {
|
||||
this.justSavedId = id;
|
||||
navigate(`/config/automation/edit/${id}`, { replace: true });
|
||||
}
|
||||
}
|
||||
@@ -1059,7 +1076,8 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this.validationErrors = undefined;
|
||||
|
||||
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
|
||||
if (this.entityRegistryUpdate !== undefined && !this.currentEntityId) {
|
||||
const entityRegistryUpdate = this.entityRegistryUpdate;
|
||||
if (entityRegistryUpdate !== undefined && !this.currentEntityId) {
|
||||
this._newAutomationId = id;
|
||||
entityRegPromise = new Promise<EntityRegistryEntry>((resolve) => {
|
||||
this.entityRegCreated = resolve;
|
||||
@@ -1069,7 +1087,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
try {
|
||||
await saveAutomationConfig(this.hass, id, this.config!);
|
||||
|
||||
if (this.entityRegistryUpdate !== undefined) {
|
||||
if (entityRegistryUpdate !== undefined) {
|
||||
let entityId = this.currentEntityId;
|
||||
|
||||
// wait for automation to appear in entity registry when creating a new automation
|
||||
@@ -1104,10 +1122,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
if (entityId) {
|
||||
await updateEntityRegistryEntry(this.hass, entityId, {
|
||||
categories: {
|
||||
automation: this.entityRegistryUpdate.category || null,
|
||||
automation: entityRegistryUpdate.category || null,
|
||||
},
|
||||
labels: this.entityRegistryUpdate.labels || [],
|
||||
area_id: this.entityRegistryUpdate.area || null,
|
||||
labels: entityRegistryUpdate.labels || [],
|
||||
area_id: entityRegistryUpdate.area || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,16 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Id of an item that was just saved from the "new" editor. The follow-up
|
||||
* navigation from edit/new to edit/<id> reuses this element and must not
|
||||
* reset the editor state.
|
||||
*/
|
||||
protected justSavedId?: string;
|
||||
|
||||
/** Bumped when the edited item changes so stale item loads are ignored. */
|
||||
protected loadGeneration = 0;
|
||||
|
||||
private _relatedContextAreaId?: string;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
@@ -273,11 +283,56 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset per-item state when the edited item changes while this element is
|
||||
* reused (same route page, different id), emulating a freshly opened
|
||||
* editor. `saving` and `entityRegCreated` are deliberately absent: they
|
||||
* are owned by an in-flight save.
|
||||
*/
|
||||
protected resetEditorState() {
|
||||
this.loadGeneration++;
|
||||
this.config = undefined;
|
||||
this.mode = "gui";
|
||||
this.readOnly = false;
|
||||
this.errors = undefined;
|
||||
this.yamlErrors = undefined;
|
||||
this.validationErrors = undefined;
|
||||
this.blueprintConfig = undefined;
|
||||
this.deprecatedConfigMigrated = false;
|
||||
this.entityRegistryUpdate = undefined;
|
||||
this.currentEntityId = undefined;
|
||||
this._initDirtyTracking({ type: "deep" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke from `updated()` with a changed id property's old and new value.
|
||||
* Resets the editor state on a switch to a different item, except when
|
||||
* the old value is `undefined` (the first assignment on a fresh element)
|
||||
* or the id was just assigned by saving a new item.
|
||||
*/
|
||||
protected resetOnItemSwitch(
|
||||
oldId: string | null | undefined,
|
||||
newId: string | null
|
||||
) {
|
||||
if (oldId === undefined || oldId === newId) {
|
||||
return;
|
||||
}
|
||||
if (newId && newId === this.justSavedId) {
|
||||
this.justSavedId = undefined;
|
||||
} else {
|
||||
this.resetEditorState();
|
||||
}
|
||||
}
|
||||
|
||||
protected async loadConfig(id: string) {
|
||||
const hooks = this.domainHooks;
|
||||
const domain = hooks.domain;
|
||||
const generation = ++this.loadGeneration;
|
||||
try {
|
||||
const config = await hooks.fetchFileConfig(this.hass, id);
|
||||
if (generation !== this.loadGeneration) {
|
||||
return;
|
||||
}
|
||||
this.readOnly = false;
|
||||
const report: AutomationMigrationReport = { deprecated: false };
|
||||
this.config = hooks.normalizeConfig(config, report);
|
||||
@@ -294,6 +349,9 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
);
|
||||
hooks.checkValidation();
|
||||
} catch (err: any) {
|
||||
if (generation !== this.loadGeneration) {
|
||||
return;
|
||||
}
|
||||
if (err.status_code !== 404) {
|
||||
const alertText =
|
||||
err.body?.message || err.body || err.error || "Unknown error";
|
||||
|
||||
@@ -164,6 +164,16 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
|
||||
private _newSceneId?: string;
|
||||
|
||||
/**
|
||||
* Id of a scene that was just saved from the "new" editor. The follow-up
|
||||
* navigation from edit/new to edit/<id> reuses this element and must not
|
||||
* reset the editor state.
|
||||
*/
|
||||
private _justSavedId?: string;
|
||||
|
||||
/** Bumped when the edited scene changes so stale scene loads are ignored. */
|
||||
private _loadGeneration = 0;
|
||||
|
||||
private _entityRegCreated?: (
|
||||
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
|
||||
) => void;
|
||||
@@ -288,7 +298,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
<ha-svg-icon slot="icon" .path=${mdiPencil}></ha-svg-icon>
|
||||
</ha-dropdown-item>
|
||||
|
||||
<ha-dropdown-item value="toggle-yaml">
|
||||
<ha-dropdown-item value="toggle-yaml" .disabled=${!this._config}>
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.edit_${this._mode !== "yaml" ? "yaml" : "ui"}`
|
||||
)}
|
||||
@@ -667,6 +677,14 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
|
||||
const oldscene = changedProps.get("sceneId");
|
||||
|
||||
if (oldscene !== undefined && oldscene !== this.sceneId) {
|
||||
if (this.sceneId && this.sceneId === this._justSavedId) {
|
||||
this._justSavedId = undefined;
|
||||
} else {
|
||||
this._resetEditorState();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
changedProps.has("sceneId") &&
|
||||
this.sceneId &&
|
||||
@@ -839,15 +857,20 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
private _enterYamlMode() {
|
||||
if (this._mode === "live") {
|
||||
this._generateConfigFromLive();
|
||||
if (this._unsubscribeEvents) {
|
||||
this._unsubscribeEvents();
|
||||
this._unsubscribeEvents = undefined;
|
||||
}
|
||||
applyScene(this.hass, this._storedStates);
|
||||
this._stopLiveMode();
|
||||
}
|
||||
this._mode = "yaml";
|
||||
}
|
||||
|
||||
/** Unsubscribe from live updates and restore the pre-live entity states. */
|
||||
private _stopLiveMode() {
|
||||
if (this._unsubscribeEvents) {
|
||||
this._unsubscribeEvents();
|
||||
this._unsubscribeEvents = undefined;
|
||||
}
|
||||
applyScene(this.hass, this._storedStates);
|
||||
}
|
||||
|
||||
private async _toggleLiveMode() {
|
||||
if (this._mode === "live") {
|
||||
this._exitLiveMode();
|
||||
@@ -881,11 +904,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
|
||||
private _exitLiveMode() {
|
||||
this._generateConfigFromLive();
|
||||
if (this._unsubscribeEvents) {
|
||||
this._unsubscribeEvents();
|
||||
this._unsubscribeEvents = undefined;
|
||||
}
|
||||
applyScene(this.hass, this._storedStates);
|
||||
this._stopLiveMode();
|
||||
this._mode = "review";
|
||||
}
|
||||
|
||||
@@ -911,11 +930,15 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
}
|
||||
|
||||
private async _subscribeEvents() {
|
||||
this._unsubscribeEvents =
|
||||
await this.hass!.connection.subscribeEvents<HassEvent>(
|
||||
(event) => this._stateChanged(event),
|
||||
"state_changed"
|
||||
);
|
||||
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
|
||||
(event) => this._stateChanged(event),
|
||||
"state_changed"
|
||||
);
|
||||
if (this._mode !== "live") {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
this._unsubscribeEvents = unsubscribe;
|
||||
}
|
||||
|
||||
private _showMoreInfo(ev: Event) {
|
||||
@@ -924,10 +947,14 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
const generation = ++this._loadGeneration;
|
||||
let config: SceneConfig;
|
||||
try {
|
||||
config = await getSceneConfig(this.hass, this.sceneId!);
|
||||
} catch (err: any) {
|
||||
if (generation !== this._loadGeneration) {
|
||||
return;
|
||||
}
|
||||
await showAlertDialog(this, {
|
||||
text:
|
||||
err.status_code === 404
|
||||
@@ -943,6 +970,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
return;
|
||||
}
|
||||
|
||||
if (generation !== this._loadGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.entities) {
|
||||
config.entities = {};
|
||||
}
|
||||
@@ -958,6 +989,27 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
/** Emulate a freshly opened editor when the edited scene changes. */
|
||||
private _resetEditorState() {
|
||||
this._loadGeneration++;
|
||||
if (this._mode === "live") {
|
||||
this._stopLiveMode();
|
||||
}
|
||||
this._config = undefined;
|
||||
this._mode = "review";
|
||||
this._errors = undefined;
|
||||
this._yamlErrors = undefined;
|
||||
this._scene = undefined;
|
||||
this._entities = [];
|
||||
this._single_entities = [];
|
||||
this._devices = [];
|
||||
this._storedStates = {};
|
||||
this._activateContextId = undefined;
|
||||
this._entityRegistryUpdate = undefined;
|
||||
this._sceneRevision = 0;
|
||||
this._initDirtyTracking({ type: "shallow" }, 0);
|
||||
}
|
||||
|
||||
private _initEntities(config: SceneConfig) {
|
||||
this._entities = Object.keys(config.entities);
|
||||
this._single_entities = [];
|
||||
@@ -1255,7 +1307,8 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
this._saving = true;
|
||||
|
||||
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
|
||||
if (this._entityRegistryUpdate !== undefined && !this.sceneId) {
|
||||
const entityRegistryUpdate = this._entityRegistryUpdate;
|
||||
if (entityRegistryUpdate !== undefined && !this.sceneId) {
|
||||
this._newSceneId = id;
|
||||
entityRegPromise = new Promise<EntityRegistryEntry>((resolve) => {
|
||||
this._entityRegCreated = resolve;
|
||||
@@ -1266,7 +1319,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
await saveScene(this.hass, id, this._config!);
|
||||
this._errors = undefined;
|
||||
|
||||
if (this._entityRegistryUpdate !== undefined) {
|
||||
if (entityRegistryUpdate !== undefined) {
|
||||
let entityId = this._scene?.entity_id;
|
||||
|
||||
// wait for scene to appear in entity registry when creating a new scene
|
||||
@@ -1300,10 +1353,10 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
|
||||
if (entityId) {
|
||||
await updateEntityRegistryEntry(this.hass, entityId, {
|
||||
area_id: this._entityRegistryUpdate.area || null,
|
||||
labels: this._entityRegistryUpdate.labels || [],
|
||||
area_id: entityRegistryUpdate.area || null,
|
||||
labels: entityRegistryUpdate.labels || [],
|
||||
categories: {
|
||||
scene: this._entityRegistryUpdate.category || null,
|
||||
scene: entityRegistryUpdate.category || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1311,6 +1364,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
|
||||
this._markDirtyStateClean();
|
||||
if (isNewScene) {
|
||||
this._justSavedId = id;
|
||||
navigate(`/config/scene/edit/${id}`, { replace: true });
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -111,6 +111,11 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
return super.isDirtyState || !!this.yamlErrors;
|
||||
}
|
||||
|
||||
protected override resetEditorState() {
|
||||
super.resetEditorState();
|
||||
this._undoRedoController.reset();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
@@ -564,6 +569,12 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
super.updated(changedProps);
|
||||
|
||||
const oldScript = changedProps.get("scriptId");
|
||||
if (!this.entityId) {
|
||||
// the show route assigns scriptId internally
|
||||
this.resetOnItemSwitch(oldScript, this.scriptId);
|
||||
}
|
||||
this.resetOnItemSwitch(changedProps.get("entityId"), this.entityId);
|
||||
|
||||
if (
|
||||
changedProps.has("scriptId") &&
|
||||
this.scriptId &&
|
||||
@@ -609,7 +620,11 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
}
|
||||
|
||||
if (changedProps.has("entityId") && this.entityId) {
|
||||
const generation = ++this.loadGeneration;
|
||||
getScriptStateConfig(this.hass, this.entityId).then((c) => {
|
||||
if (generation !== this.loadGeneration) {
|
||||
return;
|
||||
}
|
||||
this.config = normalizeScriptConfig(c.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
@@ -827,6 +842,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
|
||||
private async _takeControl() {
|
||||
const config = this.config as BlueprintScriptConfig;
|
||||
const generation = this.loadGeneration;
|
||||
|
||||
try {
|
||||
const result = await substituteBlueprint(
|
||||
@@ -835,6 +851,9 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
config.use_blueprint.path,
|
||||
config.use_blueprint.input || {}
|
||||
);
|
||||
if (generation !== this.loadGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newConfig = {
|
||||
...normalizeScriptConfig(result.substituted_config),
|
||||
@@ -964,6 +983,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
|
||||
await this._saveScript(id);
|
||||
if (!this.scriptId) {
|
||||
this.justSavedId = String(id);
|
||||
navigate(`/config/script/edit/${id}`, { replace: true });
|
||||
}
|
||||
}
|
||||
@@ -972,7 +992,8 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
this.saving = true;
|
||||
|
||||
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
|
||||
if (this.entityRegistryUpdate !== undefined && !this.scriptId) {
|
||||
const entityRegistryUpdate = this.entityRegistryUpdate;
|
||||
if (entityRegistryUpdate !== undefined && !this.scriptId) {
|
||||
this._newScriptId = id.toString();
|
||||
entityRegPromise = new Promise<EntityRegistryEntry>((resolve) => {
|
||||
this.entityRegCreated = resolve;
|
||||
@@ -986,7 +1007,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
this.config
|
||||
);
|
||||
|
||||
if (this.entityRegistryUpdate !== undefined) {
|
||||
if (entityRegistryUpdate !== undefined) {
|
||||
let entityId = this.currentEntityId;
|
||||
|
||||
// wait for new script to appear in entity registry
|
||||
@@ -1022,10 +1043,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
if (entityId) {
|
||||
await updateEntityRegistryEntry(this.hass, entityId, {
|
||||
categories: {
|
||||
script: this.entityRegistryUpdate.category || null,
|
||||
script: entityRegistryUpdate.category || null,
|
||||
},
|
||||
labels: this.entityRegistryUpdate.labels || [],
|
||||
area_id: this.entityRegistryUpdate.area || null,
|
||||
labels: entityRegistryUpdate.labels || [],
|
||||
area_id: entityRegistryUpdate.area || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
/** Wait a macrotask so async work started by a lifecycle hook settles. */
|
||||
export const flush = () =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Simulate the router re-pointing a reused editor element at another item:
|
||||
* set the property, then invoke the lifecycle hook with the old value the
|
||||
* way Lit would after an update.
|
||||
*/
|
||||
export const runUpdated = (el: any, changed: Record<string, unknown>) => {
|
||||
el.updated(new Map(Object.entries(changed)));
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import "../../../../src/panels/config/automation/ha-automation-editor";
|
||||
import type { HaAutomationEditor } from "../../../../src/panels/config/automation/ha-automation-editor";
|
||||
import type { AutomationConfig } from "../../../../src/data/automation";
|
||||
import { createMockHass } from "../../../fixtures/hass";
|
||||
import { flush, runUpdated } from "../../../fixtures/lit";
|
||||
|
||||
const configFor = (alias: string): AutomationConfig => ({
|
||||
alias,
|
||||
triggers: [],
|
||||
conditions: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const createEditor = (): any => {
|
||||
const el = document.createElement(
|
||||
"ha-automation-editor"
|
||||
) as HaAutomationEditor;
|
||||
const hass = createMockHass();
|
||||
(hass as any).callApi = vi.fn(async (_method: string, path: string) =>
|
||||
configFor(path.split("/").pop()!)
|
||||
);
|
||||
(hass as any).callWS = vi.fn(async () => ({ config: configFor("state") }));
|
||||
el.hass = hass;
|
||||
el.automations = [];
|
||||
return el;
|
||||
};
|
||||
|
||||
describe("automation editor item switch", () => {
|
||||
test("switching to another automation resets the editor state", async () => {
|
||||
const el = createEditor();
|
||||
el.automationId = "a";
|
||||
runUpdated(el, { automationId: undefined });
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("a");
|
||||
|
||||
el.mode = "yaml";
|
||||
el.yamlErrors = "unparseable";
|
||||
el.errors = "save failed";
|
||||
el._undoRedoController.commit(configFor("a-edited"));
|
||||
expect(el._undoRedoController.canUndo).toBe(true);
|
||||
|
||||
el.automationId = "b";
|
||||
runUpdated(el, { automationId: "a" });
|
||||
|
||||
expect(el.mode).toBe("gui");
|
||||
expect(el.yamlErrors).toBeUndefined();
|
||||
expect(el.errors).toBeUndefined();
|
||||
expect(el._undoRedoController.canUndo).toBe(false);
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("b");
|
||||
expect(el.isDirtyState).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps state when the id change comes from saving a new automation", async () => {
|
||||
const el = createEditor();
|
||||
el.automationId = null;
|
||||
runUpdated(el, { automationId: undefined });
|
||||
expect(el.config).toBeDefined();
|
||||
|
||||
el.mode = "yaml";
|
||||
el.justSavedId = "123";
|
||||
el.automationId = "123";
|
||||
runUpdated(el, { automationId: null });
|
||||
|
||||
expect(el.mode).toBe("yaml");
|
||||
expect(el.justSavedId).toBeUndefined();
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("123");
|
||||
});
|
||||
|
||||
test("resets when leaving an unsaved new automation for an existing one", () => {
|
||||
const el = createEditor();
|
||||
el.automationId = null;
|
||||
runUpdated(el, { automationId: undefined });
|
||||
el.mode = "yaml";
|
||||
|
||||
el.automationId = "b";
|
||||
runUpdated(el, { automationId: null });
|
||||
|
||||
expect(el.mode).toBe("gui");
|
||||
});
|
||||
|
||||
test("resets when switching from an existing automation to a new one", async () => {
|
||||
const el = createEditor();
|
||||
el.automationId = "a";
|
||||
runUpdated(el, { automationId: undefined });
|
||||
await flush();
|
||||
el.mode = "yaml";
|
||||
|
||||
el.automationId = null;
|
||||
runUpdated(el, { automationId: "a" });
|
||||
|
||||
expect(el.mode).toBe("gui");
|
||||
expect(el.config?.triggers).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not reset on the first id assignment of a fresh element", async () => {
|
||||
const el = createEditor();
|
||||
el.yamlErrors = "sentinel";
|
||||
el.automationId = "a";
|
||||
runUpdated(el, { automationId: undefined });
|
||||
|
||||
expect(el.yamlErrors).toBe("sentinel");
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("a");
|
||||
});
|
||||
|
||||
test("ignores a config fetch that resolves after switching items", async () => {
|
||||
const el = createEditor();
|
||||
let resolveA!: (value: AutomationConfig) => void;
|
||||
(el.hass.callApi as any).mockImplementation(
|
||||
(_method: string, path: string) => {
|
||||
const id = path.split("/").pop();
|
||||
if (id === "a") {
|
||||
return new Promise((resolve) => {
|
||||
resolveA = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(configFor(id!));
|
||||
}
|
||||
);
|
||||
el.automationId = "a";
|
||||
runUpdated(el, { automationId: undefined });
|
||||
|
||||
el.automationId = "b";
|
||||
runUpdated(el, { automationId: "a" });
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("b");
|
||||
|
||||
resolveA(configFor("a"));
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("b");
|
||||
});
|
||||
|
||||
test("switching to another entity in the read-only view resets the editor state", async () => {
|
||||
const el = createEditor();
|
||||
el.entityId = "automation.one";
|
||||
runUpdated(el, { entityId: undefined });
|
||||
await flush();
|
||||
el.mode = "yaml";
|
||||
|
||||
el.entityId = "automation.two";
|
||||
runUpdated(el, { entityId: "automation.one" });
|
||||
|
||||
expect(el.mode).toBe("gui");
|
||||
expect(el.readOnly).toBe(true);
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("state");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import "../../../../src/panels/config/scene/ha-scene-editor";
|
||||
import type { HaSceneEditor } from "../../../../src/panels/config/scene/ha-scene-editor";
|
||||
import type { SceneConfig } from "../../../../src/data/scene";
|
||||
import { showSceneEditor } from "../../../../src/data/scene";
|
||||
import { createMockHass } from "../../../fixtures/hass";
|
||||
import { flush, runUpdated } from "../../../fixtures/lit";
|
||||
|
||||
const createEditor = (): any => {
|
||||
const el = document.createElement("ha-scene-editor") as HaSceneEditor;
|
||||
const hass = createMockHass();
|
||||
(hass as any).callApi = vi.fn(async (_method: string, path: string) => ({
|
||||
name: `${path.split("/").pop()} name`,
|
||||
entities: {},
|
||||
}));
|
||||
(hass as any).callService = vi.fn(async () => ({}));
|
||||
(hass as any).connection = { subscribeEvents: vi.fn(async () => vi.fn()) };
|
||||
el.hass = hass;
|
||||
el.scenes = [];
|
||||
return el;
|
||||
};
|
||||
|
||||
describe("scene editor item switch", () => {
|
||||
test("switching to another scene resets the editor state", async () => {
|
||||
const el = createEditor();
|
||||
el.sceneId = "scene_a";
|
||||
runUpdated(el, { sceneId: undefined });
|
||||
await flush();
|
||||
expect(el._config?.name).toBe("scene_a name");
|
||||
|
||||
el._mode = "yaml";
|
||||
el._yamlErrors = "unparseable";
|
||||
|
||||
el.sceneId = "scene_b";
|
||||
runUpdated(el, { sceneId: "scene_a" });
|
||||
|
||||
expect(el._mode).toBe("review");
|
||||
expect(el._yamlErrors).toBeUndefined();
|
||||
await flush();
|
||||
expect(el._config?.name).toBe("scene_b name");
|
||||
});
|
||||
|
||||
test("leaving live mode restores stored states and unsubscribes", async () => {
|
||||
const el = createEditor();
|
||||
el.sceneId = "scene_a";
|
||||
runUpdated(el, { sceneId: undefined });
|
||||
await flush();
|
||||
|
||||
const unsubscribe = vi.fn();
|
||||
const storedStates = { "light.kitchen": "on" };
|
||||
el._mode = "live";
|
||||
el._storedStates = storedStates;
|
||||
el._unsubscribeEvents = unsubscribe;
|
||||
|
||||
el.sceneId = "scene_b";
|
||||
runUpdated(el, { sceneId: "scene_a" });
|
||||
|
||||
expect(el.hass.callService).toHaveBeenCalledWith("scene", "apply", {
|
||||
entities: storedStates,
|
||||
});
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
expect(el._unsubscribeEvents).toBeUndefined();
|
||||
expect(el._mode).toBe("review");
|
||||
});
|
||||
|
||||
test("duplicating a scene opens the copy in review mode", async () => {
|
||||
const el = createEditor();
|
||||
el.sceneId = "scene_a";
|
||||
runUpdated(el, { sceneId: undefined });
|
||||
await flush();
|
||||
el._mode = "yaml";
|
||||
|
||||
showSceneEditor({ name: "scene_a name (Duplicate)", entities: {} });
|
||||
el.sceneId = null;
|
||||
runUpdated(el, { sceneId: "scene_a" });
|
||||
|
||||
expect(el._mode).toBe("review");
|
||||
expect(el._config?.name).toBe("scene_a name (Duplicate)");
|
||||
expect(el.hass.connection.subscribeEvents).not.toHaveBeenCalled();
|
||||
expect(el.isDirtyState).toBe(true);
|
||||
});
|
||||
|
||||
test("ignores a scene fetch that resolves after switching scenes", async () => {
|
||||
const el = createEditor();
|
||||
let resolveA!: (value: SceneConfig) => void;
|
||||
(el.hass.callApi as any).mockImplementation(
|
||||
(_method: string, path: string) => {
|
||||
if (path.endsWith("/scene_a")) {
|
||||
return new Promise((resolve) => {
|
||||
resolveA = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
name: `${path.split("/").pop()} name`,
|
||||
entities: {},
|
||||
});
|
||||
}
|
||||
);
|
||||
el.sceneId = "scene_a";
|
||||
runUpdated(el, { sceneId: undefined });
|
||||
|
||||
el.sceneId = "scene_b";
|
||||
runUpdated(el, { sceneId: "scene_a" });
|
||||
await flush();
|
||||
expect(el._config?.name).toBe("scene_b name");
|
||||
|
||||
resolveA({ name: "scene_a name", entities: {} });
|
||||
await flush();
|
||||
expect(el._config?.name).toBe("scene_b name");
|
||||
});
|
||||
|
||||
test("discards a live subscription that resolves after switching scenes", async () => {
|
||||
const el = createEditor();
|
||||
el.sceneId = "scene_a";
|
||||
runUpdated(el, { sceneId: undefined });
|
||||
await flush();
|
||||
|
||||
let resolveSubscribe!: (value: () => void) => void;
|
||||
el.hass.connection.subscribeEvents = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSubscribe = resolve;
|
||||
})
|
||||
);
|
||||
el._mode = "live";
|
||||
el._subscribeEvents();
|
||||
|
||||
el.sceneId = "scene_b";
|
||||
runUpdated(el, { sceneId: "scene_a" });
|
||||
|
||||
const unsubscribe = vi.fn();
|
||||
resolveSubscribe(unsubscribe);
|
||||
await flush();
|
||||
expect(unsubscribe).toHaveBeenCalled();
|
||||
expect(el._unsubscribeEvents).toBeUndefined();
|
||||
});
|
||||
|
||||
test("keeps state when the id change comes from saving a new scene", async () => {
|
||||
const el = createEditor();
|
||||
el.sceneId = null;
|
||||
runUpdated(el, { sceneId: undefined });
|
||||
expect(el._config).toBeDefined();
|
||||
|
||||
el._mode = "yaml";
|
||||
el._justSavedId = "123";
|
||||
el.sceneId = "123";
|
||||
runUpdated(el, { sceneId: null });
|
||||
|
||||
expect(el._mode).toBe("yaml");
|
||||
expect(el._justSavedId).toBeUndefined();
|
||||
await flush();
|
||||
expect(el._config?.name).toBe("123 name");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import "../../../../src/panels/config/script/ha-script-editor";
|
||||
import type { HaScriptEditor } from "../../../../src/panels/config/script/ha-script-editor";
|
||||
import type { ScriptConfig } from "../../../../src/data/script";
|
||||
import { createMockHass } from "../../../fixtures/hass";
|
||||
import { flush, runUpdated } from "../../../fixtures/lit";
|
||||
|
||||
const configFor = (alias: string): ScriptConfig => ({
|
||||
alias,
|
||||
sequence: [],
|
||||
});
|
||||
|
||||
const createEditor = (): any => {
|
||||
const el = document.createElement("ha-script-editor") as HaScriptEditor;
|
||||
const hass = createMockHass();
|
||||
(hass as any).callApi = vi.fn(async (_method: string, path: string) =>
|
||||
configFor(path.split("/").pop()!)
|
||||
);
|
||||
(hass as any).callWS = vi.fn(async () => ({ config: configFor("state") }));
|
||||
el.hass = hass;
|
||||
return el;
|
||||
};
|
||||
|
||||
describe("script editor item switch", () => {
|
||||
test("switching to another script resets the editor state", async () => {
|
||||
const el = createEditor();
|
||||
el.scriptId = "a";
|
||||
runUpdated(el, { scriptId: undefined });
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("a");
|
||||
|
||||
el.mode = "yaml";
|
||||
el.yamlErrors = "unparseable";
|
||||
el._undoRedoController.commit(configFor("a-edited"));
|
||||
expect(el._undoRedoController.canUndo).toBe(true);
|
||||
|
||||
el.scriptId = "b";
|
||||
runUpdated(el, { scriptId: "a" });
|
||||
|
||||
expect(el.mode).toBe("gui");
|
||||
expect(el.yamlErrors).toBeUndefined();
|
||||
expect(el._undoRedoController.canUndo).toBe(false);
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("b");
|
||||
});
|
||||
|
||||
test("keeps state when the id change comes from saving a new script", async () => {
|
||||
const el = createEditor();
|
||||
el.scriptId = null;
|
||||
runUpdated(el, { scriptId: undefined });
|
||||
expect(el.config).toBeDefined();
|
||||
|
||||
el.mode = "yaml";
|
||||
el.justSavedId = "123";
|
||||
el.scriptId = "123";
|
||||
runUpdated(el, { scriptId: null });
|
||||
|
||||
expect(el.mode).toBe("yaml");
|
||||
expect(el.justSavedId).toBeUndefined();
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("123");
|
||||
});
|
||||
|
||||
test("switching to another entity in the read-only view resets the editor state", async () => {
|
||||
const el = createEditor();
|
||||
el.entityRegistry = [
|
||||
{ entity_id: "script.one", unique_id: "one", platform: "script" },
|
||||
{ entity_id: "script.two", unique_id: "two", platform: "script" },
|
||||
];
|
||||
el.entityId = "script.one";
|
||||
runUpdated(el, { entityId: undefined });
|
||||
expect(el.scriptId).toBe("one");
|
||||
await flush();
|
||||
|
||||
// the show route assigns scriptId internally; that must not reset
|
||||
el.yamlErrors = "sentinel";
|
||||
runUpdated(el, { scriptId: null });
|
||||
expect(el.yamlErrors).toBe("sentinel");
|
||||
|
||||
el.mode = "yaml";
|
||||
el.entityId = "script.two";
|
||||
runUpdated(el, { entityId: "script.one" });
|
||||
|
||||
expect(el.mode).toBe("gui");
|
||||
expect(el.readOnly).toBe(true);
|
||||
expect(el.scriptId).toBe("two");
|
||||
await flush();
|
||||
expect(el.config?.alias).toBe("state");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user