Compare commits

..

14 Commits

Author SHA1 Message Date
Claude 3d271ba1df Clamp the column count to the config instead of caching it in the resize callback 2026-07-25 22:23:13 +00:00
Claude 955d750b4b Merge branch 'dev' into claude/sections-view-column-count 2026-07-25 22:16:39 +00:00
Paulus Schoutsen 5a6a16ed27 Fix gallery type errors by letting registry mocks take the demo area and floor shapes (#53302)
Let gallery mocks use the demo area and floor shapes

The gallery passed full AreaRegistryEntry and FloorRegistryEntry objects to
mockAreaRegistry and mockFloorRegistry. Those data parameters were dropped
when the registries became demo-config-owned, which broke the gallery build.

Instead of restoring the verbose registry-entry parameters, have the mocks
accept the same DemoArea and DemoFloor shapes the demo configs use and seed
the registry through setDemoAreas/setDemoFloors. The gallery fixtures now
only spell out the fields they care about, matching how the other registry
mocks take their initial data.


Claude-Session: https://claude.ai/code/session_01PP4xVL8aDg5QAefLSmxY4C

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-25 18:15:30 -04:00
Claude 2d5a8bc5fe Measure the host in the resize callback instead of the observer entries
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV
2026-07-25 21:48:17 +00:00
Claude 2e4a35a233 Recompute sections view column count when the config changes
The sections view element is reused when navigating between two sections
views, so a view with a different max_columns kept the column count of the
previous view until a resize made the ResizeObserver fire again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV
2026-07-25 21:34:02 +00:00
Aidan Timson 3d1ed43edc Automatically activate frontend updates, support accessible toast actions, publish global dirty state (#53124)
* Support accessible toast actions

* Publish global dirty state changes

* Automatically activate frontend updates

* Add baseline tests for update dependencies

* Cover update dependency behaviour

* Render primary toast actions as filled

* Test notification toast lifecycle

* Document dirty state provider ownership

* Publish dirty state from editor updates

* Aggregate connected dirty state providers

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-25 11:18:34 +02:00
Paulus Schoutsen d682830a3e Register the hidden default dashboard panels in the demo (#53278)
Register the light, climate, security and maintenance summary panels in
the demo, hidden from the sidebar like the home panel. This makes them
routable so strategy-generated dashboards can navigate to them.


Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-25 11:15:05 +02:00
dependabot[bot] 863aad24fc Bump js-yaml from 5.2.1 to 5.2.2 (#53292)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 5.2.1 to 5.2.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/5.2.1...5.2.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 5.2.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 11:13:12 +02:00
Paulus Schoutsen 3284633ac9 Let demo configs provide areas and floors (#53280)
Make areas and floors part of the individual demo config data instead
of a single global registry:

- DemoConfig gains optional floors and areas, applied on initial load
  and when switching demos, so each demo brings (and clears) its own
  registries
- Areas can set a temperature and humidity entity
- Mock entities can be assigned to an area via EntityInput.area_id,
  which flows into the mocked entity registry used by area filtering


Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-25 11:03:27 +02:00
renovate[bot] 8dd54e0aa8 Update dependency lint-staged to v17.1.1 (#53290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 11:01:06 +02:00
renovate[bot] 90a79d458f Update dependency tar to v7.5.21 (#53287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-25 11:01:04 +02:00
Joakim Plate 34dba7ea5a Normalize number entity row input (#53284)
hui-number-entity-row.ts bound the raw stateObj.state string to the
non-slider ha-input, so a state like "50.0" showed decimals on the
device page. state-card-number.ts (used by the more-info dialog)
already normalizes via Number(state).toString(); apply the same here
so both views render consistently.
2026-07-24 18:25:56 +02:00
renovate[bot] e76e7d1064 Unpin dependency glob resolution (#53282)
* Pin dependency glob to 10.5.0

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-07-24 15:39:16 +00:00
Paulus Schoutsen b6675159ae Mock the usage prediction command in the demo (#53279)
Mock usage_prediction/common_control and load the usage_prediction
component so common-controls sections in strategy dashboards render
suggested entities in the demo. Entities that don't exist in the
currently loaded demo config are filtered out by the section strategy.


Claude-Session: https://claude.ai/code/session_01KZ6ydA22VeQndgynH4SxCV

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-24 17:20:29 +02:00
46 changed files with 1673 additions and 866 deletions
+4
View File
@@ -1,6 +1,8 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { setDemoAreas } from "../stubs/area_registry";
import { energyEntities } from "../stubs/entities";
import { setDemoFloors } from "../stubs/floor_registry";
import { getDemoTheme } from "../stubs/frontend";
import type { DemoConfig, DemoTheme } from "./types";
@@ -37,6 +39,8 @@ export const setDemoConfig = async (
selectedDemoConfigIndex = index;
selectedDemoConfig = confProm;
setDemoFloors(hass, config.floors);
setDemoAreas(hass, config.areas);
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(energyEntities());
lovelace.saveConfig(config.lovelace(hass.localize));
+4
View File
@@ -3,6 +3,8 @@ import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { ThemeSettings } from "../../../src/types";
import type { DemoArea } from "../stubs/area_registry";
import type { DemoFloor } from "../stubs/floor_registry";
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
@@ -15,5 +17,7 @@ export interface DemoConfig {
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
floors?: DemoFloor[];
areas?: DemoArea[];
theme: DemoTheme;
}
+15 -5
View File
@@ -6,7 +6,7 @@ import { provideHass } from "../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry } from "./stubs/area_registry";
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
import { mockAuth } from "./stubs/auth";
import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
@@ -14,7 +14,7 @@ import { mockEnergy } from "./stubs/energy";
import { energyEntities } from "./stubs/entities";
import { mockEntityRegistry } from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry } from "./stubs/floor_registry";
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
import { mockFrontend } from "./stubs/frontend";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
@@ -29,6 +29,7 @@ import { mockSystemLog } from "./stubs/system_log";
import { mockTemplate } from "./stubs/template";
import { mockTodo } from "./stubs/todo";
import { mockTranslations } from "./stubs/translations";
import { mockUsagePrediction } from "./stubs/usage_prediction";
import "./cloud/cloud-demo-controls";
// WS command / REST path prefixes whose mocks live in the lazily imported
@@ -74,11 +75,17 @@ export class HaDemo extends HomeAssistantAppEl {
// The cloud account page only fetches backup config and the webhook count
// when those integrations are loaded. Enable them here (demo only) so the
// mocked backup/config/info and webhook/list are queried.
// mocked backup/config/info and webhook/list are queried. usage_prediction
// is needed for common-controls sections in strategy dashboards.
hass.updateHass({
config: {
...hass.config,
components: [...(hass.config?.components ?? []), "backup", "webhook"],
components: [
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
],
},
});
@@ -122,6 +129,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
@@ -169,9 +177,11 @@ export class HaDemo extends HomeAssistantAppEl {
hass.addEntities(energyEntities());
// Once config is loaded AND localize, set entities and apply theme.
// Once config is loaded AND localize, set registries, entities and theme.
Promise.all([selectedDemoConfig, localizePromise]).then(
([conf, localize]) => {
setDemoFloors(hass, conf.floors);
setDemoAreas(hass, conf.areas);
hass.addEntities(conf.entities(localize));
applyDemoTheme(hass, conf.theme);
}
+39 -7
View File
@@ -1,14 +1,46 @@
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export interface DemoArea {
area_id: string;
name: string;
floor_id?: string;
icon?: string;
temperature_entity_id?: string;
humidity_entity_id?: string;
}
let areas: AreaRegistryEntry[] = [];
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: AreaRegistryEntry[] = []
data: DemoArea[] = []
) => {
hass.mockWS("config/area_registry/list", () => data);
const areas = {};
data.forEach((area) => {
areas[area.area_id] = area;
});
hass.updateHass({ areas });
hass.mockWS("config/area_registry/list", () => areas);
setDemoAreas(hass, data);
};
/** Set the areas of the currently loaded demo config. */
export const setDemoAreas = (
hass: MockHomeAssistant,
demoAreas: DemoArea[] = []
) => {
areas = demoAreas.map((area) => ({
area_id: area.area_id,
name: area.name,
floor_id: area.floor_id ?? null,
icon: area.icon ?? null,
temperature_entity_id: area.temperature_entity_id ?? null,
humidity_entity_id: area.humidity_entity_id ?? null,
aliases: [],
labels: [],
picture: null,
created_at: 0,
modified_at: 0,
}));
const areasById: Record<string, AreaRegistryEntry> = {};
areas.forEach((area) => {
areasById[area.area_id] = area;
});
hass.updateHass({ areas: areasById });
};
+35 -2
View File
@@ -1,7 +1,40 @@
import type { FloorRegistryEntry } from "../../../src/data/floor_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export interface DemoFloor {
floor_id: string;
name: string;
level?: number;
icon?: string;
}
let floors: FloorRegistryEntry[] = [];
export const mockFloorRegistry = (
hass: MockHomeAssistant,
data: FloorRegistryEntry[] = []
) => hass.mockWS("config/floor_registry/list", () => data);
data: DemoFloor[] = []
) => {
hass.mockWS("config/floor_registry/list", () => floors);
setDemoFloors(hass, data);
};
/** Set the floors of the currently loaded demo config. */
export const setDemoFloors = (
hass: MockHomeAssistant,
demoFloors: DemoFloor[] = []
) => {
floors = demoFloors.map((floor) => ({
floor_id: floor.floor_id,
name: floor.name,
level: floor.level ?? null,
icon: floor.icon ?? null,
aliases: [],
created_at: 0,
modified_at: 0,
}));
const floorsById: Record<string, FloorRegistryEntry> = {};
floors.forEach((floor) => {
floorsById[floor.floor_id] = floor;
});
hass.updateHass({ floors: floorsById });
};
+19
View File
@@ -0,0 +1,19 @@
import type { CommonControlsResult } from "../../../src/data/usage_prediction";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUsagePrediction = (hass: MockHomeAssistant) => {
// Entities that don't exist in the currently loaded demo config are
// filtered out by the common-controls section strategy.
hass.mockWS("usage_prediction/common_control", (): CommonControlsResult => ({
entities: [
"light.living_room_floor_lamp",
"climate.living_room",
"cover.living_room_blinds",
"media_player.living_room_speaker",
"light.kitchen_spotlights",
"switch.coffee_machine",
"light.bedside_lamp",
"alarm_control_panel.home_alarm",
],
}));
};
+5 -28
View File
@@ -2,7 +2,10 @@
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import {
mockAreaRegistry,
type DemoArea,
} from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
@@ -10,7 +13,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
import "../../../../src/components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
@@ -136,45 +138,20 @@ const DEVICES: DeviceRegistryEntry[] = [
},
];
const AREAS: AreaRegistryEntry[] = [
const AREAS: DemoArea[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "livingroom",
floor_id: null,
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
+10 -38
View File
@@ -1,21 +1,25 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import {
mockAreaRegistry,
type DemoArea,
} from "../../../../demo/src/stubs/area_registry";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import {
mockFloorRegistry,
type DemoFloor,
} from "../../../../demo/src/stubs/floor_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
import {
showDialog,
@@ -147,75 +151,43 @@ const DEVICES: DeviceRegistryEntry[] = [
},
];
const AREAS: AreaRegistryEntry[] = [
const AREAS: DemoArea[] = [
{
area_id: "backyard",
floor_id: "ground",
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: "first",
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "livingroom",
floor_id: "ground",
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
const FLOORS: FloorRegistryEntry[] = [
const FLOORS: DemoFloor[] = [
{
floor_id: "ground",
name: "Ground floor",
level: 0,
icon: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
{
floor_id: "first",
name: "First floor",
level: 1,
icon: "mdi:numeric-1",
aliases: [],
created_at: 0,
modified_at: 0,
},
{
floor_id: "second",
name: "Second floor",
level: 2,
icon: "mdi:numeric-2",
aliases: [],
created_at: 0,
modified_at: 0,
},
];
+4 -5
View File
@@ -108,7 +108,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.12",
"js-yaml": "5.2.1",
"js-yaml": "5.2.2",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -196,7 +196,7 @@
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.1.0",
"lint-staged": "17.1.1",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -207,7 +207,7 @@
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.1.0",
"tar": "7.5.20",
"tar": "7.5.21",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
@@ -226,8 +226,7 @@
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.7.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "yarn@4.17.1",
"volta": {
@@ -1,15 +0,0 @@
import type { EntityIdPart } from "../../data/entity_id_format";
import { slugify } from "../string/slugify";
export const computeEntityIdFormatExample = (
format: EntityIdPart[],
examples: Record<EntityIdPart, string>
): string => {
const parts = format
.map((item) => examples[item])
.filter(Boolean)
.map((part) => slugify(part, "_"))
.filter(Boolean);
return parts.join("_") || "unknown";
};
+1 -1
View File
@@ -126,7 +126,7 @@ export class HaProgressButton extends LitElement {
visibility: hidden;
}
.progress ha-svg-icon {
:host([appearance="brand"]) ha-svg-icon {
color: var(--white-color);
}
`;
-1
View File
@@ -27,7 +27,6 @@ export class HaInputChip extends InputChip {
);
--ha-input-chip-selected-container-opacity: 1;
--md-input-chip-label-text-font: Roboto, sans-serif;
--md-input-chip-label-text-weight: 400;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
+22 -2
View File
@@ -24,6 +24,8 @@ export interface ToastClosedEventDetail {
export class HaToast extends LitElement {
@property({ attribute: "label-text" }) public labelText = "";
@property({ attribute: "announce-text" }) public announceText?: string;
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
@@ -190,8 +192,6 @@ export class HaToast extends LitElement {
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
role="status"
aria-live="polite"
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<span class="message">${this.labelText}</span>
@@ -200,6 +200,14 @@ export class HaToast extends LitElement {
<slot name="dismiss"></slot>
</div>
</div>
<span
class="assistive-message"
role="status"
aria-live=${this._active ? "polite" : "off"}
aria-atomic="true"
>
${this.announceText ?? this.labelText}
</span>
`;
}
@@ -254,6 +262,18 @@ export class HaToast extends LitElement {
min-width: 0;
}
.assistive-message {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.actions {
display: flex;
align-items: center;
+10
View File
@@ -36,3 +36,13 @@ export interface DirtyStateContext<
* boundary.
*/
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
declare global {
interface Window {
isDirtyState?: boolean;
}
interface HASSDomEvents {
"dirty-state-changed": { isDirty: boolean };
}
}
@@ -1,22 +0,0 @@
import type { HomeAssistantApi } from "../../types";
import type { EntityIdFormat } from "../entity_id_format";
export interface EntityRegistrySettings {
entity_id_parts: EntityIdFormat | null;
}
export const fetchEntityRegistrySettings = (
api: HomeAssistantApi
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/get",
});
export const updateEntityRegistrySettings = (
api: HomeAssistantApi,
updates: Partial<EntityRegistrySettings>
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/update",
...updates,
});
-12
View File
@@ -1,12 +0,0 @@
export type EntityIdPart = "area" | "device" | "entity" | "floor";
export type EntityIdFormat = EntityIdPart[];
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
"area",
"device",
"entity",
];
export const isDefaultEntityIdFormat = (format: EntityIdFormat): boolean =>
JSON.stringify(format) === JSON.stringify(DEFAULT_ENTITY_ID_FORMAT);
@@ -54,6 +54,7 @@ export class HaImagecropperDialog
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (!changedProperties.has("_params") || !this._params) {
return;
}
+32
View File
@@ -16,6 +16,38 @@ export const demoPanels: Panels = {
config: null,
url_path: "home",
},
light: {
component_name: "light",
icon: "mdi:lamps",
title: "light",
default_visible: false,
config: null,
url_path: "light",
},
climate: {
component_name: "climate",
icon: "mdi:home-thermometer",
title: "climate",
default_visible: false,
config: null,
url_path: "climate",
},
security: {
component_name: "security",
icon: "mdi:security",
title: "security",
default_visible: false,
config: null,
url_path: "security",
},
maintenance: {
component_name: "maintenance",
icon: "mdi:wrench",
title: "maintenance",
default_visible: false,
config: null,
url_path: "maintenance",
},
"dev-state": {
component_name: "dev-state",
icon: null,
+3
View File
@@ -27,6 +27,8 @@ export class MockBaseEntity {
public state: string;
public areaId?: string;
public baseAttributes: EntityAttributes;
public attributes: EntityAttributes;
@@ -47,6 +49,7 @@ export class MockBaseEntity {
this.domain = domain;
this.objectId = objectId;
this.state = input.state;
this.areaId = input.area_id;
this.lastChanged = randomTime();
this.lastUpdated = randomTime();
+4 -1
View File
@@ -10,7 +10,10 @@ export type EntityAttributes = HassEntityAttributeBase & Record<string, any>;
export type EntityInput = Pick<
HassEntity,
"entity_id" | "state" | "attributes"
>;
> & {
/** Area the entity is assigned to in the mocked entity registry */
area_id?: string;
};
/**
* The hass mock object interface, kept intentionally loose
+1
View File
@@ -299,6 +299,7 @@ export const provideHass = (
icon: undefined,
platform: "demo",
labels: [],
area_id: ent.areaId,
} satisfies EntityRegistryDisplayEntry;
});
if (replace) {
+50 -26
View File
@@ -13,8 +13,13 @@ export interface ShowToastParams {
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
id?: string;
message:
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
announceMessage?:
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
action?: ToastActionParams;
secondaryAction?: ToastActionParams;
dismiss?: () => void;
duration?: number;
dismissable?: boolean;
@@ -23,8 +28,10 @@ export interface ShowToastParams {
export interface ToastActionParams {
action: () => void;
primary?: boolean;
text:
string | { translationKey: LocalizeKeys; args?: Record<string, string> };
| string
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
}
@customElement("notification-manager")
@@ -93,31 +100,22 @@ class NotificationManager extends LitElement {
)
: this._parameters.message
}
.announceText=${
this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined
}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
>
${
this._parameters?.action
? html`
<ha-button
appearance="plain"
size="s"
slot="action"
@click=${this._buttonClicked}
>
${
typeof this._parameters?.action.text !== "string"
? this.hass.localize(
this._parameters?.action.text.translationKey,
this._parameters?.action.text.args
)
: this._parameters?.action.text
}
</ha-button>
`
: nothing
}
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.dismissable
? html`
@@ -134,11 +132,37 @@ class NotificationManager extends LitElement {
`;
}
private _renderAction(
action: ToastActionParams | undefined,
secondary: boolean
) {
if (!action) {
return nothing;
}
return html`
<ha-button
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@click=${secondary ? this._secondaryButtonClicked : this._buttonClicked}
>
${
typeof action.text !== "string"
? this.hass.localize(action.text.translationKey, action.text.args)
: action.text
}
</ha-button>
`;
}
private _buttonClicked() {
this._toast?.hide("action");
if (this._parameters?.action) {
this._parameters?.action.action();
}
this._parameters?.action?.action();
}
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
}
private _dismissClicked() {
+35 -1
View File
@@ -1,7 +1,8 @@
import { provide } from "@lit/context";
import deepClone from "deep-clone-simple";
import type { LitElement } from "lit";
import type { LitElement, PropertyValues } from "lit";
import { state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { deepEqual } from "../common/util/deep-equal";
import { shallowEqual } from "../common/util/shallow-equal";
import {
@@ -17,6 +18,18 @@ export type CompareStrategy<State> =
| { type: "shallow" }
| { type: "custom"; compare: (a: State, b: State) => boolean };
const connectedDirtyStateProviders = new Map<object, boolean>();
const publishGlobalDirtyState = (): void => {
const isDirty = Array.from(connectedDirtyStateProviders.values()).some(
Boolean
);
if (isDirty !== window.isDirtyState) {
window.isDirtyState = isDirty;
fireEvent(window, "dirty-state-changed", { isDirty });
}
};
/**
* Mixin that provides dirty-state tracking via Lit context.
*
@@ -26,6 +39,9 @@ export type CompareStrategy<State> =
* so independent contributors (e.g. a helper form alongside the entity
* registry editor) can coexist without overwriting each other.
*
* Connected providers contribute to the global dirty state. It remains dirty
* while any provider has unsaved changes.
*
* `isEffectiveDirty` runs the same comparison, but first passes each slice's
* initial and current value through the optional `effectiveNormalize` function
* given to `_initDirtyTracking`. Provide a normalizer that collapses values you
@@ -118,6 +134,24 @@ export const DirtyStateProviderMixin =
this._dirtyStateContext = this._buildContextValue();
}
public connectedCallback(): void {
super.connectedCallback();
connectedDirtyStateProviders.set(this, this.isDirtyState);
publishGlobalDirtyState();
}
protected updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
connectedDirtyStateProviders.set(this, this.isDirtyState);
publishGlobalDirtyState();
}
public disconnectedCallback(): void {
connectedDirtyStateProviders.delete(this);
publishGlobalDirtyState();
super.disconnectedCallback();
}
private _writeSlice(key: Key | DefaultDirtyStateKey, value: State): void {
const slice = this._dirtySlices.get(key);
if (!slice) {
@@ -203,6 +203,7 @@ class DialogBackupOnboarding
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_step") && this._step === "key") {
this._save();
}
-9
View File
@@ -22,7 +22,6 @@ import {
mdiPuzzle,
mdiRadioTower,
mdiRemote,
mdiRenameOutline,
mdiRobot,
mdiScrewdriver,
mdiScriptText,
@@ -495,14 +494,6 @@ export const configSections: Record<string, PageNavigation[]> = {
component: "backup",
adminOnly: true,
},
{
path: "/config/entity-id-format",
translationKey: "entity_id_format",
iconPath: mdiRenameOutline,
iconColor: "#24a5cb",
core: true,
adminOnly: true,
},
{
path: "/config/analytics",
translationKey: "analytics",
@@ -1,229 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import { mdiRestore } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
import { apiContext, configContext } from "../../../data/context";
import {
fetchEntityRegistrySettings,
updateEntityRegistrySettings,
} from "../../../data/entity/entity_registry_settings";
import {
DEFAULT_ENTITY_ID_FORMAT,
isDefaultEntityIdFormat,
type EntityIdFormat,
type EntityIdPart,
} from "../../../data/entity_id_format";
import { haStyle } from "../../../resources/styles";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
@customElement("ha-config-entity-id-format")
export class HaConfigEntityIdFormat extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state() private _format?: EntityIdFormat;
@state() private _error?: string;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
const settings = await fetchEntityRegistrySettings(this._api);
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
} catch (err: any) {
this._error = err.message;
}
}
private _examples = memoizeOne(
(localize: LocalizeFunc): Record<EntityIdPart, string> => ({
area: localize(
"ui.panel.config.entity_id_format.card.editor.examples.area"
),
device: localize(
"ui.panel.config.entity_id_format.card.editor.examples.device"
),
entity: localize(
"ui.panel.config.entity_id_format.card.editor.examples.entity"
),
floor: localize(
"ui.panel.config.entity_id_format.card.editor.examples.floor"
),
})
);
protected render() {
return html`
<ha-card
outlined
.header=${this._localize(
"ui.panel.config.entity_id_format.card.header"
)}
>
<div class="card-content">
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<p class="description">
${this._localize(
"ui.panel.config.entity_id_format.card.description"
)}
<a
href=${documentationUrl(
this._config,
"/docs/configuration/customizing-devices/"
)}
target="_blank"
rel="noreferrer"
>${this._localize(
"ui.panel.config.entity_id_format.card.learn_more"
)}</a
>
</p>
${
this._format
? html`
<ha-entity-id-format-editor
.value=${this._format}
.label=${this._localize(
"ui.panel.config.entity_id_format.card.editor.label"
)}
@value-changed=${this._formatChanged}
></ha-entity-id-format-editor>
${this._renderPreview()}
`
: nothing
}
</div>
<div class="card-actions">
<ha-button
appearance="plain"
@click=${this._reset}
.disabled=${!this._format || isDefaultEntityIdFormat(this._format)}
>
<ha-svg-icon slot="start" .path=${mdiRestore}></ha-svg-icon>
${this._localize("ui.panel.config.entity_id_format.card.reset")}
</ha-button>
<ha-progress-button
appearance="filled"
@click=${this._save}
.disabled=${!this._format}
>
${this._localize("ui.common.save")}
</ha-progress-button>
</div>
</ha-card>
`;
}
private _renderPreview() {
const example = computeEntityIdFormatExample(
this._format!,
this._examples(this._localize)
);
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.entity_id_format.card.preview")}
</span>
<code>${EXAMPLE_DOMAIN}.${example}</code>
</div>
`;
}
private _formatChanged(ev: CustomEvent) {
this._format = ev.detail.value;
}
private _reset() {
this._format = [...DEFAULT_ENTITY_ID_FORMAT];
}
private async _save(ev: Event) {
const button = ev.currentTarget as HaProgressButton;
if (button.progress) {
return;
}
button.progress = true;
this._error = undefined;
try {
const format = this._format!;
const settings = await updateEntityRegistrySettings(this._api, {
entity_id_parts: isDefaultEntityIdFormat(format) ? null : format,
});
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
button.actionSuccess();
} catch (err: any) {
button.actionError();
this._error = err.message;
} finally {
button.progress = false;
}
}
static styles = [
haStyle,
css`
:host {
display: block;
}
.description {
margin-top: 0;
color: var(--secondary-text-color);
}
.preview {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
margin-top: var(--ha-space-5);
}
.preview-label {
font-weight: 500;
}
.preview code {
display: block;
padding: var(--ha-space-3);
border-radius: var(--ha-border-radius-md);
background-color: var(--secondary-background-color);
font-family: var(--ha-font-family-code);
overflow-wrap: anywhere;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entity-id-format": HaConfigEntityIdFormat;
}
}
@@ -1,51 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "./ha-config-entity-id-format";
@customElement("ha-config-section-entity-id-format")
export class HaConfigSectionEntityIdFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render() {
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.entity_id_format.caption"
)}
>
<div class="content">
<ha-config-entity-id-format></ha-config-entity-id-format>
</div>
</hass-subpage>
`;
}
static styles = css`
.content {
padding: var(--ha-space-7) var(--ha-space-5) 0;
max-width: 1040px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
}
ha-config-entity-id-format {
max-width: 600px;
margin: 0 auto;
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-entity-id-format": HaConfigSectionEntityIdFormat;
}
}
@@ -1,348 +0,0 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/chips/ha-assist-chip";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-input-chip";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-generic-picker";
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type {
EntityIdFormat,
EntityIdPart,
} from "../../../data/entity_id_format";
import type { ValueChangedEvent } from "../../../types";
const STRUCTURAL_TYPES = ["area", "device", "entity", "floor"] as const;
const REQUIRED_TYPES: readonly EntityIdPart[] = ["device", "entity"];
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
const encodeType = (type: EntityIdPart) => `___${type}___`;
const decodeType = (value: string): EntityIdPart | undefined => {
const type =
value.startsWith("___") && value.endsWith("___")
? value.slice(3, -3)
: value;
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
? (type as EntityIdPart)
: undefined;
};
@customElement("ha-entity-id-format-editor")
export class HaEntityIdFormatEditor extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public value: EntityIdFormat = [];
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _editIndex?: number;
protected render() {
return html`
<div class="container">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.disabled=${this.disabled}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
@value-changed=${this._pickerValueChanged}
.searchLabel=${this._localize(
"ui.panel.config.entity_id_format.card.editor.search"
)}
>
<div slot="field" class="field">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this.value,
(item) => item,
(item: EntityIdPart, idx) => {
const label = this._formatType(item);
if (REQUIRED_TYPES.includes(item)) {
return html`
<ha-assist-chip
filled
class="required"
.label=${label}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-assist-chip>
`;
}
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._localize(
"ui.panel.config.entity_id_format.card.editor.add"
)}
class="add"
>
<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>
</ha-assist-chip>
`
}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
</div>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx || "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newValue = this.value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this.value];
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const type = decodeType(value);
if (!type) {
return;
}
const newValue = [...this.value];
if (this._editIndex != null) {
newValue[this._editIndex] = type;
this._editIndex = undefined;
} else {
newValue.push(type);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityIdFormat) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
private _formatType = (type: EntityIdPart) =>
this._localize(`ui.components.entity.entity-name-picker.types.${type}`);
private _getItems = memoizeOne(
(localize: LocalizeFunc): PickerComboBoxItem[] =>
STRUCTURAL_TYPES.map((type) => {
const primary = localize(
`ui.components.entity.entity-name-picker.types.${type}`
);
const id = encodeType(type);
return {
id,
primary,
search_labels: { primary, id },
sorting_label: primary,
};
})
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this.value[this._editIndex];
return item ? encodeType(item) : undefined;
}
return undefined;
}
private _getFilteredItems = (): PickerComboBoxItem[] => {
const items = this._getItems(this._localize);
const currentValue =
this._editIndex != null
? encodeType(this.value[this._editIndex])
: undefined;
const usedValues = new Set(this.value.map((item) => encodeType(item)));
return items.filter(
(item) => !usedValues.has(item.id) || item.id === currentValue
);
};
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: 500;
}
ha-generic-picker {
width: 100%;
}
.field {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.field:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
}
:host([disabled]) .field:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.field:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-chip-set {
padding: var(--ha-space-3);
}
ha-assist-chip.required {
--ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
}
.add {
order: 1;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-id-format-editor": HaEntityIdFormatEditor;
}
}
-4
View File
@@ -170,10 +170,6 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-section-ai-tasks",
load: () => import("./core/ha-config-section-ai-tasks"),
},
"entity-id-format": {
tag: "ha-config-section-entity-id-format",
load: () => import("./core/ha-config-section-entity-id-format"),
},
zha: {
tag: "zha-config-dashboard-router",
load: () =>
@@ -146,6 +146,7 @@ export class HuiDialogEditBadge
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!changedProps.has("_badgeConfig")) {
return;
}
@@ -135,6 +135,7 @@ export class HuiDialogEditCard
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._cardConfig || !changedProps.has("_cardConfig")) {
return;
}
@@ -76,6 +76,7 @@ export class HuiDialogEditSection
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const sectionConfig = {
...this._config,
@@ -94,6 +94,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const viewConfig = {
...this._config,
@@ -43,6 +43,7 @@ export class HuiDialogEditViewFooter extends DirtyStateProviderMixin<LovelaceVie
@state() private _open = false;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const config = {
...this._config,
@@ -44,6 +44,7 @@ export class HuiDialogEditViewHeader extends DirtyStateProviderMixin<LovelaceVie
@state() private _open = false;
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (this._yamlMode && changedProperties.has("_yamlMode")) {
const config = {
...this._config,
@@ -107,7 +107,7 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
.max=${Number(stateObj.attributes.max)}
.value=${stateObj.state}
.value=${Number(stateObj.state).toString()}
type="number"
@change=${this._selectedValueChanged}
>
+1
View File
@@ -104,6 +104,7 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
const oldLovelace = changedProps.get("lovelace") as Lovelace | undefined;
if (
!this._saving &&
@@ -101,8 +101,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
const columns = Math.floor(
(totalWidth - padding + columnGap) / (minColumnWidth + columnGap)
);
const maxColumns = this._config?.max_columns ?? DEFAULT_MAX_COLUMNS;
return clamp(columns, 1, maxColumns);
return Math.max(columns, 1);
},
});
@@ -155,7 +154,14 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
private _updateMaxColumnCount(): void {
const maxColumnCount = this._columnsController.value ?? 1;
// The column count is clamped here instead of in the resize callback, so
// that it follows the config of the view the element is currently used for
const maxColumns = this._config?.max_columns ?? DEFAULT_MAX_COLUMNS;
const maxColumnCount = clamp(
this._columnsController.value ?? 1,
1,
maxColumns
);
if (maxColumnCount !== this._maxColumns) {
this._maxColumns = maxColumnCount;
+2 -24
View File
@@ -2510,8 +2510,8 @@
"triggered": "Triggered {name}",
"dismiss": "Dismiss",
"no_matching_link_found": "No matching My link found for {path}",
"new_version_available": "A new version of the frontend is available.",
"reload": "Reload",
"new_version_available": "A new version of the frontend is available. This page will update in {seconds, plural, one {# second} other {# seconds}}.",
"update_now": "Update now",
"theme_save_failed": "Unable to save theme settings to your user profile.",
"theme_preferences_unavailable": "Unable to load user profile theme settings.",
"onboarding_survey": {
@@ -8599,28 +8599,6 @@
"caption": "AI tasks",
"description": "Configure AI suggestions and task preferences"
},
"entity_id_format": {
"caption": "Entity ID format",
"description": "Manage the format of newly created entity IDs",
"card": {
"header": "Entity ID format for new entities",
"description": "Entity IDs are used to reference entities in automations, scripts, and dashboards. This format only applies when a new entity is created. Using it is optional, and you can still rename each entity ID afterwards in its settings",
"learn_more": "Learn more",
"preview": "Preview",
"reset": "Reset to default",
"editor": {
"label": "Format",
"add": "Add",
"search": "Search part",
"examples": {
"area": "Living room",
"device": "Thermostat",
"entity": "Temperature",
"floor": "Ground floor"
}
}
}
},
"labs": {
"caption": "Labs",
"custom_integration": "Custom integration",
+94 -16
View File
@@ -1,5 +1,8 @@
import { showToast } from "./toast";
const UPDATE_DELAY = 60_000;
const UPDATE_TOAST_ID = "frontend-update-available";
export const supportsServiceWorker = () =>
"serviceWorker" in navigator &&
(location.protocol === "https:" || location.hostname === "localhost");
@@ -23,6 +26,92 @@ export const registerServiceWorker = async (
return;
}
let pendingWorker: ServiceWorker | undefined;
let updateDeadline = 0;
let updateInterval = 0;
const hideUpdateToast = () => {
showToast(rootEl, {
id: UPDATE_TOAST_ID,
message: "",
duration: 0,
});
};
const clearUpdateCountdown = () => {
clearInterval(updateInterval);
};
const activateUpdate = () => {
if (!pendingWorker) {
return;
}
clearUpdateCountdown();
hideUpdateToast();
pendingWorker.postMessage({ type: "skipWaiting" });
pendingWorker = undefined;
};
const showUpdateToast = () => {
const seconds = Math.ceil((updateDeadline - Date.now()) / 1000);
if (seconds <= 0) {
activateUpdate();
return;
}
const announceSeconds =
seconds > 40 ? 60 : seconds > 20 ? 40 : seconds > 5 ? 20 : 5;
showToast(rootEl, {
id: UPDATE_TOAST_ID,
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: announceSeconds },
},
action: {
action: activateUpdate,
primary: true,
text: { translationKey: "ui.notification_toast.update_now" },
},
secondaryAction: {
action: () => {
pendingWorker = undefined;
clearUpdateCountdown();
},
text: { translationKey: "ui.common.cancel" },
},
duration: -1,
});
};
const startUpdateCountdown = () => {
updateDeadline = Date.now() + UPDATE_DELAY;
showUpdateToast();
updateInterval = window.setInterval(showUpdateToast, 1000);
};
window.addEventListener("dirty-state-changed", () => {
if (!pendingWorker) {
return;
}
if (window.isDirtyState) {
clearUpdateCountdown();
hideUpdateToast();
return;
}
startUpdateCountdown();
});
const updateReady = (worker: ServiceWorker) => {
clearUpdateCountdown();
pendingWorker = worker;
if (!window.isDirtyState) {
startUpdateCountdown();
}
};
reg.addEventListener("updatefound", () => {
const installingWorker = reg.installing;
@@ -37,22 +126,11 @@ export const registerServiceWorker = async (
) {
return;
}
// Notify users a new frontend is available.
showToast(rootEl, {
message: {
translationKey: "ui.notification_toast.new_version_available",
},
action: {
// We tell the service worker to call skipWaiting, which activates
// the new service worker. Above we listen for `controllerchange`
// so we reload the page once a new service worker activates.
action: () => installingWorker.postMessage({ type: "skipWaiting" }),
text: { translationKey: "ui.notification_toast.reload" },
},
duration: -1,
dismissable: false,
});
updateReady(installingWorker);
});
});
if (reg.waiting && navigator.serviceWorker.controller) {
updateReady(reg.waiting);
}
};
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HaToast } from "../../src/components/ha-toast";
import "../../src/components/ha-toast";
let toast: HaToast | undefined;
const mountToast = async (properties: Partial<HaToast> = {}) => {
toast = document.createElement("ha-toast");
Object.assign(toast, properties);
document.body.append(toast);
await toast.updateComplete;
return toast;
};
afterEach(() => {
toast?.remove();
toast = undefined;
vi.useRealTimers();
});
const showToast = async (element: HaToast) => {
vi.useFakeTimers();
const shown = element.show();
await element.updateComplete;
Object.defineProperty(
element.shadowRoot!.querySelector(".toast"),
"getAnimations",
{
configurable: true,
value: () => [],
}
);
await vi.advanceTimersByTimeAsync(20);
await shown;
};
describe("ha-toast", () => {
it("renders its message and bottom offset", async () => {
const element = await mountToast({
labelText: "Configuration saved",
bottomOffset: 24,
});
expect(element.shadowRoot?.querySelector(".message")?.textContent).toBe(
"Configuration saved"
);
expect(
(
element.shadowRoot?.querySelector(".toast") as HTMLElement
).style.getPropertyValue("--ha-toast-bottom-offset")
).toBe("24px");
});
it("renders assigned action and dismiss content", async () => {
toast = document.createElement("ha-toast");
const action = document.createElement("button");
action.slot = "action";
const dismiss = document.createElement("button");
dismiss.slot = "dismiss";
toast.append(action, dismiss);
document.body.append(toast);
await toast.updateComplete;
toast.requestUpdate();
await toast.updateComplete;
expect(
toast.shadowRoot
?.querySelector<HTMLSlotElement>('slot[name="action"]')
?.assignedElements()
).toEqual([action]);
expect(
toast.shadowRoot
?.querySelector<HTMLSlotElement>('slot[name="dismiss"]')
?.assignedElements()
).toEqual([dismiss]);
expect(
toast.shadowRoot
?.querySelector(".actions")
?.classList.contains("has-action")
).toBe(true);
});
it("keeps changing visual text separate from live-region text", async () => {
const element = await mountToast({
labelText: "Updating in 59 seconds",
announceText: "Updating in 60 seconds",
});
const visibleMessage = element.shadowRoot!.querySelector(".message")!;
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
expect(visibleMessage.textContent).toBe("Updating in 59 seconds");
expect(visibleMessage.closest('[role="status"]')).toBeNull();
expect(liveRegion.textContent?.trim()).toBe("Updating in 60 seconds");
expect(liveRegion.getAttribute("role")).toBe("status");
expect(liveRegion.getAttribute("aria-atomic")).toBe("true");
});
it("falls back to announcing the visible message", async () => {
const element = await mountToast({ labelText: "Configuration saved" });
expect(
element
.shadowRoot!.querySelector(".assistive-message")!
.textContent?.trim()
).toBe("Configuration saved");
});
it("activates its live region while shown", async () => {
const element = await mountToast();
const liveRegion = element.shadowRoot!.querySelector(".assistive-message")!;
expect(liveRegion.getAttribute("aria-live")).toBe("off");
await showToast(element);
expect(liveRegion.getAttribute("aria-live")).toBe("polite");
expect(
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
).toBe(true);
});
it.each(["action", "dismiss", "programmatic"] as const)(
"reports a %s close reason",
async (reason) => {
const element = await mountToast({ timeoutMs: -1 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await element.hide(reason);
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0][0].detail).toEqual({ reason });
expect(
element
.shadowRoot!.querySelector(".toast")!
.classList.contains("active")
).toBe(false);
expect(
element
.shadowRoot!.querySelector(".assistive-message")!
.getAttribute("aria-live")
).toBe("off");
}
);
it("closes with a timeout reason", async () => {
const element = await mountToast({ timeoutMs: 1000 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await vi.advanceTimersByTimeAsync(1000);
expect(listener.mock.calls[0][0].detail).toEqual({ reason: "timeout" });
});
it("does not close automatically when the timeout is disabled", async () => {
const element = await mountToast({ timeoutMs: -1 });
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await showToast(element);
await vi.advanceTimersByTimeAsync(10_000);
expect(listener).not.toHaveBeenCalled();
});
it("does not emit a close event when already hidden", async () => {
const element = await mountToast();
const listener = vi.fn();
element.addEventListener("toast-closed", listener);
await element.hide("dismiss");
expect(listener).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,119 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import "../../src/managers/notification-manager";
vi.mock("../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
const localize = (key: string, args?: Record<string, string | number>) =>
args ? `${key}:${JSON.stringify(args)}` : key;
@customElement("test-notification-lifecycle-host")
class TestNotificationLifecycleHost extends LitElement {
@property({ attribute: false }) public hass = {
localize,
} as unknown as HomeAssistant;
protected render() {
return html`<notification-manager
.hass=${this.hass}
></notification-manager>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-notification-lifecycle-host": TestNotificationLifecycleHost;
}
}
let host: TestNotificationLifecycleHost | undefined;
afterEach(() => {
host?.remove();
host = undefined;
vi.useRealTimers();
});
describe("notification toast lifecycle", () => {
it("updates a visible toast and closes it after its primary action", async () => {
vi.useFakeTimers();
host = document.createElement("test-notification-lifecycle-host");
document.body.append(host);
await host.updateComplete;
const manager = host.shadowRoot!.querySelector("notification-manager")!;
const action = vi.fn();
const firstShow = manager.showDialog({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
action: { action, primary: true, text: "Update now" },
duration: -1,
});
await Promise.resolve();
await manager.updateComplete;
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
Object.defineProperty(
toast.shadowRoot!.querySelector(".toast"),
"getAnimations",
{
configurable: true,
value: () => [],
}
);
await vi.advanceTimersByTimeAsync(20);
await firstShow;
expect(toast.shadowRoot!.querySelector(".toast")!.classList).toContain(
"visible"
);
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
'"seconds":60'
);
expect(
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
).toContain('"seconds":60');
await manager.showDialog({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 59 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
action: { action, primary: true, text: "Update now" },
duration: -1,
});
expect(toast.shadowRoot!.querySelector(".message")!.textContent).toContain(
'"seconds":59'
);
expect(
toast.shadowRoot!.querySelector(".assistive-message")!.textContent
).toContain('"seconds":60');
const closed = new Promise<void>((resolve) => {
toast.addEventListener("toast-closed", () => resolve(), { once: true });
});
manager.shadowRoot!.querySelector<HTMLElement>("ha-button")!.click();
await closed;
await manager.updateComplete;
expect(action).toHaveBeenCalledOnce();
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
});
+245
View File
@@ -0,0 +1,245 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../src/types";
import "../../src/managers/notification-manager";
const localize = vi.fn((key: string, args?: Record<string, string | number>) =>
args ? `${key}:${JSON.stringify(args)}` : key
);
vi.mock("../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../src/components/ha-icon-button", () => {
customElements.define("ha-icon-button", class extends HTMLElement {});
return {};
});
vi.mock("../../src/components/ha-toast", () => {
customElements.define(
"ha-toast",
class extends HTMLElement {
public show = vi.fn();
public hide = vi.fn();
}
);
return {};
});
@customElement("test-notification-manager-host")
class TestNotificationManagerHost extends LitElement {
@property({ attribute: false }) public hass = {
localize,
} as unknown as HomeAssistant;
protected render() {
return html`<notification-manager
.hass=${this.hass}
></notification-manager>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"test-notification-manager-host": TestNotificationManagerHost;
}
}
let host: TestNotificationManagerHost | undefined;
const mountManager = async () => {
host = document.createElement(
"test-notification-manager-host"
) as TestNotificationManagerHost;
document.body.append(host);
await host.updateComplete;
return host.shadowRoot!.querySelector("notification-manager")!;
};
const deferred = () => {
let resolve: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve: resolve! };
};
afterEach(() => {
host?.remove();
host = undefined;
vi.clearAllMocks();
});
describe("notification-manager", () => {
it("shows a message with the default duration", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Configuration saved" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.labelText).toBe("Configuration saved");
expect(toast.timeoutMs).toBe(4000);
expect(toast.bottomOffset).toBe(0);
expect(toast.show).toHaveBeenCalledOnce();
});
it("renders a dismiss button and closes with the dismiss reason", async () => {
const manager = await mountManager();
await manager.showDialog({
message: "Connection lost",
dismissable: true,
duration: -1,
bottomOffset: 16,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.timeoutMs).toBe(-1);
expect(toast.bottomOffset).toBe(16);
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
expect(toast.hide).toHaveBeenCalledWith("dismiss");
});
it("clears the current notification when duration is zero", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "First message", duration: -1 });
await manager.showDialog({ message: "", duration: 0 });
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
it.each([
{ duration: 1, expected: 4000 },
{ duration: 4000, expected: 4000 },
{ duration: 4001, expected: 4001 },
{ duration: -1, expected: -1 },
])(
"normalizes a $duration ms duration to $expected ms",
async ({ duration, expected }) => {
const manager = await mountManager();
await manager.showDialog({ message: "Message", duration });
expect(manager.shadowRoot!.querySelector("ha-toast")!.timeoutMs).toBe(
expected
);
}
);
it("localizes visible and assistive messages with numeric arguments", async () => {
const manager = await mountManager();
await manager.showDialog({
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 59 },
},
announceMessage: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(toast.labelText).toContain('"seconds":59');
expect(toast.announceText).toContain('"seconds":60');
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 59 }
);
expect(localize).toHaveBeenCalledWith(
"ui.notification_toast.new_version_available",
{ seconds: 60 }
);
});
it("renders and invokes primary and secondary actions", async () => {
const manager = await mountManager();
const primary = vi.fn();
const secondary = vi.fn();
await manager.showDialog({
message: "Update available",
action: { action: primary, primary: true, text: "Update now" },
secondaryAction: { action: secondary, text: "Cancel" },
duration: -1,
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const buttons = manager.shadowRoot!.querySelectorAll("ha-button");
expect(buttons).toHaveLength(2);
expect(buttons[0].textContent?.trim()).toBe("Cancel");
expect(buttons[0].getAttribute("appearance")).toBe("plain");
expect(buttons[1].textContent?.trim()).toBe("Update now");
expect(buttons[1].getAttribute("appearance")).toBe("filled");
buttons[0].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
expect(secondary).toHaveBeenCalledOnce();
expect(primary).not.toHaveBeenCalled();
buttons[1].click();
expect(toast.hide).toHaveBeenLastCalledWith("action");
expect(primary).toHaveBeenCalledOnce();
});
it("keeps a same-ID toast visible while replacing its content", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "status", message: "Second" });
expect(toast.hide).not.toHaveBeenCalled();
expect(toast.labelText).toBe("Second");
});
it("hides a toast before replacing it with a different ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
vi.mocked(toast.hide).mockClear();
await manager.showDialog({ id: "second", message: "Second" });
expect(toast.hide).toHaveBeenCalledOnce();
expect(toast.labelText).toBe("Second");
});
it("ignores a stale replacement after a newer notification starts", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const delayedHide = deferred();
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
const staleShow = manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "third", message: "Third" });
delayedHide.resolve();
await staleShow;
expect(toast.labelText).toBe("Third");
});
it("clears rendered state after the toast closes", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "Message" });
manager.shadowRoot!.querySelector("ha-toast")!.dispatchEvent(
new CustomEvent("toast-closed", {
detail: { reason: "programmatic" },
})
);
await manager.updateComplete;
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
});
@@ -0,0 +1,368 @@
import { consume } from "@lit/context";
import { LitElement } from "lit";
import { state } from "lit/decorators";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../src/data/context/dirty-state";
import type { CompareStrategy } from "../../src/mixins/dirty-state-provider-mixin";
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
interface TestState {
name: string;
enabled: boolean;
options?: { mode: string };
}
class TestDirtyStateConsumer extends LitElement {
@consume({ context: dirtyStateContext, subscribe: true })
@state()
public dirtyState?: DirtyStateContext;
}
customElements.define("test-dirty-state-consumer", TestDirtyStateConsumer);
class TestDirtyStateProvider extends DirtyStateProviderMixin<TestState>()(
LitElement
) {
public initialize(
strategy: CompareStrategy<TestState>,
value?: TestState,
normalize?: (value: TestState) => TestState
) {
this._initDirtyTracking(strategy, value, normalize);
}
public setValue(value: TestState) {
this._updateDirtyState(value);
}
public markClean() {
this._markDirtyStateClean();
}
public discardChanges() {
this._discardDirtyStateChanges();
}
}
customElements.define("test-dirty-state-provider", TestDirtyStateProvider);
declare global {
interface HTMLElementTagNameMap {
"test-dirty-state-consumer": TestDirtyStateConsumer;
"test-dirty-state-provider": TestDirtyStateProvider;
}
}
let provider: TestDirtyStateProvider | undefined;
const mountProvider = async () => {
provider = document.createElement(
"test-dirty-state-provider"
) as TestDirtyStateProvider;
document.body.append(provider);
await provider.updateComplete;
return provider;
};
afterEach(() => {
document.querySelectorAll("test-dirty-state-provider").forEach((element) => {
element.remove();
});
provider = undefined;
window.isDirtyState = false;
});
describe("DirtyStateProviderMixin", () => {
it("tracks changes against an initial state", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Kitchen light", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
it("marks the current state as clean", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Kitchen light", enabled: false });
await element.updateComplete;
element.markClean();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("discards changes back to the initial state", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Kitchen light", enabled: true });
await element.updateComplete;
element.discardChanges();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
expect(element.isEffectiveDirtyState).toBe(false);
});
it("uses deep comparison for nested values", async () => {
const element = await mountProvider();
element.initialize(
{ type: "deep" },
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "manual" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("uses reference comparison for nested values with a shallow strategy", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false, options: { mode: "auto" } }
);
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("supports a custom comparison strategy", async () => {
const element = await mountProvider();
const compare = vi.fn(
(initial: TestState, current: TestState) =>
initial.name.toLowerCase() === current.name.toLowerCase()
);
element.initialize(
{ type: "custom", compare },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "KITCHEN", enabled: true });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
expect(compare).toHaveBeenCalled();
});
it("keeps the deep baseline isolated from initial state mutations", async () => {
const element = await mountProvider();
const initial = {
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
};
element.initialize({ type: "deep" }, initial);
initial.options.mode = "manual";
element.setValue({
name: "Kitchen",
enabled: false,
options: { mode: "auto" },
});
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
it("uses the first deferred value as its baseline", async () => {
const element = await mountProvider();
element.initialize({ type: "shallow" });
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
});
it("tracks raw and effective dirty state separately", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false },
(value) => ({ ...value, enabled: false })
);
element.setValue({ name: "Kitchen", enabled: true });
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
expect(element.isEffectiveDirtyState).toBe(false);
});
it("publishes dirty and clean transitions with event details", async () => {
const listener = vi.fn();
window.addEventListener("dirty-state-changed", listener);
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
await element.updateComplete;
listener.mockClear();
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
element.setValue({ name: "Kitchen", enabled: false });
await element.updateComplete;
expect(listener).toHaveBeenCalledTimes(2);
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: true });
expect(listener.mock.calls[1][0].detail).toEqual({ isDirty: false });
expect(window.isDirtyState).toBe(false);
window.removeEventListener("dirty-state-changed", listener);
});
it("clears published dirty state when disconnected", async () => {
const listener = vi.fn();
window.addEventListener("dirty-state-changed", listener);
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
listener.mockClear();
element.remove();
expect(window.isDirtyState).toBe(false);
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0][0].detail).toEqual({ isDirty: false });
window.removeEventListener("dirty-state-changed", listener);
});
it("hands global dirty state to the next provider after disconnecting", async () => {
const firstProvider = await mountProvider();
firstProvider.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
firstProvider.setValue({ name: "Bedroom", enabled: false });
await firstProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
firstProvider.remove();
expect(window.isDirtyState).toBe(false);
const nextProvider = await mountProvider();
nextProvider.initialize(
{ type: "shallow" },
{ name: "Hallway", enabled: false }
);
nextProvider.setValue({ name: "Hallway", enabled: true });
await nextProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
});
it("stays globally dirty while any connected provider is dirty", async () => {
const dirtyProvider = await mountProvider();
dirtyProvider.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
dirtyProvider.setValue({ name: "Bedroom", enabled: false });
await dirtyProvider.updateComplete;
const cleanProvider = document.createElement("test-dirty-state-provider");
document.body.append(cleanProvider);
cleanProvider.initialize(
{ type: "shallow" },
{ name: "Hallway", enabled: false }
);
await cleanProvider.updateComplete;
expect(window.isDirtyState).toBe(true);
cleanProvider.remove();
expect(window.isDirtyState).toBe(true);
dirtyProvider.setValue({ name: "Kitchen", enabled: false });
await dirtyProvider.updateComplete;
expect(window.isDirtyState).toBe(false);
});
it("restores a provider's dirty state after reconnecting", async () => {
const element = await mountProvider();
element.initialize(
{ type: "shallow" },
{ name: "Kitchen", enabled: false }
);
element.setValue({ name: "Bedroom", enabled: false });
await element.updateComplete;
element.remove();
expect(window.isDirtyState).toBe(false);
document.body.append(element);
expect(window.isDirtyState).toBe(true);
});
it("tracks independent context slices and marks all of them clean", async () => {
const element = await mountProvider();
element.initialize({ type: "shallow" });
const consumer = document.createElement("test-dirty-state-consumer");
element.append(consumer);
await consumer.updateComplete;
const setState = (value: TestState, key: "first" | "second") => {
Reflect.apply(consumer.dirtyState!.setState, undefined, [value, key]);
};
setState({ name: "Kitchen", enabled: false }, "first");
setState({ name: "Bedroom", enabled: false }, "second");
await element.updateComplete;
setState({ name: "Kitchen light", enabled: false }, "first");
await element.updateComplete;
expect(element.isDirtyState).toBe(true);
consumer.dirtyState!.markClean();
await element.updateComplete;
expect(element.isDirtyState).toBe(false);
});
});
+341
View File
@@ -0,0 +1,341 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ShowToastParams } from "../../src/managers/notification-manager";
import {
registerServiceWorker,
supportsServiceWorker,
} from "../../src/util/register-service-worker";
class FakeWorker extends EventTarget {
public state: ServiceWorkerState = "installing";
public postMessage = vi.fn();
}
class FakeRegistration extends EventTarget {
public installing: ServiceWorker | null = null;
public waiting: ServiceWorker | null = null;
}
class FakeServiceWorkerContainer extends EventTarget {
public controller: ServiceWorker | null = null;
public register = vi.fn();
}
const serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(
navigator,
"serviceWorker"
);
const restoreServiceWorker = () => {
if (serviceWorkerDescriptor) {
Object.defineProperty(navigator, "serviceWorker", serviceWorkerDescriptor);
} else {
Reflect.deleteProperty(navigator, "serviceWorker");
}
};
describe("supportsServiceWorker", () => {
afterEach(restoreServiceWorker);
it("returns true when the service worker API is available", () => {
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: new FakeServiceWorkerContainer(),
});
expect(supportsServiceWorker()).toBe(true);
});
it("returns false when the service worker API is unavailable", () => {
Reflect.deleteProperty(navigator, "serviceWorker");
expect(supportsServiceWorker()).toBe(false);
});
});
describe("registerServiceWorker", () => {
let root: HTMLElement;
let worker: FakeWorker;
let registration: FakeRegistration;
let serviceWorker: FakeServiceWorkerContainer;
let notifications: ShowToastParams[];
const latestNotification = () => notifications[notifications.length - 1];
const setDirtyState = (isDirty: boolean) => {
window.isDirtyState = isDirty;
window.dispatchEvent(
new CustomEvent("dirty-state-changed", { detail: { isDirty } })
);
};
const installUpdate = () => {
registration.installing = worker as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
worker.state = "installed";
worker.dispatchEvent(new Event("statechange"));
};
beforeEach(() => {
vi.useFakeTimers();
globalThis.__BUILD__ = "modern";
globalThis.__DEV__ = false;
globalThis.__DEMO__ = false;
window.isDirtyState = false;
notifications = [];
root = document.createElement("div");
root.addEventListener("hass-notification", (event) => {
notifications.push((event as CustomEvent<ShowToastParams>).detail);
});
worker = new FakeWorker();
registration = new FakeRegistration();
serviceWorker = new FakeServiceWorkerContainer();
serviceWorker.controller = worker as unknown as ServiceWorker;
serviceWorker.register.mockResolvedValue(registration);
Object.defineProperty(navigator, "serviceWorker", {
configurable: true,
value: serviceWorker,
});
});
afterEach(() => {
latestNotification()?.secondaryAction?.action();
vi.clearAllTimers();
vi.useRealTimers();
window.isDirtyState = false;
globalThis.__DEV__ = false;
globalThis.__DEMO__ = false;
restoreServiceWorker();
});
it("registers the worker for the current build", async () => {
await registerServiceWorker(root, false);
expect(serviceWorker.register).toHaveBeenCalledOnce();
expect(serviceWorker.register).toHaveBeenCalledWith("/sw-modern.js");
});
it("does not register when service workers are unsupported", async () => {
Reflect.deleteProperty(navigator, "serviceWorker");
await registerServiceWorker(root);
expect(serviceWorker.register).not.toHaveBeenCalled();
});
it("listens for controller changes before registering", async () => {
const addEventListener = vi.spyOn(serviceWorker, "addEventListener");
await registerServiceWorker(root, false);
expect(addEventListener).toHaveBeenCalledWith(
"controllerchange",
expect.any(Function)
);
expect(addEventListener.mock.invocationCallOrder[0]).toBeLessThan(
serviceWorker.register.mock.invocationCallOrder[0]
);
});
it.each([
{ notifyUpdate: false, dev: false, demo: false },
{ notifyUpdate: true, dev: true, demo: false },
{ notifyUpdate: true, dev: false, demo: true },
])(
"does not monitor updates with notifyUpdate=$notifyUpdate, dev=$dev, demo=$demo",
async ({ notifyUpdate, dev, demo }) => {
globalThis.__DEV__ = dev;
globalThis.__DEMO__ = demo;
const addEventListener = vi.spyOn(registration, "addEventListener");
await registerServiceWorker(root, notifyUpdate);
expect(addEventListener).not.toHaveBeenCalledWith(
"updatefound",
expect.any(Function)
);
}
);
it("ignores update discovery without an installing worker", async () => {
await registerServiceWorker(root);
registration.dispatchEvent(new Event("updatefound"));
expect(notifications).toHaveLength(0);
});
it("waits for an installing worker to reach the installed state", async () => {
await registerServiceWorker(root);
registration.installing = worker as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
worker.state = "activating";
worker.dispatchEvent(new Event("statechange"));
expect(notifications).toHaveLength(0);
});
it("ignores an initial installation without an existing controller", async () => {
serviceWorker.controller = null;
await registerServiceWorker(root);
installUpdate();
expect(notifications).toHaveLength(0);
});
it("starts at 60 seconds when an update is installed", async () => {
await registerServiceWorker(root);
installUpdate();
expect(latestNotification()).toMatchObject({
id: "frontend-update-available",
message: {
translationKey: "ui.notification_toast.new_version_available",
args: { seconds: 60 },
},
announceMessage: {
args: { seconds: 60 },
},
action: {
primary: true,
text: { translationKey: "ui.notification_toast.update_now" },
},
secondaryAction: {
text: { translationKey: "ui.common.cancel" },
},
duration: -1,
});
});
it("uses an already waiting worker", async () => {
registration.waiting = worker as unknown as ServiceWorker;
await registerServiceWorker(root);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("ignores a waiting worker without an existing controller", async () => {
registration.waiting = worker as unknown as ServiceWorker;
serviceWorker.controller = null;
await registerServiceWorker(root);
expect(notifications).toHaveLength(0);
});
it.each([
{ elapsed: 1_000, visual: 59, announced: 60 },
{ elapsed: 20_000, visual: 40, announced: 40 },
{ elapsed: 40_000, visual: 20, announced: 20 },
{ elapsed: 55_000, visual: 5, announced: 5 },
{ elapsed: 59_000, visual: 1, announced: 5 },
])(
"shows $visual seconds and announces $announced after $elapsed ms",
async ({ elapsed, visual, announced }) => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(elapsed);
expect(latestNotification().message).toMatchObject({
args: { seconds: visual },
});
expect(latestNotification().announceMessage).toMatchObject({
args: { seconds: announced },
});
}
);
it("activates and hides the update when the countdown finishes", async () => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
expect(latestNotification()).toMatchObject({
id: "frontend-update-available",
message: "",
duration: 0,
});
});
it("activates and hides the update immediately on request", async () => {
await registerServiceWorker(root);
installUpdate();
latestNotification().action!.action();
expect(worker.postMessage).toHaveBeenCalledOnce();
expect(worker.postMessage).toHaveBeenCalledWith({ type: "skipWaiting" });
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
});
it("cancels automatic activation", async () => {
await registerServiceWorker(root);
installUpdate();
latestNotification().secondaryAction!.action();
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
});
it("defers the message and activation while the page is dirty", async () => {
setDirtyState(true);
await registerServiceWorker(root);
installUpdate();
expect(notifications).toHaveLength(0);
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
setDirtyState(false);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("hides and resets the countdown when the page becomes dirty", async () => {
await registerServiceWorker(root);
installUpdate();
vi.advanceTimersByTime(10_000);
setDirtyState(true);
expect(latestNotification()).toMatchObject({ message: "", duration: 0 });
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
setDirtyState(false);
expect(latestNotification().message).toMatchObject({
args: { seconds: 60 },
});
});
it("uses the latest installed worker", async () => {
await registerServiceWorker(root);
installUpdate();
const replacement = new FakeWorker();
replacement.state = "installed";
registration.installing = replacement as unknown as ServiceWorker;
registration.dispatchEvent(new Event("updatefound"));
replacement.dispatchEvent(new Event("statechange"));
vi.advanceTimersByTime(60_000);
expect(worker.postMessage).not.toHaveBeenCalled();
expect(replacement.postMessage).toHaveBeenCalledWith({
type: "skipWaiting",
});
});
});
+15 -15
View File
@@ -10023,14 +10023,14 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.12"
js-yaml: "npm:5.2.1"
js-yaml: "npm:5.2.2"
jsdom: "npm:29.1.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
leaflet.markercluster: "npm:1.5.3"
license-checker-rseidelsohn: "npm:5.0.1"
lint-staged: "npm:17.1.0"
lint-staged: "npm:17.1.1"
lit: "npm:3.3.3"
lit-analyzer: "npm:2.0.3"
lit-html: "npm:3.3.3"
@@ -10056,7 +10056,7 @@ __metadata:
sortablejs: "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch"
stacktrace-js: "npm:2.0.2"
superstruct: "npm:2.0.2"
tar: "npm:7.5.20"
tar: "npm:7.5.21"
terser-webpack-plugin: "npm:5.6.1"
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
@@ -10957,14 +10957,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:5.2.1":
version: 5.2.1
resolution: "js-yaml@npm:5.2.1"
"js-yaml@npm:5.2.2":
version: 5.2.2
resolution: "js-yaml@npm:5.2.2"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/e1eca2d21c15572585bb236d9fde31d6789eb50b9c63e8753fa7e0777bc480f7521cad517bd7a0c66f27dfc27ddcd7100beeefa51c1a50e10e98f2e009633c3d
checksum: 10/2b4c2933af12c97e1c4894a4f27fe9b06dab70a64a96bb50624b4429bef6bf11008bde20d868bce52a36784473314efc30078ba6025b58cf7537961e23b1ae9c
languageName: node
linkType: hard
@@ -11423,9 +11423,9 @@ __metadata:
languageName: node
linkType: hard
"lint-staged@npm:17.1.0":
version: 17.1.0
resolution: "lint-staged@npm:17.1.0"
"lint-staged@npm:17.1.1":
version: 17.1.1
resolution: "lint-staged@npm:17.1.1"
dependencies:
picomatch: "npm:^4.0.5"
string-argv: "npm:^0.3.2"
@@ -11436,7 +11436,7 @@ __metadata:
optional: true
bin:
lint-staged: bin/lint-staged.js
checksum: 10/050d8ce1f6c2cf218ebc0b693de6d1754b333f04e263ecbee230b1aa742fd684667d0f42beee48c3bb11cbcc4e50c4995289c82c36bb08f6bd0f8157816514e1
checksum: 10/a54e063a579c093cecd4fc210f78c424fa0aa1dbe8196cf57160fad05acbfe4cb60ddd50751c799277094a0b7949de0ea3b61392b90e137f5c5bdf8473826e72
languageName: node
linkType: hard
@@ -14501,16 +14501,16 @@ __metadata:
languageName: node
linkType: hard
"tar@npm:*, tar@npm:7.5.20, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.20
resolution: "tar@npm:7.5.20"
"tar@npm:*, tar@npm:7.5.21, tar@npm:^7.4.3, tar@npm:^7.5.4":
version: 7.5.21
resolution: "tar@npm:7.5.21"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10/90a0fe423ac921197ad5eefc5e5f7ad7f42b06e80444c8c347c1e4112384cbe9cb53ade3ef71748eed3684888756869c2aeef6b6303c766101f3217e254d4be9
checksum: 10/a1b7dcee15e4f7dbef7f07c3cf0fe946248efabd1cb029b54c698d817dfc2876c75bcaa164a12dd21191e385759ce3e37fea562cf4aabaa00984ef9ed3c48d17
languageName: node
linkType: hard