mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-14 20:04:57 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af0529c118 | |||
| 4cb3680f27 | |||
| 9127e8b13c | |||
| fd12ce921f | |||
| 574d097a7c | |||
| 310fef33db | |||
| 66d48e0d2c | |||
| 8b178ed421 | |||
| 195f49f2af | |||
| 3b49dcb579 | |||
| 36a9da37f8 | |||
| cee5361525 |
+11
-11
@@ -52,15 +52,15 @@
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.10",
|
||||
"@formatjs/intl-displaynames": "7.3.11",
|
||||
"@formatjs/intl-durationformat": "0.10.16",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.10",
|
||||
"@formatjs/intl-listformat": "8.3.11",
|
||||
"@formatjs/intl-locale": "5.3.9",
|
||||
"@formatjs/intl-numberformat": "9.3.12",
|
||||
"@formatjs/intl-pluralrules": "6.3.11",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.11",
|
||||
"@formatjs/intl-datetimeformat": "7.5.0",
|
||||
"@formatjs/intl-displaynames": "7.3.12",
|
||||
"@formatjs/intl-durationformat": "0.10.17",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.12",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.13",
|
||||
"@formatjs/intl-pluralrules": "6.3.12",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.12",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -107,7 +107,7 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.10",
|
||||
"intl-messageformat": "11.2.11",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -173,7 +173,7 @@
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.6.0",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export class HaImagecropperDialog
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (!changedProperties.has("_params") || !this._params) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
duration?: number;
|
||||
dismissable?: boolean;
|
||||
bottomOffset?: number;
|
||||
@@ -22,8 +27,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")
|
||||
@@ -89,31 +96,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`
|
||||
@@ -130,11 +128,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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -2504,8 +2504,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."
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2745,135 +2745,135 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/bigdecimal@npm:0.2.6":
|
||||
version: 0.2.6
|
||||
resolution: "@formatjs/bigdecimal@npm:0.2.6"
|
||||
checksum: 10/5ef248f0feadeb1bceb9fa65ba36ccd1768a41ea6165bc58ae794564ebb09a67621d894fc94ab8b328cc7bba60e4c181847562c92b7cec5cd1208a4191fbbe67
|
||||
"@formatjs/bigdecimal@npm:0.2.7":
|
||||
version: 0.2.7
|
||||
resolution: "@formatjs/bigdecimal@npm:0.2.7"
|
||||
checksum: 10/e78fe804ed6805708b849fbd49006e981cfa736677bac29556f84bd6ca5271f848076f2f35de087197a559d434435bbfaecca2776563a5b46ecd6246bde7406d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/fast-memoize@npm:3.1.6":
|
||||
version: 3.1.6
|
||||
resolution: "@formatjs/fast-memoize@npm:3.1.6"
|
||||
checksum: 10/7dec3e82586d4c4889671c6081d7b0d87b2c229ba551d8328f96ae8ab58b2cd6056fc5d360c0b0c9688b7164f12ef517428016fbce15b950987a77005e481824
|
||||
"@formatjs/fast-memoize@npm:3.1.7":
|
||||
version: 3.1.7
|
||||
resolution: "@formatjs/fast-memoize@npm:3.1.7"
|
||||
checksum: 10/2d7ead48684539764ecb8a52178719169186595e830d9d6941eff12bec1ae31e20642a9cfd197007c48a8cc7e965bf61445ffcccc0fd77281341271d00e7b8fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/icu-messageformat-parser@npm:3.5.13":
|
||||
version: 3.5.13
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.13"
|
||||
"@formatjs/icu-messageformat-parser@npm:3.5.14":
|
||||
version: 3.5.14
|
||||
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.14"
|
||||
dependencies:
|
||||
"@formatjs/icu-skeleton-parser": "npm:2.1.10"
|
||||
checksum: 10/b6451febdcfbe32571af5ae1c94cc560f6b3d815401321849c9dba229c460edf9ee4eca4c2d4da6dedefea416f2e3b10b4aa5507383e12b67042f941157e8045
|
||||
"@formatjs/icu-skeleton-parser": "npm:2.1.11"
|
||||
checksum: 10/e842d8ec0c17c174da0eb562e161f5216b31cb83546bf94fd3dd6e4b6493f8c0c404b9fb47b6b8106a607a83d305386bc1fb44abbc70453cf467d261e39d09c1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/icu-skeleton-parser@npm:2.1.10":
|
||||
version: 2.1.10
|
||||
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.10"
|
||||
checksum: 10/ec30d106ce38de80f4128d0cdfac15699628652807695843254bf0d31650bd0dc4b57e48691d164556234494d59b2816e710fa12321234c85b803c5cda32bedf
|
||||
"@formatjs/icu-skeleton-parser@npm:2.1.11":
|
||||
version: 2.1.11
|
||||
resolution: "@formatjs/icu-skeleton-parser@npm:2.1.11"
|
||||
checksum: 10/492f42bd4ad72b2cdb4fd75a07e3874aa78e88f346027058f74c58e3828378092784176c9f7c77f4b5baf58ffaa1a4e8f5efcb53143552def2990510d0ac3c16
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-datetimeformat@npm:7.4.10":
|
||||
version: 7.4.10
|
||||
resolution: "@formatjs/intl-datetimeformat@npm:7.4.10"
|
||||
"@formatjs/intl-datetimeformat@npm:7.5.0":
|
||||
version: 7.5.0
|
||||
resolution: "@formatjs/intl-datetimeformat@npm:7.5.0"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/67fd55857555fe2651bc8ed5544fb036807d926542c92ccc07e475c14493d93a87d2cc805d04c812c6df68c9fdaeb4d64b399ac019f8c27940e2b872c9837618
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/8f5bbcd2768609e466e5d03cd42880e62fa5c584ef57d716023a0f89ffff12036da41a248956cc1b3369b1af0155459464ac05d5f8fdfb42c0aa066d77ede815
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-displaynames@npm:7.3.11":
|
||||
version: 7.3.11
|
||||
resolution: "@formatjs/intl-displaynames@npm:7.3.11"
|
||||
"@formatjs/intl-displaynames@npm:7.3.12":
|
||||
version: 7.3.12
|
||||
resolution: "@formatjs/intl-displaynames@npm:7.3.12"
|
||||
dependencies:
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/3278e430d7f2bbb0656f5cf0a76a1a539a572c58f0d2473e2a175bc927de7e3a4a01823c3c98a38dc2878fd05ca3a6ebe83afa94dbd254733fe9195377064347
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/d802f9d44831fcb567efd7399b2f20da7872bf6cdf3cc8c5b38b188111fc8c38af704a79f55902caadd50edb8ebf2f830c473aecc5a93573ddbe1bcfba275793
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-durationformat@npm:0.10.16":
|
||||
version: 0.10.16
|
||||
resolution: "@formatjs/intl-durationformat@npm:0.10.16"
|
||||
"@formatjs/intl-durationformat@npm:0.10.17":
|
||||
version: 0.10.17
|
||||
resolution: "@formatjs/intl-durationformat@npm:0.10.17"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/8d0f5bbf24528c6d8e65636502de64b13e12c71854935db8bf2ce6bcc5b6e0e19533d0e0178c1079b1dd52214b025369c98e297b0e8484fa8ce29c9f6408c953
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/ee3210da7b946a80995b36609a360a55d63f4626e3f40223215d782082f1f8254c49af6145f397ea6324b370038095145c6ff51fda8712e7b611f8609e1ac2b3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-getcanonicallocales@npm:3.2.10":
|
||||
version: 3.2.10
|
||||
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.10"
|
||||
checksum: 10/dbf704d141bd4efc4e2687bd745d1a847a7b94955c23d2f06fe26add8e5ab8ad6096168babad72f2b4568f1fbee32c1528082269273b750bed4bdd1dc5b5d396
|
||||
"@formatjs/intl-getcanonicallocales@npm:3.2.11":
|
||||
version: 3.2.11
|
||||
resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.11"
|
||||
checksum: 10/3132dba96c7cfe63787e126a91e620211a32fa6a623cb763f96102ff153845fb12486f378aa1d761736a7641adb19f5cc307156c8b7a40eedd00223b87c8d2a9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-listformat@npm:8.3.11":
|
||||
version: 8.3.11
|
||||
resolution: "@formatjs/intl-listformat@npm:8.3.11"
|
||||
"@formatjs/intl-listformat@npm:8.3.12":
|
||||
version: 8.3.12
|
||||
resolution: "@formatjs/intl-listformat@npm:8.3.12"
|
||||
dependencies:
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/a08a74c22bb01871944cad91fcb87342e30f3f6792375af3028737794a56b2dec6b7506c8e2b9d0495f4eff50f4d63542fcf86116cd4fdf1d4d8b6838a54ac5c
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/3ae640ab968b3051fd1b2d150d892a087d5a4feecb8bdf4a6a61726888996ee0b054de43e35548374d0a61d8577b5a48f23abfb9506c1560f88d64c0809691f1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-locale@npm:5.3.9":
|
||||
version: 5.3.9
|
||||
resolution: "@formatjs/intl-locale@npm:5.3.9"
|
||||
"@formatjs/intl-locale@npm:5.3.10":
|
||||
version: 5.3.10
|
||||
resolution: "@formatjs/intl-locale@npm:5.3.10"
|
||||
dependencies:
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
|
||||
"@formatjs/intl-supportedvaluesof": "npm:2.3.8"
|
||||
checksum: 10/e2af858ec3ff75611d5412a1e39abce882eab2cf13eb278e4cd1647481321823cc2303c74e86c38b95662e668f6900f9d666136debb2377231d5ddf0bdba3218
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.11"
|
||||
"@formatjs/intl-supportedvaluesof": "npm:2.3.9"
|
||||
checksum: 10/f7d8be5ea145089b9daa56e24158f835ed0f3d67d3230b42c3a8a32f3dbdd98534236e9262d86f628288d09968fec32d1c2f5906ec1d5f38e692d436a4052255
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-localematcher@npm:0.8.11":
|
||||
version: 0.8.11
|
||||
resolution: "@formatjs/intl-localematcher@npm:0.8.11"
|
||||
"@formatjs/intl-localematcher@npm:0.8.12":
|
||||
version: 0.8.12
|
||||
resolution: "@formatjs/intl-localematcher@npm:0.8.12"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.6"
|
||||
checksum: 10/482b819100997439b2c7295c8112b43c81f64083644cde7124ef1afecaa8fc663bc7b9820b6386d02b45872db7a943c3f1f43a4f78780fe52ed4873ef422e7dc
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
checksum: 10/4f9ecb936ac76cb978460edacbb2704e9760f65203e2c17f96b8db605a3c344ecc7da2ac78c897316f519b033c623b39062ba720e23fe0c2162af7c761f765e6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-numberformat@npm:9.3.12":
|
||||
version: 9.3.12
|
||||
resolution: "@formatjs/intl-numberformat@npm:9.3.12"
|
||||
"@formatjs/intl-numberformat@npm:9.3.13":
|
||||
version: 9.3.13
|
||||
resolution: "@formatjs/intl-numberformat@npm:9.3.13"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/6a9f3115136ce9f74fab520d5eeddb6a3f23d32492b004c9c6f108a19dd18e9ccaec04582569238f089b3028cc68783a2b75328b3ce38b13bf30d6267cb33b20
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/59530d3aec0411a2dae8f2113829b969dceec3ec4a59032cc985ef1b9cebec6d406fbe4dd44ac69e905b4f3b3edb333d61f728a5610fc1e1e132e4bdcb2436aa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-pluralrules@npm:6.3.11":
|
||||
version: 6.3.11
|
||||
resolution: "@formatjs/intl-pluralrules@npm:6.3.11"
|
||||
"@formatjs/intl-pluralrules@npm:6.3.12":
|
||||
version: 6.3.12
|
||||
resolution: "@formatjs/intl-pluralrules@npm:6.3.12"
|
||||
dependencies:
|
||||
"@formatjs/bigdecimal": "npm:0.2.6"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/c5506c1a20f1aa7fdca24b6800243aec5cbacd5aa0de96d01b497fa9485f6affa7abf6b0d2ea04ebff0ec8a2d22a41913f4353b9fd9c6054899a9f168ef2ebd0
|
||||
"@formatjs/bigdecimal": "npm:0.2.7"
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/23833b17822a648fc4e87dfe581733f303a8d1c4721a47e875994bcb56c5985c83ea01f0ab176557886fc6b2ef158ce50f9cafa1e1441badefddec0ea79c5303
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-relativetimeformat@npm:12.3.11":
|
||||
version: 12.3.11
|
||||
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.11"
|
||||
"@formatjs/intl-relativetimeformat@npm:12.3.12":
|
||||
version: 12.3.12
|
||||
resolution: "@formatjs/intl-relativetimeformat@npm:12.3.12"
|
||||
dependencies:
|
||||
"@formatjs/intl-localematcher": "npm:0.8.11"
|
||||
checksum: 10/ead23de113c9c28334ff2f8696e0f5ff0567f2b929def10d06e67dba2ca502967671abb1bb6ad06b200329e0215b469edd3c3ad9ed4259b313e97f2badfdab53
|
||||
"@formatjs/intl-localematcher": "npm:0.8.12"
|
||||
checksum: 10/c667ed62a13cbc8cd5d07f5dbc57a4d1b7e23c31f2aac0ef9e5f509feec014c5a2dfd77ac41a2c34fe537d6f1f6d8ebc8391a479a4294867bb4e980c7211f822
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formatjs/intl-supportedvaluesof@npm:2.3.8":
|
||||
version: 2.3.8
|
||||
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.8"
|
||||
"@formatjs/intl-supportedvaluesof@npm:2.3.9":
|
||||
version: 2.3.9
|
||||
resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.9"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.6"
|
||||
checksum: 10/d9d4e4d5dda1c26c771d0f2746e1031f9695dee663fb2d2ef9f83794c1e8683de88caf6cdb9718022c592cc879c5c1afa7f72af4c77da9ce5e1d98b54267dfff
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
checksum: 10/36254064dd8aab3cb2cbe31db88d751cb0c1d2b0739563ddd1736ed7bd8ec02881de385a69cc4ab7b691f670a3f11ec61a41ba678baa3d0967d87dc0b395cc7b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8681,9 +8681,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint@npm:10.6.0":
|
||||
version: 10.6.0
|
||||
resolution: "eslint@npm:10.6.0"
|
||||
"eslint@npm:10.7.0":
|
||||
version: 10.7.0
|
||||
resolution: "eslint@npm:10.7.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.8.0"
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
@@ -8722,7 +8722,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
eslint: bin/eslint.js
|
||||
checksum: 10/36d02cdbe37668e601f255917c605d10a5d06d278648dc72cec21fe23b7b60a53453241b32c382ba8107533cacba70aeec97581cc4f4bb591544b1ada2516335
|
||||
checksum: 10/51cb70fbdd0de6586f0cb12625388faab18eb679cbdb523ecb631cae9f47fd9171d9ff02f570dc4d2e5700017908a81477511025ccb2a6603c5928fbfddcaebf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9736,15 +9736,15 @@ __metadata:
|
||||
"@date-fns/tz": "npm:1.5.0"
|
||||
"@egjs/hammerjs": "npm:2.0.17"
|
||||
"@eslint/js": "npm:10.0.1"
|
||||
"@formatjs/intl-datetimeformat": "npm:7.4.10"
|
||||
"@formatjs/intl-displaynames": "npm:7.3.11"
|
||||
"@formatjs/intl-durationformat": "npm:0.10.16"
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.10"
|
||||
"@formatjs/intl-listformat": "npm:8.3.11"
|
||||
"@formatjs/intl-locale": "npm:5.3.9"
|
||||
"@formatjs/intl-numberformat": "npm:9.3.12"
|
||||
"@formatjs/intl-pluralrules": "npm:6.3.11"
|
||||
"@formatjs/intl-relativetimeformat": "npm:12.3.11"
|
||||
"@formatjs/intl-datetimeformat": "npm:7.5.0"
|
||||
"@formatjs/intl-displaynames": "npm:7.3.12"
|
||||
"@formatjs/intl-durationformat": "npm:0.10.17"
|
||||
"@formatjs/intl-getcanonicallocales": "npm:3.2.11"
|
||||
"@formatjs/intl-listformat": "npm:8.3.12"
|
||||
"@formatjs/intl-locale": "npm:5.3.10"
|
||||
"@formatjs/intl-numberformat": "npm:9.3.13"
|
||||
"@formatjs/intl-pluralrules": "npm:6.3.12"
|
||||
"@formatjs/intl-relativetimeformat": "npm:12.3.12"
|
||||
"@fullcalendar/core": "npm:6.1.21"
|
||||
"@fullcalendar/daygrid": "npm:6.1.21"
|
||||
"@fullcalendar/interaction": "npm:6.1.21"
|
||||
@@ -9814,7 +9814,7 @@ __metadata:
|
||||
dialog-polyfill: "npm:0.5.6"
|
||||
echarts: "npm:6.1.0"
|
||||
element-internals-polyfill: "npm:3.0.2"
|
||||
eslint: "npm:10.6.0"
|
||||
eslint: "npm:10.7.0"
|
||||
eslint-config-prettier: "npm:10.1.8"
|
||||
eslint-import-resolver-webpack: "npm:0.13.11"
|
||||
eslint-plugin-import-x: "npm:4.17.1"
|
||||
@@ -9838,7 +9838,7 @@ __metadata:
|
||||
html-minifier-terser: "npm:7.2.0"
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.10"
|
||||
intl-messageformat: "npm:11.2.11"
|
||||
js-yaml: "npm:5.2.1"
|
||||
jsdom: "npm:29.1.1"
|
||||
jszip: "npm:3.10.1"
|
||||
@@ -10186,13 +10186,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"intl-messageformat@npm:11.2.10":
|
||||
version: 11.2.10
|
||||
resolution: "intl-messageformat@npm:11.2.10"
|
||||
"intl-messageformat@npm:11.2.11":
|
||||
version: 11.2.11
|
||||
resolution: "intl-messageformat@npm:11.2.11"
|
||||
dependencies:
|
||||
"@formatjs/fast-memoize": "npm:3.1.6"
|
||||
"@formatjs/icu-messageformat-parser": "npm:3.5.13"
|
||||
checksum: 10/d3f751ebfe2015cf4a011afe5679398dfa7156c0bc2ccd76ba8c5aa4e82946bd663e750538c97a8b4c946a6d5f5d19fbb56b2e159d422c416e51bba9b10b2746
|
||||
"@formatjs/fast-memoize": "npm:3.1.7"
|
||||
"@formatjs/icu-messageformat-parser": "npm:3.5.14"
|
||||
checksum: 10/5074494588d6c16fb2730234b3c8eb80799189c9c21485323b5c119c22d3a7a8b385868b46479b81c09ca350d82ea0eeaf82b5e9799911b9a1b8bddbb2c3ad1c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user