mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-11 17:12:53 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898072e3d8 |
+1
-31
@@ -17,9 +17,6 @@ const rspackConfigPath = fileURLToPath(
|
||||
new URL("./rspack.config.cjs", import.meta.url)
|
||||
);
|
||||
|
||||
// Applies everywhere, including the files exempted from the history rule below.
|
||||
const restrictedSyntax = ["LabeledStatement", "WithStatement"];
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
@@ -114,16 +111,7 @@ export default tseslint.config(
|
||||
"no-bitwise": "error",
|
||||
"no-console": "error",
|
||||
"no-restricted-globals": [2, "event"],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
...restrictedSyntax,
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.property.name=/^(push|replace)State$/]",
|
||||
message:
|
||||
"Use navigate(), updateHistoryState() or replaceCurrentUrl() from common/navigate. History entries carry the app's own bookkeeping, which a raw pushState/replaceState drops.",
|
||||
},
|
||||
],
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"wc/no-self-class": "off",
|
||||
|
||||
// import-x rules
|
||||
@@ -234,24 +222,6 @@ export default tseslint.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// These own history entries themselves: the navigation helpers, the dialog
|
||||
// stack, the boot paths that run before the app has any state to keep, and
|
||||
// the tests that fabricate entries to simulate a document load.
|
||||
files: [
|
||||
"src/common/navigate.ts",
|
||||
"src/dialogs/make-dialog-manager.ts",
|
||||
"src/state/url-sync-mixin.ts",
|
||||
"src/panels/config/automation/add-automation-element-dialog.ts",
|
||||
"src/entrypoints/core.ts",
|
||||
"src/onboarding/**/*.ts",
|
||||
"cast/**/*.ts",
|
||||
"test/**/*.ts",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-syntax": ["error", ...restrictedSyntax],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/util/recorder-worklet.js"],
|
||||
languageOptions: {
|
||||
|
||||
@@ -37,16 +37,6 @@ title: Button
|
||||
<ha-button size="s"> small </ha-button>
|
||||
```
|
||||
|
||||
### Icons in the `xs` size
|
||||
|
||||
Avoid icons in `xs` buttons. At 24px the label carries the meaning on its own, and a
|
||||
16px glyph next to it adds visual noise without adding information.
|
||||
|
||||
Use an icon only when the button needs to be recognized at a glance in a dense layout,
|
||||
and only when the glyph is a common one users can identify from its silhouette alone,
|
||||
such as close, add, or settings. A detailed or unfamiliar glyph is unreadable at this
|
||||
size and should be replaced by the label alone.
|
||||
|
||||
### API
|
||||
|
||||
This component is based on the webawesome button component.
|
||||
|
||||
@@ -56,19 +56,6 @@ export class DemoHaButton extends LitElement {
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
${appearances.map(
|
||||
(appearance) => html`
|
||||
<ha-button
|
||||
.appearance=${appearance}
|
||||
.variant=${variant}
|
||||
size="xs"
|
||||
>
|
||||
${titleCase(`${variant} ${appearance}`)}
|
||||
</ha-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
${appearances.map(
|
||||
(appearance) => html`
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { getHistoryState, updateHistoryState } from "../navigate";
|
||||
import { throttle } from "../util/throttle";
|
||||
|
||||
const throttleReplaceState = throttle((value) => {
|
||||
updateHistoryState({ scrollPosition: value });
|
||||
history.replaceState({ scrollPosition: value }, "");
|
||||
}, 300);
|
||||
|
||||
export function restoreScroll(selector: string) {
|
||||
@@ -40,8 +39,7 @@ export function restoreScroll(selector: string) {
|
||||
newDescriptor = {
|
||||
get(this: ReactiveElement) {
|
||||
return (
|
||||
this[`__${String(propertyKey)}`] ||
|
||||
getHistoryState()?.scrollPosition
|
||||
this[`__${String(propertyKey)}`] || history.state?.scrollPosition
|
||||
);
|
||||
},
|
||||
set(this: ReactiveElement, value) {
|
||||
|
||||
+41
-78
@@ -1,7 +1,6 @@
|
||||
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
|
||||
import { fireEvent } from "./dom/fire_event";
|
||||
import { mainWindow } from "./dom/get_main_window";
|
||||
import { currentPath } from "./url/current-path";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -12,38 +11,12 @@ declare global {
|
||||
|
||||
export interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// max time to wait for dialogs to close before navigating
|
||||
const DIALOG_WAIT_TIMEOUT = 500;
|
||||
|
||||
/**
|
||||
* State of the current history entry. Always read through this, the app writes
|
||||
* to the main window and a panel running in an iframe has its own history.
|
||||
*/
|
||||
export const getHistoryState = (): any => mainWindow.history.state;
|
||||
|
||||
/**
|
||||
* Merge into the current history entry's state, keeping what is already there.
|
||||
* Entries carry the app's own bookkeeping (`from`, `root`, dialog state), so
|
||||
* they must never be replaced wholesale.
|
||||
*/
|
||||
export const updateHistoryState = (patch: Record<string, unknown>) => {
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, ...patch },
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrite the URL of the current history entry without navigating and without
|
||||
* touching its state. For query parameter cleanup.
|
||||
*/
|
||||
export const replaceCurrentUrl = (url: string) => {
|
||||
mainWindow.history.replaceState(mainWindow.history.state, "", url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stash a destination URL in the current history entry's state. If the page
|
||||
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
|
||||
@@ -51,7 +24,10 @@ export const replaceCurrentUrl = (url: string) => {
|
||||
* The current URL is not changed.
|
||||
*/
|
||||
export const setRefreshUrl = (path: string) => {
|
||||
updateHistoryState({ refreshUrl: path });
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, refreshUrl: path },
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,17 +56,6 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
|
||||
return ensureDialogsClosed(timestamp);
|
||||
};
|
||||
|
||||
const buildHistoryState = (
|
||||
data: Record<string, unknown> | undefined,
|
||||
from?: string
|
||||
) => {
|
||||
const state = typeof data === "object" ? data : undefined;
|
||||
if (from === undefined) {
|
||||
return state ?? null;
|
||||
}
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
@@ -98,32 +63,37 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
}
|
||||
const replace = options?.replace || false;
|
||||
|
||||
if (__DEMO__ && !path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
}
|
||||
|
||||
const { history } = mainWindow;
|
||||
|
||||
if (replace) {
|
||||
// A replaced entry keeps its predecessor, so it keeps `from`.
|
||||
const { root, from } = history.state ?? {};
|
||||
const data = root ? { root: true } : options?.data;
|
||||
history.replaceState(buildHistoryState(data, from), "", path);
|
||||
} else {
|
||||
history.pushState(
|
||||
buildHistoryState(options?.data, currentPath()),
|
||||
if (__DEMO__) {
|
||||
if (!path.includes("#")) {
|
||||
// The demo routes with the hash instead of the pathname. Resolve the
|
||||
// path like the browser would do for pushState, and keep the query
|
||||
// parameters in the URL query instead of inside the hash.
|
||||
const url = new URL(
|
||||
path,
|
||||
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
|
||||
);
|
||||
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
|
||||
}
|
||||
if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root
|
||||
? { root: true }
|
||||
: (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
} else if (replace) {
|
||||
mainWindow.history.replaceState(
|
||||
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
|
||||
"",
|
||||
path
|
||||
);
|
||||
} else {
|
||||
mainWindow.history.pushState(options?.data ?? null, "", path);
|
||||
}
|
||||
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
@@ -131,17 +101,8 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the previous history entry is a page this app navigated away from.
|
||||
* `history.length` cannot answer this: a login redirect goes through
|
||||
* `location.assign`, which leaves /auth/authorize right behind the requested
|
||||
* page, and going back there would bounce the user out of the app.
|
||||
*/
|
||||
export const canGoBack = (): boolean =>
|
||||
mainWindow.history.state?.from !== undefined;
|
||||
|
||||
/**
|
||||
* Navigate back to the page we came from, falling back to a path when the
|
||||
* previous entry is not ours (deep link, login redirect, fresh tab).
|
||||
* Navigate back in history, with fallback to a default path if no history exists.
|
||||
* This prevents a user from getting stuck when they navigate directly to a page with no history.
|
||||
*/
|
||||
export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
@@ -149,12 +110,14 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Read after closing dialogs: their history entries are popped by then, so
|
||||
// this is the state of the page entry.
|
||||
if (canGoBack()) {
|
||||
mainWindow.history.back();
|
||||
// Check if we have history to go back to
|
||||
const { history } = mainWindow;
|
||||
if (history.length > 1) {
|
||||
history.back();
|
||||
return;
|
||||
}
|
||||
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
// No history available, navigate to fallback path
|
||||
const fallback = fallbackPath || "/";
|
||||
navigate(fallback, { replace: true });
|
||||
};
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { mainWindow } from "../dom/get_main_window";
|
||||
|
||||
/**
|
||||
* The path of the page currently shown by the app. The demo routes with the
|
||||
* hash instead of the pathname, see navigate().
|
||||
*/
|
||||
export const currentPath = (): string =>
|
||||
__DEMO__
|
||||
? mainWindow.location.hash.substring(1)
|
||||
: mainWindow.location.pathname;
|
||||
@@ -10,12 +10,16 @@ interface MeanFrame {
|
||||
}
|
||||
|
||||
interface MinMaxFrame {
|
||||
// A frame can hold a gap marker before any value lands in it, so the min/max
|
||||
// slots below only mean something once this is true.
|
||||
hasValue: boolean;
|
||||
minPoint: Point;
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxPoint: Point;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
gapPoint: Point | undefined;
|
||||
}
|
||||
|
||||
const SECOND = 1000;
|
||||
@@ -49,6 +53,25 @@ function snapFrameSize(step: number): number {
|
||||
return snapped;
|
||||
}
|
||||
|
||||
// y is NaN for a frame seeded by a gap marker, which has no value yet.
|
||||
function newFrame(
|
||||
point: Point,
|
||||
x: number,
|
||||
y: number,
|
||||
gapPoint: Point | undefined
|
||||
): MinMaxFrame {
|
||||
return {
|
||||
hasValue: gapPoint === undefined,
|
||||
minPoint: point,
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
gapPoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function downSampleLineData<
|
||||
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
|
||||
>(
|
||||
@@ -82,7 +105,10 @@ export function downSampleLineData<
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
const y = Number(pointData[1]);
|
||||
const rawY = pointData[1] as number | null;
|
||||
// Number(null) is 0, which would drag the mean towards zero
|
||||
if (rawY === null) continue;
|
||||
const y = Number(rawY);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor(x / step);
|
||||
@@ -120,21 +146,34 @@ export function downSampleLineData<
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
if (isNaN(x)) continue;
|
||||
const rawY = pointData[1] as number | null;
|
||||
if (rawY === null) {
|
||||
// The chart data modules push a null value to break the line where an
|
||||
// entity was unavailable. Number(null) is 0, so such a marker must stay
|
||||
// out of the comparisons below, where it would win the minimum slot
|
||||
// whenever the readings are positive and discard the frame's real
|
||||
// minimum. One marker per frame is enough to break the line, and keeping
|
||||
// them all would blow up the output on series that are mostly null. The
|
||||
// last one wins: where the break lands only depends on which points it
|
||||
// sits between, not on its own x.
|
||||
const gapIndex = Math.floor(x / step);
|
||||
const gapFrame = frames.get(gapIndex);
|
||||
if (gapFrame) {
|
||||
gapFrame.gapPoint = point;
|
||||
} else {
|
||||
frames.set(gapIndex, newFrame(point, x, NaN, point));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const y = Number(rawY);
|
||||
if (isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor(x / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, {
|
||||
minPoint: point,
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
});
|
||||
} else {
|
||||
frames.set(frameIndex, newFrame(point, x, y, undefined));
|
||||
} else if (frame.hasValue) {
|
||||
// Match the original strict-less / strict-greater comparisons so the
|
||||
// first occurrence wins on ties.
|
||||
if (y < frame.minY) {
|
||||
@@ -147,18 +186,40 @@ export function downSampleLineData<
|
||||
frame.maxX = x;
|
||||
frame.maxY = y;
|
||||
}
|
||||
} else {
|
||||
// the frame held nothing but a marker so far
|
||||
frame.hasValue = true;
|
||||
frame.minPoint = point;
|
||||
frame.minX = x;
|
||||
frame.minY = y;
|
||||
frame.maxPoint = point;
|
||||
frame.maxX = x;
|
||||
frame.maxY = y;
|
||||
}
|
||||
}
|
||||
|
||||
const result: T[] = [];
|
||||
for (const frame of frames.values()) {
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (frame.minX > frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
if (frame.hasValue) {
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (frame.minX > frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
result.push(frame.minPoint as T);
|
||||
if (frame.minX < frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
}
|
||||
}
|
||||
result.push(frame.minPoint as T);
|
||||
if (frame.minX < frame.maxX) {
|
||||
result.push(frame.maxPoint as T);
|
||||
if (frame.gapPoint !== undefined) {
|
||||
// A marker followed by a value in its own frame is a gap that closed
|
||||
// within one frame, which is about one device pixel: too narrow to show.
|
||||
// The kept points are exactly min and max, so comparing against the
|
||||
// later of the two catches that without any work on the ingest path. A
|
||||
// marker-only frame compares against its own x and always passes.
|
||||
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
|
||||
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
|
||||
result.push(frame.gapPoint as T);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,21 +65,6 @@ export class HaButton extends Button {
|
||||
box-shadow: var(--ha-button-box-shadow);
|
||||
}
|
||||
|
||||
:host([size="xs"]) .button {
|
||||
--wa-form-control-height: var(
|
||||
--ha-button-height,
|
||||
var(--button-height, 24px)
|
||||
);
|
||||
font-size: var(--ha-font-size-m);
|
||||
--wa-form-control-padding-inline: var(--ha-space-2);
|
||||
}
|
||||
|
||||
/* A default 24px icon would fill the whole xs button. */
|
||||
:host([size="xs"]) slot[name="start"]::slotted(*),
|
||||
:host([size="xs"]) slot[name="end"]::slotted(*) {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
:host([size="s"]) .button {
|
||||
--wa-form-control-height: var(
|
||||
--ha-button-height,
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
type TargetType,
|
||||
} from "../../data/target";
|
||||
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { buttonLinkStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
@@ -220,28 +221,30 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
? html`
|
||||
<div slot="end" class="summary">
|
||||
${
|
||||
this.expand || !entries.referenced_entities.length
|
||||
? html`<span class="main">
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</span>`
|
||||
: html`<ha-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
size="xs"
|
||||
showEntities &&
|
||||
!this.expand &&
|
||||
entries?.referenced_entities.length
|
||||
? html`<button
|
||||
class="main link"
|
||||
@click=${this._openDetails}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries.referenced_entities.length,
|
||||
count: entries?.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</ha-button>`
|
||||
</button>`
|
||||
: showEntities
|
||||
? html`<span class="main">
|
||||
${this.hass.localize(
|
||||
"ui.components.target-picker.entities_count",
|
||||
{
|
||||
count: entries?.referenced_entities.length,
|
||||
}
|
||||
)}
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`
|
||||
@@ -809,6 +812,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
};
|
||||
|
||||
static styles = [
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
:host {
|
||||
--md-list-item-top-space: 0;
|
||||
@@ -879,6 +883,16 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
button.link {
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
button.link:hover,
|
||||
button.link:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.state {
|
||||
width: fit-content;
|
||||
font-size: var(--ha-font-size-s);
|
||||
|
||||
@@ -146,13 +146,15 @@ export const updateDeviceRegistryEntry = (
|
||||
...updates,
|
||||
});
|
||||
|
||||
export const removeDeviceFromRegistry = (
|
||||
export const removeConfigEntryFromDevice = (
|
||||
hass: HomeAssistant,
|
||||
deviceId: string
|
||||
deviceId: string,
|
||||
configEntryId: string
|
||||
) =>
|
||||
hass.callWS<null>({
|
||||
type: "config/device_registry/remove",
|
||||
hass.callWS<DeviceRegistryEntry>({
|
||||
type: "config/device_registry/remove_config_entry",
|
||||
device_id: deviceId,
|
||||
config_entry_id: configEntryId,
|
||||
});
|
||||
|
||||
export const sortDeviceRegistryByName = (
|
||||
|
||||
@@ -98,31 +98,20 @@ export const showDialog = async (
|
||||
return false;
|
||||
}
|
||||
LOADED[dialogTag] = {
|
||||
element: dialogImport().then(
|
||||
() => {
|
||||
const dialogEl = document.createElement(dialogTag) as
|
||||
HassDialogNext | HassDialog;
|
||||
element: dialogImport().then(() => {
|
||||
const dialogEl = document.createElement(dialogTag) as
|
||||
HassDialogNext | HassDialog;
|
||||
|
||||
if ("showDialog" in dialogEl) {
|
||||
// provide hass for legacy persistent dialogs
|
||||
element.provideHass(dialogEl);
|
||||
}
|
||||
|
||||
dialogEl.addEventListener("dialog-closed", _handleClosed);
|
||||
dialogEl.addEventListener("dialog-closed", _handleClosedFocus);
|
||||
|
||||
return dialogEl;
|
||||
},
|
||||
(err) => {
|
||||
// Don't cache a rejected import (e.g. a stale build's chunk 404s
|
||||
// while the app stayed open): drop the entry so a later open
|
||||
// re-imports instead of being permanently stuck on the rejected
|
||||
// promise. The rejection still propagates to the global stale-build
|
||||
// handler (logging-mixin) for recovery.
|
||||
delete LOADED[dialogTag];
|
||||
throw err;
|
||||
if ("showDialog" in dialogEl) {
|
||||
// provide hass for legacy persistent dialogs
|
||||
element.provideHass(dialogEl);
|
||||
}
|
||||
),
|
||||
|
||||
dialogEl.addEventListener("dialog-closed", _handleClosed);
|
||||
dialogEl.addEventListener("dialog-closed", _handleClosedFocus);
|
||||
|
||||
return dialogEl;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,7 @@ import {
|
||||
getEntityEntryContext,
|
||||
} from "../../common/entity/context/get_entity_context";
|
||||
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../common/navigate";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { withViewTransition } from "../../common/util/view-transition";
|
||||
@@ -272,12 +268,16 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private _setView(view: MoreInfoView) {
|
||||
updateHistoryState({
|
||||
dialogParams: {
|
||||
...getHistoryState()?.dialogParams,
|
||||
view,
|
||||
history.replaceState(
|
||||
{
|
||||
...history.state,
|
||||
dialogParams: {
|
||||
...history.state?.dialogParams,
|
||||
view,
|
||||
},
|
||||
},
|
||||
});
|
||||
""
|
||||
);
|
||||
this._currView = view;
|
||||
}
|
||||
|
||||
@@ -1063,10 +1063,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
outline: none;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
/* Keep the content width constant when the scrollbar toggles;
|
||||
otherwise width-dependent content can flicker at the overflow
|
||||
threshold (#53228). */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.content-wrapper.settings-view .fade-bottom {
|
||||
|
||||
@@ -191,10 +191,6 @@ interface EMOutgoingMessageFocusElement extends EMMessage {
|
||||
};
|
||||
}
|
||||
|
||||
interface EMOutgoingMessageReloadAndClearCache extends EMMessage {
|
||||
type: "frontend/reload_and_clear_cache";
|
||||
}
|
||||
|
||||
// These types are handled internally by the Android app via postMessage.
|
||||
// They are not sent by the frontend and should not be used directly.
|
||||
// They are intentionally listed here to prevent anyone from using them unintentionally.
|
||||
@@ -224,7 +220,6 @@ type EMOutgoingMessageWithoutAnswer =
|
||||
| EMOutgoingMessageImprovConfigureDevice
|
||||
| EMOutgoingMessageAddEntityTo
|
||||
| EMOutgoingMessageFocusElement
|
||||
| EMOutgoingMessageReloadAndClearCache
|
||||
| EMOutgoingMessageAssistSettings;
|
||||
|
||||
export interface EMIncomingMessageRestart {
|
||||
@@ -516,26 +511,14 @@ export class ExternalMessaging {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Sending message to external app", msg);
|
||||
}
|
||||
fireExternalBusMessage(msg);
|
||||
if (window.externalAppV2) {
|
||||
window.externalAppV2.postMessage(
|
||||
JSON.stringify({ type: "externalBus", payload: msg })
|
||||
);
|
||||
} else if (window.externalApp) {
|
||||
window.externalApp.externalBus(JSON.stringify(msg));
|
||||
} else {
|
||||
window.webkit!.messageHandlers.externalBus.postMessage(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a message to the companion app's external bus without needing an
|
||||
* `ExternalMessaging` instance (i.e. without `hass`). Returns `false` when no
|
||||
* external bridge is present, so callers can fall back to browser behavior.
|
||||
*/
|
||||
export const fireExternalBusMessage = (msg: EMMessage): boolean => {
|
||||
if (window.externalAppV2) {
|
||||
window.externalAppV2.postMessage(
|
||||
JSON.stringify({ type: CALLBACK_EXTERNAL_BUS, payload: msg })
|
||||
);
|
||||
} else if (window.externalApp) {
|
||||
window.externalApp.externalBus(JSON.stringify(msg));
|
||||
} else if (window.webkit?.messageHandlers?.externalBus) {
|
||||
window.webkit.messageHandlers.externalBus.postMessage(msg);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
|
||||
/**
|
||||
* Shared behavior of the toolbar back arrow. The arrow is a link to the
|
||||
* declared parent page so it can be opened in a new tab, but a plain click
|
||||
* returns to the page the user came from instead.
|
||||
*/
|
||||
export const handleBackClick = (
|
||||
ev: MouseEvent,
|
||||
backPath?: string,
|
||||
backCallback?: () => void
|
||||
): void => {
|
||||
const path = sanitizeNavigationPath(backPath);
|
||||
|
||||
// Ctrl, cmd and shift click open the parent in a new tab or window: let
|
||||
// the anchor handle those. A plain click is handled here instead, and
|
||||
// isNavigationClick calls preventDefault so the anchor stays inert.
|
||||
if (path && !isNavigationClick(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (backCallback) {
|
||||
backCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
goBack(path);
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { getHistoryState, goBack } from "../common/navigate";
|
||||
import { goBack } from "../common/navigate";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-top-app-bar-fixed";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { reloadForUpdate } from "../util/recover-stale-build";
|
||||
import "../components/ha-alert";
|
||||
|
||||
@customElement("hass-error-screen")
|
||||
@@ -20,9 +19,6 @@ class HassErrorScreen extends LitElement {
|
||||
|
||||
@property() public error?: string;
|
||||
|
||||
@property({ type: Boolean, attribute: "show-reload" }) public showReload =
|
||||
false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this.toolbar) {
|
||||
return this._renderContent();
|
||||
@@ -31,7 +27,7 @@ class HassErrorScreen extends LitElement {
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
.backButton=${!(this.rootnav || getHistoryState()?.root)}
|
||||
.backButton=${!(this.rootnav || history.state?.root)}
|
||||
>
|
||||
${this._renderContent()}
|
||||
</ha-top-app-bar-fixed>
|
||||
@@ -43,19 +39,6 @@ class HassErrorScreen extends LitElement {
|
||||
<div class="content">
|
||||
<ha-alert alert-type="error">${this.error}</ha-alert>
|
||||
<slot>
|
||||
${
|
||||
this.showReload
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._handleReload}
|
||||
>
|
||||
${this.hass?.localize("ui.common.refresh")}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-button appearance="plain" size="s" @click=${this._handleBack}>
|
||||
${this.hass?.localize("ui.common.back")}
|
||||
</ha-button>
|
||||
@@ -68,12 +51,6 @@ class HassErrorScreen extends LitElement {
|
||||
goBack();
|
||||
}
|
||||
|
||||
private _handleReload(): void {
|
||||
// Dirty-aware: reloads when clean, or defers with a toast when an editor
|
||||
// has unsaved changes.
|
||||
reloadForUpdate();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { getHistoryState } from "../common/navigate";
|
||||
import "../components/animation/ha-fade-in";
|
||||
import "../components/ha-top-app-bar-fixed";
|
||||
import "../components/ha-spinner";
|
||||
@@ -28,7 +27,7 @@ class HassLoadingScreen extends LitElement {
|
||||
return html`
|
||||
<ha-top-app-bar-fixed
|
||||
.narrow=${this.narrow}
|
||||
.backButton=${!(this.rootnav || getHistoryState()?.root)}
|
||||
.backButton=${!(this.rootnav || history.state?.root)}
|
||||
>
|
||||
${this._renderContent()}
|
||||
</ha-top-app-bar-fixed>
|
||||
|
||||
@@ -5,7 +5,6 @@ import memoizeOne from "memoize-one";
|
||||
import { navigate } from "../common/navigate";
|
||||
import { computeRouteTail } from "../common/url/route";
|
||||
import type { Route } from "../types";
|
||||
import { recoverFromStaleBuild } from "../util/recover-stale-build";
|
||||
import { PanelReady } from "./panel-ready";
|
||||
|
||||
const extractPage = (path: string, defaultPage: string) => {
|
||||
@@ -183,21 +182,10 @@ export class HassRouterPage extends ReactiveElement {
|
||||
this._showLoadingScreenTimeout = undefined;
|
||||
}
|
||||
|
||||
// A stale build (the panel's hashed chunk 404s after an upgrade while
|
||||
// the app stayed open) is recoverable: reload onto the current build
|
||||
// (or prompt when there are unsaved edits) instead of dead-ending.
|
||||
const message = err instanceof Error ? err.message : String(err ?? "");
|
||||
const stale = recoverFromStaleBuild(message, this);
|
||||
|
||||
// Show error screen, offering a reload action for a stale build. Set
|
||||
// `showReload` on the returned element rather than through
|
||||
// createErrorScreen's signature, so router subclasses that override
|
||||
// createErrorScreen (e.g. ToolsRouter) can't drop it.
|
||||
const errorScreen = this.createErrorScreen(
|
||||
`Error while loading page ${newPage}.`
|
||||
// Show error screen
|
||||
this.appendChild(
|
||||
this.createErrorScreen(`Error while loading page ${newPage}.`)
|
||||
);
|
||||
errorScreen.showReload = stale;
|
||||
this.appendChild(errorScreen);
|
||||
});
|
||||
|
||||
// If we don't show loading screen, just show the panel.
|
||||
|
||||
+19
-11
@@ -4,9 +4,8 @@ import { customElement, eventOptions, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
|
||||
import { getHistoryState } from "../common/navigate";
|
||||
import { goBack } from "../common/navigate";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import { handleBackClick } from "./back-navigation";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
@@ -38,14 +37,19 @@ class HassSubpage extends LitElement {
|
||||
<div class="toolbar ${classMap({ narrow: this.narrow })}">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || (!backPath && getHistoryState()?.root)
|
||||
this.mainPage || history.state?.root
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
href=${backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
}
|
||||
|
||||
<div class="main-title">
|
||||
@@ -75,8 +79,12 @@ class HassSubpage extends LitElement {
|
||||
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
|
||||
}
|
||||
|
||||
private _backTapped(ev: MouseEvent): void {
|
||||
handleBackClick(ev, this.backPath, this.backCallback);
|
||||
private _backTapped(): void {
|
||||
if (this.backCallback) {
|
||||
this.backCallback();
|
||||
return;
|
||||
}
|
||||
goBack();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -14,10 +14,9 @@ import { canShowPage } from "../common/config/can_show_page";
|
||||
import { restoreScroll } from "../common/decorators/restore-scroll";
|
||||
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { getHistoryState, navigate } from "../common/navigate";
|
||||
import { goBack, navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
|
||||
import { handleBackClick } from "./back-navigation";
|
||||
import "../components/ha-icon-button-arrow-prev";
|
||||
import "../components/ha-menu-button";
|
||||
import "../components/ha-svg-icon";
|
||||
@@ -174,14 +173,19 @@ export class HassTabsSubpage extends LitElement {
|
||||
<slot name="toolbar">
|
||||
<div class="toolbar-content">
|
||||
${
|
||||
this.mainPage || (!backPath && getHistoryState()?.root)
|
||||
this.mainPage || (!backPath && history.state?.root)
|
||||
? html`<ha-menu-button></ha-menu-button>`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: backPath
|
||||
? html`
|
||||
<ha-icon-button-arrow-prev
|
||||
.href=${backPath}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
: html`
|
||||
<ha-icon-button-arrow-prev
|
||||
@click=${this._backTapped}
|
||||
></ha-icon-button-arrow-prev>
|
||||
`
|
||||
}
|
||||
${
|
||||
this._narrow || !this.showTabs
|
||||
@@ -242,8 +246,12 @@ export class HassTabsSubpage extends LitElement {
|
||||
this._content.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
private _backTapped(ev: MouseEvent): void {
|
||||
handleBackClick(ev, this.backPath, this.backCallback);
|
||||
private _backTapped(): void {
|
||||
if (this.backCallback) {
|
||||
this.backCallback();
|
||||
return;
|
||||
}
|
||||
goBack();
|
||||
}
|
||||
|
||||
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { HomeAssistant, Route } from "../types";
|
||||
import { storeState } from "../util/ha-pref-storage";
|
||||
import { renderLaunchScreenContent } from "../util/launch-screen";
|
||||
import { checkOnboardingSurveyToast } from "../util/onboarding-survey";
|
||||
import { reloadForUpdate } from "../util/recover-stale-build";
|
||||
import {
|
||||
registerServiceWorker,
|
||||
supportsServiceWorker,
|
||||
@@ -248,11 +247,13 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
if (registration) {
|
||||
registration.update();
|
||||
} else if (oldVersion) {
|
||||
reloadForUpdate();
|
||||
// @ts-ignore Firefox supports forceGet
|
||||
location.reload(true);
|
||||
}
|
||||
});
|
||||
} else if (oldVersion) {
|
||||
reloadForUpdate();
|
||||
// @ts-ignore Firefox supports forceGet
|
||||
location.reload(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,7 +644,6 @@ class HaConfigAreaPage extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/areas/dashboard"
|
||||
.header=${html`${
|
||||
area.icon
|
||||
? html`<ha-icon
|
||||
@@ -903,7 +902,7 @@ class HaConfigAreaPage extends LitElement {
|
||||
destructive: true,
|
||||
confirm: async () => {
|
||||
await deleteAreaRegistryEntry(this.hass!, area!.area_id);
|
||||
afterNextRender(() => goBack("/config/areas/dashboard"));
|
||||
afterNextRender(() => goBack("/config"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -86,6 +86,8 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
|
||||
@state() private _hierarchy?: AreasFloorHierarchy;
|
||||
|
||||
private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
private _blockHierarchyUpdate = false;
|
||||
|
||||
private _blockHierarchyUpdateTimeout?: number;
|
||||
@@ -166,7 +168,9 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.isWide=${this.isWide}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.tabs=${configSections.areas}
|
||||
.route=${this.route}
|
||||
has-fab
|
||||
|
||||
@@ -986,7 +986,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
private async _delete() {
|
||||
if (this.automationId) {
|
||||
await deleteAutomation(this.hass, this.automationId);
|
||||
goBack(this.dashboardPath);
|
||||
goBack("/config");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -443,7 +443,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
id="entity_id"
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.automations}
|
||||
|
||||
@@ -147,10 +147,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
|
||||
protected domainHooks!: EditorDomainHooks<TConfig>;
|
||||
|
||||
protected get dashboardPath(): string {
|
||||
return `/config/${this.domainHooks.domain}/dashboard`;
|
||||
}
|
||||
|
||||
protected entityRegCreated?: (
|
||||
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
|
||||
) => void;
|
||||
@@ -256,7 +252,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
protected backTapped = async () => {
|
||||
const result = await this.confirmUnsavedChanged();
|
||||
if (result) {
|
||||
afterNextRender(() => goBack(this.dashboardPath));
|
||||
afterNextRender(() => goBack("/config"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -304,7 +300,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
),
|
||||
text: html`<pre>${alertText}</pre>`,
|
||||
});
|
||||
goBack(this.dashboardPath);
|
||||
goBack("/config");
|
||||
return;
|
||||
}
|
||||
const entity = this.entityRegistry?.find(
|
||||
@@ -321,7 +317,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
`ui.panel.config.${domain}.editor.load_error_not_editable`
|
||||
),
|
||||
});
|
||||
goBack(this.dashboardPath);
|
||||
goBack("/config");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
@@ -110,7 +110,6 @@ export class HaAutomationTrace extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/automation/dashboard"
|
||||
.header=${title}
|
||||
.scrollable=${this.narrow}
|
||||
>
|
||||
@@ -453,7 +452,11 @@ export class HaAutomationTrace extends LitElement {
|
||||
if (runId) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("run_id");
|
||||
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${location.pathname}?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
await showAlertDialog(this, {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
|
||||
import {
|
||||
extractSearchParam,
|
||||
@@ -174,7 +173,11 @@ export const ManualEditorMixin = <TConfig>(
|
||||
}
|
||||
|
||||
protected clearParam(param: string) {
|
||||
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
constructUrlCurrentPath(removeSearchParam(param))
|
||||
);
|
||||
}
|
||||
|
||||
protected async openSidebar(ev: CustomEvent<SidebarConfig>) {
|
||||
|
||||
@@ -74,6 +74,8 @@ class HaConfigBackupOverview extends LitElement {
|
||||
|
||||
@state() private _config?: BackupConfig;
|
||||
|
||||
private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("config") && !this._config) {
|
||||
@@ -204,7 +206,9 @@ class HaConfigBackupOverview extends LitElement {
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/system"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config/system"
|
||||
}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.backup.overview.header")}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-alert";
|
||||
@@ -119,7 +118,7 @@ class HaConfigBackupSettings extends LitElement {
|
||||
}
|
||||
|
||||
private _clearHash() {
|
||||
replaceCurrentUrl(window.location.pathname);
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
|
||||
@@ -21,7 +21,6 @@ export class CloudForgotPassword extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.cloud.forgot_password.title"
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,6 @@ export class CloudLoginPanel extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
header="Home Assistant Cloud"
|
||||
>
|
||||
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
|
||||
|
||||
@@ -38,7 +38,6 @@ export class CloudRegister extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
|
||||
>
|
||||
<div class="content">
|
||||
|
||||
@@ -66,6 +66,8 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _showSkipped = false;
|
||||
|
||||
@state() private _supervisorInfo?: HassioSupervisorInfo;
|
||||
@@ -153,7 +155,9 @@ class HaConfigSectionUpdates extends LitElement {
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/system"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config/system"
|
||||
}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.updates.caption")}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
mdiRobot,
|
||||
mdiScriptText,
|
||||
mdiShapeOutline,
|
||||
mdiTextureBox,
|
||||
mdiTools,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
@@ -45,7 +44,6 @@ import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/item/ha-list-item-base";
|
||||
@@ -67,7 +65,7 @@ import {
|
||||
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import {
|
||||
removeDeviceFromRegistry,
|
||||
removeConfigEntryFromDevice,
|
||||
updateDeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||
@@ -988,7 +986,6 @@ export class HaConfigDevicePage extends LitElement {
|
||||
return html`<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/devices/dashboard"
|
||||
.header=${deviceName}
|
||||
>
|
||||
<ha-tooltip for="edit-settings-button" slot="toolbar-icon">
|
||||
@@ -1038,27 +1035,12 @@ export class HaConfigDevicePage extends LitElement {
|
||||
${
|
||||
area
|
||||
? html`<div class="header-name">
|
||||
<ha-button
|
||||
href="/config/areas/area/${area.area_id}"
|
||||
size="s"
|
||||
appearance="plain"
|
||||
>
|
||||
${
|
||||
area.icon
|
||||
? html`<ha-icon
|
||||
slot="start"
|
||||
.icon=${area.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
${this.hass.localize(
|
||||
<a href="/config/areas/area/${area.area_id}"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.area",
|
||||
{ area: area.name || "Unnamed Area" }
|
||||
)}
|
||||
</ha-button>
|
||||
)}</a
|
||||
>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
@@ -1236,7 +1218,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
|
||||
try {
|
||||
await removeDeviceFromRegistry(this.hass, this.deviceId);
|
||||
await removeConfigEntryFromDevice(
|
||||
this.hass,
|
||||
this.deviceId,
|
||||
entry.entry_id
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
@@ -1761,14 +1747,12 @@ export class HaConfigDevicePage extends LitElement {
|
||||
.header-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: var(--ha-space-2);
|
||||
padding-inline-start: var(--ha-space-2);
|
||||
padding-inline-end: initial;
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.header-name ha-icon,
|
||||
.header-name ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
|
||||
.column,
|
||||
.fullwidth {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -23,11 +23,7 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../../common/navigate";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
hasRejectedItems,
|
||||
@@ -69,7 +65,7 @@ import type {
|
||||
DeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
import {
|
||||
removeDeviceFromRegistry,
|
||||
removeConfigEntryFromDevice,
|
||||
updateDeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
@@ -146,7 +142,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter: string = getHistoryState()?.filter || "";
|
||||
private _filter: string = history.state?.filter || "";
|
||||
|
||||
@state()
|
||||
private _filters: DataTableFilters = {};
|
||||
@@ -266,7 +262,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
}
|
||||
|
||||
this._fromUrl = true;
|
||||
this._filter = getHistoryState()?.filter || "";
|
||||
this._filter = history.state?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-states": {
|
||||
@@ -782,7 +778,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.tabs=${configSections.devices}
|
||||
.route=${this.route}
|
||||
.searchLabel=${this.hass.localize(
|
||||
@@ -1045,7 +1043,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
updateHistoryState({ filter: this._filter });
|
||||
history.replaceState({ filter: this._filter }, "");
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
@@ -1208,9 +1206,19 @@ ${rejected
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
destructive: true,
|
||||
confirm: async () => {
|
||||
const proms: Promise<null>[] = [];
|
||||
const proms: Promise<DeviceRegistryEntry>[] = [];
|
||||
this._selectedCanDelete.forEach((deviceId) => {
|
||||
proms.push(removeDeviceFromRegistry(this.hass!, deviceId));
|
||||
const entries = this.hass!.devices[deviceId]?.config_entries;
|
||||
entries.forEach((entryId) => {
|
||||
if (
|
||||
this.entries.find((entry) => entry.entry_id === entryId)
|
||||
?.supports_remove_device
|
||||
) {
|
||||
proms.push(
|
||||
removeConfigEntryFromDevice(this.hass!, deviceId, entryId)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
const results = await Promise.allSettled(proms);
|
||||
if (hasRejectedItems(results)) {
|
||||
|
||||
@@ -78,6 +78,8 @@ class HaConfigEnergy extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _info?: EnergyInfo;
|
||||
|
||||
@state() private _preferences?: EnergyPreferences;
|
||||
@@ -124,7 +126,11 @@ class HaConfigEnergy extends LitElement {
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
back-path="/config/lovelace/dashboards"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack")
|
||||
? undefined
|
||||
: "/config/lovelace/dashboards"
|
||||
}
|
||||
.route=${this.route}
|
||||
.tabs=${TABS}
|
||||
>
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import { getHistoryState, updateHistoryState } from "../../../common/navigate";
|
||||
import { slugify } from "../../../common/string/slugify";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
@@ -193,7 +192,7 @@ export class HaConfigEntities extends LitElement {
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _filter: string = getHistoryState()?.filter || "";
|
||||
private _filter: string = history.state?.filter || "";
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@@ -811,7 +810,9 @@ export class HaConfigEntities extends LitElement {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.devices}
|
||||
.columns=${this._columns(this.hass.localize, filteredEntities)}
|
||||
@@ -1110,7 +1111,7 @@ export class HaConfigEntities extends LitElement {
|
||||
}
|
||||
|
||||
this._fromUrl = true;
|
||||
this._filter = getHistoryState()?.filter || "";
|
||||
this._filter = history.state?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-states": [],
|
||||
@@ -1245,7 +1246,7 @@ export class HaConfigEntities extends LitElement {
|
||||
|
||||
private _handleSearchChange(ev: CustomEvent) {
|
||||
this._filter = ev.detail.value;
|
||||
updateHistoryState({ filter: this._filter });
|
||||
history.replaceState({ filter: this._filter }, "");
|
||||
}
|
||||
|
||||
private _handleSelectionChanged(
|
||||
|
||||
@@ -24,7 +24,7 @@ import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../common/entity/compute_area_name";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { getHistoryState, navigate } from "../../../common/navigate";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type {
|
||||
LocalizeFunc,
|
||||
LocalizeKeys,
|
||||
@@ -644,7 +644,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.devices}
|
||||
.searchLabel=${this.hass.localize(
|
||||
@@ -1007,7 +1009,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
this._fromUrl = true;
|
||||
this._filter = getHistoryState()?.filter || "";
|
||||
this._filter = history.state?.filter || "";
|
||||
|
||||
this._filters = {
|
||||
"ha-filter-floor-areas": area ? { areas: [area] } : undefined,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type DisableConfigEntryResult,
|
||||
} from "../../../data/config_entries";
|
||||
import {
|
||||
removeDeviceFromRegistry,
|
||||
removeConfigEntryFromDevice,
|
||||
updateDeviceRegistryEntry,
|
||||
type DeviceRegistryEntry,
|
||||
} from "../../../data/device/device_registry";
|
||||
@@ -315,6 +315,7 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
};
|
||||
|
||||
private _handleDeleteDevice = async () => {
|
||||
const entry = this.entry;
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
|
||||
confirmText: this.hass.localize("ui.common.delete"),
|
||||
@@ -327,7 +328,11 @@ class HaConfigEntryDeviceRow extends LitElement {
|
||||
}
|
||||
|
||||
try {
|
||||
await removeDeviceFromRegistry(this.hass!, this.device.id);
|
||||
await removeConfigEntryFromDevice(
|
||||
this.hass!,
|
||||
this.device.id,
|
||||
entry.entry_id
|
||||
);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.devices.error_delete"),
|
||||
|
||||
@@ -373,11 +373,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
: this._manifest?.documentation;
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/integrations/dashboard"
|
||||
>
|
||||
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
|
||||
${
|
||||
documentationLink
|
||||
? html`
|
||||
@@ -905,7 +901,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
if (row) {
|
||||
row.scrollIntoView({
|
||||
block: "start",
|
||||
block: "center",
|
||||
});
|
||||
row.classList.add("highlight");
|
||||
}
|
||||
@@ -1543,7 +1539,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
ha-config-entry-row {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
scroll-margin-top: var(--ha-space-10);
|
||||
}
|
||||
a {
|
||||
text-decoration: none;
|
||||
|
||||
@@ -14,11 +14,7 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../../common/navigate";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
@@ -167,7 +163,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
window.location.hash.substring(1)
|
||||
);
|
||||
|
||||
@state() private _filter: string = getHistoryState()?.filter || "";
|
||||
@state() private _searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _filter: string = history.state?.filter || "";
|
||||
|
||||
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
|
||||
|
||||
@@ -528,7 +526,9 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParams.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.devices}
|
||||
has-fab
|
||||
@@ -885,7 +885,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
this._filter = (ev.target as HaInputSearch).value ?? "";
|
||||
updateHistoryState({ filter: this._filter });
|
||||
history.replaceState({ filter: this._filter }, "");
|
||||
}
|
||||
|
||||
private async _highlightEntry() {
|
||||
|
||||
-1
@@ -249,7 +249,6 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
|
||||
const entry = this._data.find((ent) => ent.address === ev.detail.id);
|
||||
showBluetoothDeviceInfoDialog(this, {
|
||||
entry: entry!,
|
||||
deviceId: this._sourceDevices[ev.detail.id]?.id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
-20
@@ -2,7 +2,6 @@ import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-dialog-footer";
|
||||
@@ -51,14 +50,6 @@ class DialogBluetoothDeviceInfo extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _openDevice(): void {
|
||||
if (!this._params?.deviceId) {
|
||||
return;
|
||||
}
|
||||
navigate(`/config/devices/device/${this._params.deviceId}`);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
@@ -146,17 +137,6 @@ class DialogBluetoothDeviceInfo extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
<ha-dialog-footer slot="footer">
|
||||
${
|
||||
this._params.deviceId
|
||||
? html`
|
||||
<ha-button slot="primaryAction" @click=${this._openDevice}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.bluetooth.open_device"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
|
||||
-1
@@ -3,7 +3,6 @@ import type { BluetoothDeviceData } from "../../../../../data/bluetooth";
|
||||
|
||||
export interface BluetoothDeviceInfoDialogParams {
|
||||
entry: BluetoothDeviceData;
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
export const loadBluetoothDeviceInfoDialog = () =>
|
||||
|
||||
@@ -95,7 +95,6 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
back-path="/config/integrations/integration/dhcp"
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.data=${this._dataWithIds(this._data)}
|
||||
.noDataText=${this.hass.localize(
|
||||
|
||||
@@ -61,11 +61,7 @@ export class MQTTConfigPanel extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
back-path="/config/integrations/integration/mqtt"
|
||||
>
|
||||
<hass-subpage .narrow=${this.narrow} .hass=${this.hass}>
|
||||
<div class="content">
|
||||
<ha-card
|
||||
.header=${this.hass.localize("ui.panel.config.mqtt.settings_title")}
|
||||
|
||||
@@ -99,7 +99,6 @@ export class SSDPConfigPanel extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
back-path="/config/integrations/integration/ssdp"
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
|
||||
@@ -106,7 +106,6 @@ export class ZeroconfConfigPanel extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
back-path="/config/integrations/integration/zeroconf"
|
||||
.columns=${this._columns(this.hass.localize)}
|
||||
.initialGroupColumn=${this._activeGrouping}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
|
||||
@@ -76,7 +76,6 @@ class ZHAAddDevicesPage extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/zha/dashboard"
|
||||
.header=${this.hass.localize("ui.panel.config.zha.add_device")}
|
||||
>
|
||||
<ha-button
|
||||
|
||||
@@ -10,7 +10,7 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import { goBack, navigate } from "../../../../../common/navigate";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import { narrowViewportContext } from "../../../../../data/context";
|
||||
import type { ZHADevice } from "../../../../../data/zha";
|
||||
@@ -102,7 +102,7 @@ class ZHADevicePage extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this._narrow}
|
||||
.header=${header}
|
||||
.backPath=${this._backPath}
|
||||
.backCallback=${this._goBack}
|
||||
>
|
||||
<div class="loading">
|
||||
<ha-spinner size="large"></ha-spinner>
|
||||
@@ -130,7 +130,7 @@ class ZHADevicePage extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.route=${this.route}
|
||||
.tabs=${tabNavigation}
|
||||
.backPath=${this._backPath}
|
||||
.backCallback=${this._goBack}
|
||||
>
|
||||
<div class="container">
|
||||
<zha-device-summary-card
|
||||
@@ -253,11 +253,13 @@ class ZHADevicePage extends LitElement {
|
||||
return ["clusters", "bindings", "signature", "neighbors"].includes(tab);
|
||||
}
|
||||
|
||||
private get _backPath(): string {
|
||||
return this._device
|
||||
? `/config/devices/device/${this._device.device_reg_id}`
|
||||
: "/config/zha/dashboard";
|
||||
}
|
||||
private _goBack = (): void => {
|
||||
goBack(
|
||||
this._device
|
||||
? `/config/devices/device/${this._device.device_reg_id}`
|
||||
: "/config/zha/dashboard"
|
||||
);
|
||||
};
|
||||
|
||||
private _getTabs = memoizeOne((device: ZHADevice | undefined) => {
|
||||
const tabs: ZHADevicePageTab[] = ["clusters", "bindings", "signature"];
|
||||
|
||||
-1
@@ -55,7 +55,6 @@ export class ZHANetworkVisualizationPage extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/zha/dashboard"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.zha.visualization.header"
|
||||
)}
|
||||
|
||||
+1
-1
@@ -640,7 +640,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _handleBack(): void {
|
||||
goBack("/config/integrations/integration/zwave_js");
|
||||
goBack("/config");
|
||||
}
|
||||
|
||||
private _fetchData = async () => {
|
||||
|
||||
+2
-12
@@ -34,12 +34,7 @@ class ZWaveJSConfigEntryPicker extends LitElement {
|
||||
|
||||
if (this._configEntries.length === 0) {
|
||||
return html`
|
||||
<hass-subpage
|
||||
header="Z-Wave"
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
back-path="/config/integrations/integration/zwave_js"
|
||||
>
|
||||
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
|
||||
<div class="content">
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
@@ -56,12 +51,7 @@ class ZWaveJSConfigEntryPicker extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
header="Z-Wave"
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
back-path="/config/integrations/integration/zwave_js"
|
||||
>
|
||||
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
|
||||
<div class="content">
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
subscribeLabFeatures,
|
||||
} from "../../../data/labs";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
@@ -39,7 +38,7 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _preview_features?: LabPreviewFeature[];
|
||||
@state() private _preview_features: LabPreviewFeature[] = [];
|
||||
|
||||
@state() private _highlightedPreviewFeature?: string;
|
||||
|
||||
@@ -99,10 +98,6 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this._preview_features === undefined) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
const sortedFeatures = this._sortedPreviewFeatures(
|
||||
this.hass.localize,
|
||||
this._preview_features
|
||||
|
||||
@@ -51,8 +51,6 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
|
||||
@state() private _resources: LovelaceResource[] = [];
|
||||
|
||||
@state() private _loaded = false;
|
||||
|
||||
@state() private _lovelaceInfo?: LovelaceInfo;
|
||||
|
||||
@state()
|
||||
@@ -136,7 +134,7 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this.hass || !this._loaded) {
|
||||
if (!this.hass || this._resources === undefined) {
|
||||
return html` <hass-loading-screen></hass-loading-screen> `;
|
||||
}
|
||||
|
||||
@@ -177,7 +175,6 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.route=${this.route}
|
||||
back-path="/config/lovelace/dashboards"
|
||||
.tabs=${lovelaceResourcesTabs}
|
||||
.columns=${this._columns(this.hass.language, this.hass.localize)}
|
||||
.data=${this._resources}
|
||||
@@ -231,7 +228,6 @@ export class HaConfigLovelaceResources extends LitElement {
|
||||
]);
|
||||
this._resources = resources;
|
||||
this._lovelaceInfo = lovelaceInfo;
|
||||
this._loaded = true;
|
||||
}
|
||||
|
||||
private _editResource(ev: CustomEvent) {
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
subscribeRepairsIssueRegistry,
|
||||
} from "../../../data/repairs";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-config-repairs";
|
||||
@@ -33,7 +32,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _repairsIssues: RepairsIssue[] = [];
|
||||
|
||||
@state() private _loaded = false;
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _showIgnored = false;
|
||||
|
||||
@@ -61,7 +60,6 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
this._repairsIssues = repairs.issues.sort(
|
||||
(a, b) => severitySort[a.severity] - severitySort[b.severity]
|
||||
);
|
||||
this._loaded = true;
|
||||
const integrations = new Set<string>();
|
||||
for (const issue of this._repairsIssues) {
|
||||
integrations.add(issue.domain);
|
||||
@@ -72,10 +70,6 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this._loaded) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
const issues = this._getFilteredIssues(
|
||||
this._showIgnored,
|
||||
this._repairsIssues
|
||||
@@ -83,7 +77,9 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/system"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config/system"
|
||||
}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.repairs.caption")}
|
||||
|
||||
@@ -459,7 +459,9 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.automations}
|
||||
.searchLabel=${this.hass.localize(
|
||||
|
||||
@@ -939,7 +939,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
{ err_no: err.status_code }
|
||||
),
|
||||
});
|
||||
goBack("/config/scene/dashboard");
|
||||
goBack("/config");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1084,7 +1084,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
if (this._mode === "live") {
|
||||
applyScene(this.hass, this._storedStates);
|
||||
}
|
||||
afterNextRender(() => goBack("/config/scene/dashboard"));
|
||||
afterNextRender(() => goBack("/config"));
|
||||
}
|
||||
|
||||
private _deleteTapped(): void {
|
||||
@@ -1111,7 +1111,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
if (this._mode === "live") {
|
||||
applyScene(this.hass, this._storedStates);
|
||||
}
|
||||
goBack("/config/scene/dashboard");
|
||||
goBack("/config");
|
||||
}
|
||||
|
||||
private async _confirmUnsavedChanged(): Promise<boolean> {
|
||||
|
||||
@@ -896,7 +896,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
|
||||
private async _delete() {
|
||||
await deleteScript(this.hass, this.scriptId!);
|
||||
goBack(this.dashboardPath);
|
||||
goBack("/config");
|
||||
}
|
||||
|
||||
private async _promptScriptAlias(): Promise<boolean> {
|
||||
|
||||
@@ -430,7 +430,9 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.route=${this.route}
|
||||
.tabs=${configSections.automations}
|
||||
.searchLabel=${this.hass.localize(
|
||||
|
||||
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
@@ -106,7 +106,6 @@ export class HaScriptTrace extends LitElement {
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/script/dashboard"
|
||||
.header=${title}
|
||||
.scrollable=${this.narrow}
|
||||
>
|
||||
@@ -434,7 +433,11 @@ export class HaScriptTrace extends LitElement {
|
||||
if (runId) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("run_id");
|
||||
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${location.pathname}?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
await showAlertDialog(this, {
|
||||
|
||||
+1
-7
@@ -16,7 +16,6 @@ import {
|
||||
listAssistPipelines,
|
||||
} from "../../../../data/assist_pipeline";
|
||||
import "../../../../layouts/hass-subpage";
|
||||
import "../../../../layouts/hass-loading-screen";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
|
||||
interface AssistDeviceExtra extends AssistDevice {
|
||||
@@ -125,15 +124,10 @@ class AssistDevicesPage extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._devices) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config/voice-assistants/assistants"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.assistants.pipeline.devices.title"
|
||||
)}
|
||||
@@ -149,7 +143,7 @@ class AssistDevicesPage extends LitElement {
|
||||
this.hass.states,
|
||||
this._pipelines,
|
||||
this._preferred,
|
||||
this._devices
|
||||
this._devices || []
|
||||
)}
|
||||
auto-height
|
||||
@row-click=${this._handleRowClicked}
|
||||
|
||||
@@ -51,7 +51,6 @@ export class AssistPipelineDebug extends LitElement {
|
||||
return html`<hass-subpage
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
back-path="/config/voice-assistants/debug"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.debug.header"
|
||||
)}
|
||||
|
||||
@@ -60,7 +60,6 @@ export class AssistPipelineRunDebug extends LitElement {
|
||||
<hass-subpage
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
back-path="/config/voice-assistants/assistants"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.debug.pipeline.header"
|
||||
)}
|
||||
|
||||
@@ -29,6 +29,8 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return html`<hass-loading-screen></hass-loading-screen>`;
|
||||
@@ -37,7 +39,9 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.route=${this.route}
|
||||
.tabs=${voiceAssistantTabs}
|
||||
>
|
||||
|
||||
@@ -492,7 +492,9 @@ export class VoiceAssistantsExpose extends LitElement {
|
||||
<hass-tabs-subpage-data-table
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.route=${this.route}
|
||||
.tabs=${voiceAssistantTabs}
|
||||
.columns=${this._columns(
|
||||
|
||||
@@ -57,6 +57,8 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _searchParms = new URLSearchParams(window.location.search);
|
||||
|
||||
@state() private _storageItems?: Zone[];
|
||||
|
||||
@state() private _stateItems?: HassEntity[];
|
||||
@@ -246,7 +248,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
<hass-tabs-subpage
|
||||
.hass=${this.hass}
|
||||
.route=${this.route}
|
||||
back-path="/config"
|
||||
.backPath=${
|
||||
this._searchParms.has("historyBack") ? undefined : "/config"
|
||||
}
|
||||
.tabs=${configSections.areas}
|
||||
has-fab
|
||||
>
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { mdiCreation } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import type { LightEntity } from "../../../data/light";
|
||||
import { LightEntityFeature } from "../../../data/light";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
|
||||
import { hasConfigChanged } from "../common/has-changed";
|
||||
import { HuiModeSelectCardFeatureBase } from "./hui-mode-select-card-feature-base";
|
||||
import type {
|
||||
LightEffectCardFeatureConfig,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "./types";
|
||||
|
||||
const supportsLightEffectCardFeatureFromState = (stateObj: HassEntity) => {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
return (
|
||||
domain === "light" &&
|
||||
supportsFeature(stateObj, LightEntityFeature.EFFECT) &&
|
||||
!!stateObj.attributes.effect_list?.length
|
||||
);
|
||||
};
|
||||
|
||||
export const supportsLightEffectCardFeature = (
|
||||
hass: HomeAssistant,
|
||||
context: LovelaceCardFeatureContext
|
||||
) => {
|
||||
const stateObj = context.entity_id
|
||||
? hass.states[context.entity_id]
|
||||
: undefined;
|
||||
if (!stateObj) return false;
|
||||
return supportsLightEffectCardFeatureFromState(stateObj);
|
||||
};
|
||||
|
||||
@customElement("hui-light-effect-card-feature")
|
||||
class HuiLightEffectCardFeature
|
||||
extends HuiModeSelectCardFeatureBase<
|
||||
LightEntity,
|
||||
LightEffectCardFeatureConfig
|
||||
>
|
||||
implements LovelaceCardFeature
|
||||
{
|
||||
protected readonly _attribute = "effect";
|
||||
|
||||
protected readonly _modesAttribute = "effect_list";
|
||||
|
||||
protected get _configuredModes() {
|
||||
const effects = this._config?.effects;
|
||||
return effects?.length ? effects : undefined;
|
||||
}
|
||||
|
||||
protected readonly _dropdownIconPath = mdiCreation;
|
||||
|
||||
protected readonly _allowIconsStyle = false;
|
||||
|
||||
protected readonly _hideLabel = false;
|
||||
|
||||
protected readonly _serviceDomain = "light";
|
||||
|
||||
protected readonly _serviceAction = "turn_on";
|
||||
|
||||
static getStubConfig(): LightEffectCardFeatureConfig {
|
||||
return {
|
||||
type: "light-effect",
|
||||
};
|
||||
}
|
||||
|
||||
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
|
||||
await import("../editor/config-elements/hui-light-effect-card-feature-editor");
|
||||
return document.createElement("hui-light-effect-card-feature-editor");
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
return (
|
||||
changedProps.has("_currentValue") ||
|
||||
changedProps.has("context") ||
|
||||
changedProps.has("_stateObj") ||
|
||||
hasConfigChanged(this, changedProps)
|
||||
);
|
||||
}
|
||||
|
||||
protected _isSupported(): boolean {
|
||||
return !!(
|
||||
this._stateObj && supportsLightEffectCardFeatureFromState(this._stateObj)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-light-effect-card-feature": HuiLightEffectCardFeature;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { supportsLawnMowerCommandCardFeature } from "./hui-lawn-mower-commands-c
|
||||
import { supportsLightBrightnessCardFeature } from "./hui-light-brightness-card-feature";
|
||||
import { supportsLightColorFavoritesCardFeature } from "./hui-light-color-favorites-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "./hui-light-color-temp-card-feature";
|
||||
import { supportsLightEffectCardFeature } from "./hui-light-effect-card-feature";
|
||||
import { supportsLockCommandsCardFeature } from "./hui-lock-commands-card-feature";
|
||||
import { supportsLockOpenDoorCardFeature } from "./hui-lock-open-door-card-feature";
|
||||
import { supportsMediaPlayerPlaybackCardFeature } from "./hui-media-player-playback-card-feature";
|
||||
@@ -89,7 +88,6 @@ export const UI_FEATURE_TYPES = [
|
||||
"light-brightness",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
"lock-commands",
|
||||
"lock-open-door",
|
||||
"media-player-playback",
|
||||
@@ -145,7 +143,6 @@ export const SUPPORTS_FEATURE_TYPES: Record<UiFeatureType, SupportsFeature> = {
|
||||
"light-brightness": supportsLightBrightnessCardFeature,
|
||||
"light-color-temp": supportsLightColorTempCardFeature,
|
||||
"light-color-favorites": supportsLightColorFavoritesCardFeature,
|
||||
"light-effect": supportsLightEffectCardFeature,
|
||||
"lock-commands": supportsLockCommandsCardFeature,
|
||||
"lock-open-door": supportsLockOpenDoorCardFeature,
|
||||
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
|
||||
|
||||
@@ -47,11 +47,6 @@ export interface LightColorFavoritesCardFeatureConfig {
|
||||
type: "light-color-favorites";
|
||||
}
|
||||
|
||||
export interface LightEffectCardFeatureConfig {
|
||||
type: "light-effect";
|
||||
effects?: string[];
|
||||
}
|
||||
|
||||
export interface LockCommandsCardFeatureConfig {
|
||||
type: "lock-commands";
|
||||
}
|
||||
@@ -346,7 +341,6 @@ export type LovelaceCardFeatureConfig =
|
||||
| LightBrightnessCardFeatureConfig
|
||||
| LightColorTempCardFeatureConfig
|
||||
| LightColorFavoritesCardFeatureConfig
|
||||
| LightEffectCardFeatureConfig
|
||||
| LockCommandsCardFeatureConfig
|
||||
| LockOpenDoorCardFeatureConfig
|
||||
| MediaPlayerPlaybackCardFeatureConfig
|
||||
|
||||
@@ -28,7 +28,6 @@ const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
|
||||
TILE_TOGGLE_VARIANT,
|
||||
["light-color-temp"],
|
||||
["light-color-favorites"],
|
||||
["light-effect"],
|
||||
],
|
||||
cover: [
|
||||
TILE_VARIANT,
|
||||
|
||||
@@ -23,7 +23,6 @@ import "../card-features/hui-lawn-mower-commands-card-feature";
|
||||
import "../card-features/hui-light-brightness-card-feature";
|
||||
import "../card-features/hui-light-color-temp-card-feature";
|
||||
import "../card-features/hui-light-color-favorites-card-feature";
|
||||
import "../card-features/hui-light-effect-card-feature";
|
||||
import "../card-features/hui-lock-commands-card-feature";
|
||||
import "../card-features/hui-lock-open-door-card-feature";
|
||||
import "../card-features/hui-media-player-playback-card-feature";
|
||||
@@ -83,7 +82,6 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
|
||||
"light-brightness",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
"lock-commands",
|
||||
"lock-open-door",
|
||||
"media-player-playback",
|
||||
|
||||
@@ -52,7 +52,6 @@ import { supportsHumidifierToggleCardFeature } from "../../card-features/hui-hum
|
||||
import { supportsLawnMowerCommandCardFeature } from "../../card-features/hui-lawn-mower-commands-card-feature";
|
||||
import { supportsLightBrightnessCardFeature } from "../../card-features/hui-light-brightness-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "../../card-features/hui-light-color-temp-card-feature";
|
||||
import { supportsLightEffectCardFeature } from "../../card-features/hui-light-effect-card-feature";
|
||||
import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
|
||||
import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-open-door-card-feature";
|
||||
import { supportsMediaPlayerPlaybackCardFeature } from "../../card-features/hui-media-player-playback-card-feature";
|
||||
@@ -115,7 +114,6 @@ const UI_FEATURE_TYPES = [
|
||||
"light-brightness",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
"lock-commands",
|
||||
"lock-open-door",
|
||||
"media-player-playback",
|
||||
@@ -162,7 +160,6 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
|
||||
"lawn-mower-commands",
|
||||
"media-player-playback",
|
||||
"light-color-favorites",
|
||||
"light-effect",
|
||||
"media-player-sound-mode",
|
||||
"media-player-source",
|
||||
"media-player-volume-buttons",
|
||||
@@ -209,7 +206,6 @@ const SUPPORTS_FEATURE_TYPES: Record<
|
||||
"light-brightness": supportsLightBrightnessCardFeature,
|
||||
"light-color-temp": supportsLightColorTempCardFeature,
|
||||
"light-color-favorites": supportsLightColorFavoritesCardFeature,
|
||||
"light-effect": supportsLightEffectCardFeature,
|
||||
"lock-commands": supportsLockCommandsCardFeature,
|
||||
"lock-open-door": supportsLockOpenDoorCardFeature,
|
||||
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { LightEntity } from "../../../../data/light";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type {
|
||||
LightEffectCardFeatureConfig,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "../../card-features/types";
|
||||
import type { LovelaceCardFeatureEditor } from "../../types";
|
||||
import {
|
||||
customizableListData,
|
||||
customizableListSchema,
|
||||
processCustomizableListValue,
|
||||
} from "./customizable-list-feature";
|
||||
|
||||
@customElement("hui-light-effect-card-feature-editor")
|
||||
export class HuiLightEffectCardFeatureEditor
|
||||
extends LitElement
|
||||
implements LovelaceCardFeatureEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
|
||||
|
||||
@state() private _config?: LightEffectCardFeatureConfig;
|
||||
|
||||
public setConfig(config: LightEffectCardFeatureConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne((stateObj: LightEntity | undefined) =>
|
||||
customizableListSchema({
|
||||
field: "effects",
|
||||
options:
|
||||
stateObj?.attributes.effect_list?.map((effect) => ({
|
||||
value: effect,
|
||||
label: this.hass!.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"effect",
|
||||
effect
|
||||
),
|
||||
})) ?? [],
|
||||
})
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._config) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateObj = this.context?.entity_id
|
||||
? (this.hass.states[this.context.entity_id] as LightEntity | undefined)
|
||||
: undefined;
|
||||
|
||||
const data = customizableListData(this._config, "effects");
|
||||
const schema = this._schema(stateObj);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(
|
||||
ev: ValueChangedEvent<LightEffectCardFeatureConfig>
|
||||
): void {
|
||||
const stateObj = this.context?.entity_id
|
||||
? (this.hass!.states[this.context.entity_id] as LightEntity | undefined)
|
||||
: undefined;
|
||||
const defaults = stateObj?.attributes.effect_list ?? [];
|
||||
const config = processCustomizableListValue<LightEffectCardFeatureConfig>(
|
||||
ev.detail.value,
|
||||
"effects",
|
||||
defaults
|
||||
);
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.features.types.light-effect.${schema.name}`
|
||||
);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-light-effect-card-feature-editor": HuiLightEffectCardFeatureEditor;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { navigate, replaceCurrentUrl } from "../../common/navigate";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
import {
|
||||
@@ -509,7 +509,9 @@ export class LovelacePanel extends LitElement {
|
||||
};
|
||||
|
||||
if ("editMode" in props) {
|
||||
replaceCurrentUrl(
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
constructUrlCurrentPath(
|
||||
props.editMode
|
||||
? addSearchParam({ edit: "1" })
|
||||
|
||||
@@ -27,7 +27,8 @@ import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
|
||||
import { isNavigationClick } from "../../common/dom/is-navigation-click";
|
||||
import { goBack, navigate } from "../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { constructUrlCurrentPath } from "../../common/url/construct-url";
|
||||
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
|
||||
@@ -75,7 +76,6 @@ import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-di
|
||||
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar";
|
||||
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import { handleBackClick } from "../../layouts/back-navigation";
|
||||
import { ChildPanelReady } from "../../layouts/panel-ready";
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
@@ -677,7 +677,11 @@ class HUIRoot extends LitElement {
|
||||
);
|
||||
|
||||
private _clearParam(param: string) {
|
||||
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
constructUrlCurrentPath(removeSearchParam(param))
|
||||
);
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
@@ -889,39 +893,44 @@ class HUIRoot extends LitElement {
|
||||
};
|
||||
|
||||
private _goBack(): void {
|
||||
const configuredBackPath = this._configuredBackPath;
|
||||
if (configuredBackPath) {
|
||||
navigate(configuredBackPath, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const views = this.lovelace?.config.views ?? [];
|
||||
// Falling back to the dashboard root only makes sense when its first view
|
||||
// is a real one.
|
||||
goBack(views[0]?.subview ? undefined : this.route?.prefix);
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
const backPath = sanitizeNavigationPath(
|
||||
curViewConfig?.back_path ?? this.backPath
|
||||
);
|
||||
|
||||
if (backPath) {
|
||||
navigate(backPath, { replace: true });
|
||||
} else if (history.length > 1) {
|
||||
goBack();
|
||||
} else if (!views[0].subview) {
|
||||
navigate(this.route!.prefix, { replace: true });
|
||||
} else {
|
||||
navigate("/");
|
||||
}
|
||||
}
|
||||
|
||||
private _handleBackClick(ev: MouseEvent): void {
|
||||
handleBackClick(ev, this._backPath, () => this._goBack());
|
||||
}
|
||||
|
||||
private get _configuredBackPath(): string | undefined {
|
||||
const views = this.lovelace?.config.views ?? [];
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
return sanitizeNavigationPath(curViewConfig?.back_path ?? this.backPath);
|
||||
if (this._backPath && !isNavigationClick(ev)) {
|
||||
return;
|
||||
}
|
||||
this._goBack();
|
||||
}
|
||||
|
||||
private get _backPath(): string | undefined {
|
||||
if (this._configuredBackPath) {
|
||||
return this._configuredBackPath;
|
||||
}
|
||||
|
||||
const views = this.lovelace?.config.views ?? [];
|
||||
const curViewConfig =
|
||||
typeof this._curView === "number" ? views[this._curView] : undefined;
|
||||
|
||||
const backPath = sanitizeNavigationPath(
|
||||
curViewConfig?.back_path ?? this.backPath
|
||||
);
|
||||
|
||||
if (backPath) {
|
||||
return backPath;
|
||||
}
|
||||
return curViewConfig?.subview ? this.route!.prefix : undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { clamp } from "../../../common/number/clamp";
|
||||
import { getHistoryState, updateHistoryState } from "../../../common/navigate";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-ripple";
|
||||
import "../../../components/ha-sortable";
|
||||
@@ -138,7 +137,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
"section-visibility-changed",
|
||||
this._sectionVisibilityChanged
|
||||
);
|
||||
this._sidebarTabActive = Boolean(getHistoryState()?.sidebar);
|
||||
this._sidebarTabActive = Boolean(window.history.state?.sidebar);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
@@ -510,7 +509,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
this._sidebarTabActive = !this._sidebarTabActive;
|
||||
|
||||
// Add sidebar state to history
|
||||
updateHistoryState({ sidebar: this._sidebarTabActive });
|
||||
window.history.replaceState(
|
||||
{ ...window.history.state, sidebar: this._sidebarTabActive },
|
||||
""
|
||||
);
|
||||
|
||||
// Restore scroll position after view updates
|
||||
this.updateComplete.then(() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { replaceCurrentUrl } from "../../common/navigate";
|
||||
import { nextRender } from "../../common/util/render-status";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-card";
|
||||
@@ -91,7 +90,7 @@ class HaProfileSectionGeneral extends LitElement {
|
||||
}
|
||||
|
||||
private _clearHash() {
|
||||
replaceCurrentUrl(window.location.pathname);
|
||||
history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
|
||||
@@ -209,6 +209,7 @@ class HaRefreshTokens extends LitElement {
|
||||
<ha-button
|
||||
variant="danger"
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._deleteAllTokens}
|
||||
>
|
||||
${this.hass.localize(
|
||||
@@ -351,10 +352,6 @@ class HaRefreshTokens extends LitElement {
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -379,7 +379,8 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
|
||||
// @ts-ignore
|
||||
this.hass!.callWS({ type: "get_config" }).then((config: HassConfig) => {
|
||||
if (config.safe_mode) {
|
||||
location.reload();
|
||||
// @ts-ignore Firefox supports forceGet
|
||||
location.reload(true);
|
||||
}
|
||||
this._updateHass({ config });
|
||||
this.checkDataBaseMigration();
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { PropertyValues } from "lit";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import type { SystemLogLevel } from "../data/system_log";
|
||||
import type { Constructor } from "../types";
|
||||
import { recoverFromStaleBuild } from "../util/recover-stale-build";
|
||||
import type { HassBaseEl } from "./hass-base-mixin";
|
||||
|
||||
interface WriteLogParams {
|
||||
@@ -26,31 +25,7 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
|
||||
class extends superClass {
|
||||
protected hassConnected() {
|
||||
super.hassConnected();
|
||||
// Resource-load errors (<script>, modulepreload <link>) do not bubble,
|
||||
// so observe them in the capture phase. A stale build's hashed chunk
|
||||
// 404 lands here (legacy build / modulepreload); recover instead of
|
||||
// dead-ending.
|
||||
window.addEventListener(
|
||||
"error",
|
||||
(ev) => {
|
||||
const target = ev.target as
|
||||
(HTMLScriptElement & HTMLLinkElement) | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "SCRIPT" || target.tagName === "LINK")
|
||||
) {
|
||||
recoverFromStaleBuild(target.src || target.href, this);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
window.addEventListener("error", async (ev) => {
|
||||
// A stale build can surface as a runtime error while evaluating a
|
||||
// freshly (re)loaded chunk; recover rather than log it.
|
||||
if (recoverFromStaleBuild(ev.error?.message || ev.message, this)) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!this.hass?.connected) {
|
||||
return;
|
||||
}
|
||||
@@ -84,20 +59,6 @@ export const loggingMixin = <T extends Constructor<HassBaseEl>>(
|
||||
}
|
||||
});
|
||||
window.addEventListener("unhandledrejection", async (ev) => {
|
||||
// A failed dynamic import() of a stale build's chunk rejects here
|
||||
// (dialogs, more-info, cards, config-flow, …); recover rather than
|
||||
// silently logging it at debug level.
|
||||
const reason: any = ev.reason;
|
||||
const reasonMessage =
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: typeof reason === "string"
|
||||
? reason
|
||||
: "";
|
||||
if (recoverFromStaleBuild(reasonMessage, this)) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!this.hass?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { ReactiveElement, PropertyValues } from "lit";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { updateHistoryState } from "../common/navigate";
|
||||
import { closeLastDialog } from "../dialogs/make-dialog-manager";
|
||||
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
|
||||
import type { Constructor } from "../types";
|
||||
@@ -21,7 +20,10 @@ export const urlSyncMixin = <
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (mainWindow.history.length === 1) {
|
||||
updateHistoryState({ root: true });
|
||||
mainWindow.history.replaceState(
|
||||
{ ...mainWindow.history.state, root: true },
|
||||
""
|
||||
);
|
||||
}
|
||||
mainWindow.addEventListener("popstate", this._popstateChangeListener);
|
||||
}
|
||||
|
||||
@@ -2543,7 +2543,6 @@
|
||||
"dismiss": "Dismiss",
|
||||
"no_matching_link_found": "No matching My link found for {path}",
|
||||
"new_version_available": "A new version of the frontend is available. This page will update in {seconds, plural, one {# second} other {# seconds}}.",
|
||||
"new_version_available_reload": "A new version of the frontend is available. It will be applied once you have saved or discarded your changes.",
|
||||
"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.",
|
||||
@@ -7446,7 +7445,6 @@
|
||||
"updated": "Updated",
|
||||
"device": "Device",
|
||||
"device_information": "Device information",
|
||||
"open_device": "Open device",
|
||||
"advertisement_data": "Advertisement data",
|
||||
"manufacturer_data": "Manufacturer data",
|
||||
"service_data": "Service data",
|
||||
@@ -10593,11 +10591,6 @@
|
||||
"light-color-temp": {
|
||||
"label": "Light color temperature"
|
||||
},
|
||||
"light-effect": {
|
||||
"label": "Light effect",
|
||||
"effects": "Effects",
|
||||
"customize": "Customize effects"
|
||||
},
|
||||
"lock-commands": {
|
||||
"label": "Lock commands"
|
||||
},
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { fireExternalBusMessage } from "../external_app/external_messaging";
|
||||
import * as staleBuildPatterns from "./stale-build-patterns.json";
|
||||
import { showToast } from "./toast";
|
||||
|
||||
// Patterns live in a JSON single source (stale-build-patterns.json) so the
|
||||
// build can inject the exact same ones into the inline boot-time guard
|
||||
// (src/html/_bootstrap_recovery.html.template), which runs before any bundle
|
||||
// loads and therefore cannot import from here.
|
||||
const patterns = ((staleBuildPatterns as any).default ??
|
||||
staleBuildPatterns) as { hashedEntry: string; moduleError: string };
|
||||
const HASHED_ENTRY = new RegExp(patterns.hashedEntry, "i");
|
||||
const MODULE_ERROR = new RegExp(patterns.moduleError, "i");
|
||||
|
||||
const RELOAD_STORAGE_KEY = "haStaleBuildReload";
|
||||
const RELOAD_COOLDOWN = 60_000;
|
||||
// Reuse the service worker update toast id so we never show two competing
|
||||
// "new version available" toasts (see register-service-worker.ts).
|
||||
const UPDATE_TOAST_ID = "frontend-update-available";
|
||||
|
||||
let reloading = false;
|
||||
let toastShown = false;
|
||||
|
||||
/**
|
||||
* True when the given string looks like a failed load of a content-hashed
|
||||
* frontend chunk — i.e. a stale build referencing files that no longer exist
|
||||
* on the server after an upgrade. Accepts either a URL (from a resource
|
||||
* `error` event) or an error message (from a rejected dynamic `import()`).
|
||||
*/
|
||||
export const isStaleBuildError = (urlOrMessage: string | undefined): boolean =>
|
||||
!!urlOrMessage &&
|
||||
(HASHED_ENTRY.test(urlOrMessage) || MODULE_ERROR.test(urlOrMessage));
|
||||
|
||||
const dropCachesAndReload = async (bust: number): Promise<void> => {
|
||||
// A service worker serves chunks CacheFirst (with `ignoreSearch`), so a
|
||||
// cache-busting navigation alone would still be answered from the stale
|
||||
// cache. Drop the worker and its caches first, then navigate with a
|
||||
// cache-busting query so a fresh index.html is fetched. (Android's WebView
|
||||
// has a service worker, so this path recovers it too.)
|
||||
if ("serviceWorker" in navigator && navigator.serviceWorker.controller) {
|
||||
try {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
await Promise.all(registrations.map((reg) => reg.unregister()));
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
if ("caches" in window) {
|
||||
try {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.map((key) => caches.delete(key)));
|
||||
} catch (_err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const url = new URL(mainWindow.location.href);
|
||||
url.searchParams.set("ha_cache_bust", String(bust));
|
||||
mainWindow.location.replace(url.href);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reload the page onto the current build.
|
||||
*
|
||||
* Returns `true` when a reload was actually initiated, `false` when the loop
|
||||
* guard blocked it — so callers can fall back to logging / an error screen
|
||||
* instead of silently swallowing a still-failing chunk.
|
||||
*
|
||||
* Guarded by a monotonic sessionStorage counter — NOT the boot guard's URL
|
||||
* param, which core.ts strips on every successful connect — so a recovery that
|
||||
* boots and then immediately re-triggers the same missing chunk (a deep-linked
|
||||
* panel, more-info from the URL, or a preloaded route) cannot reload-loop.
|
||||
*/
|
||||
export const reloadFresh = (): boolean => {
|
||||
if (reloading) {
|
||||
return false;
|
||||
}
|
||||
const now = Date.now();
|
||||
let attempts = 0;
|
||||
let last = 0;
|
||||
let storageOk = true;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(RELOAD_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
attempts = Number(parsed.n) || 0;
|
||||
last = Number(parsed.t) || 0;
|
||||
}
|
||||
} catch (_err) {
|
||||
storageOk = false;
|
||||
}
|
||||
if (now - last > RELOAD_COOLDOWN) {
|
||||
attempts = 0;
|
||||
}
|
||||
if (attempts >= 1) {
|
||||
// Already reloaded once recently and it still failed: stop, to avoid a
|
||||
// reload loop on a genuinely broken (not merely stale) deploy.
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
RELOAD_STORAGE_KEY,
|
||||
JSON.stringify({ n: attempts + 1, t: now })
|
||||
);
|
||||
} catch (_err) {
|
||||
storageOk = false;
|
||||
}
|
||||
if (!storageOk) {
|
||||
// Without a durable cooldown marker (e.g. private mode) we can't stop a
|
||||
// loop across reloads, so fail closed rather than risk reloading forever.
|
||||
return false;
|
||||
}
|
||||
reloading = true;
|
||||
|
||||
// `frontend/reload_and_clear_cache` is a WKWebView (iOS/macOS companion)
|
||||
// command; the Android bridges (externalApp/externalAppV2) don't handle it.
|
||||
// Only send it to the WebKit bridge — everything else takes the browser
|
||||
// path below (Android's WebView has a service worker, so it recovers there).
|
||||
if (window.webkit?.messageHandlers?.externalBus) {
|
||||
fireExternalBusMessage({ type: "frontend/reload_and_clear_cache" });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fire and forget; the page navigates away once it resolves.
|
||||
void dropCachesAndReload(now);
|
||||
return true;
|
||||
};
|
||||
|
||||
const showStaleBuildToast = (rootEl?: HTMLElement): void => {
|
||||
if (toastShown) {
|
||||
return;
|
||||
}
|
||||
const el =
|
||||
rootEl ??
|
||||
(mainWindow.document.querySelector("home-assistant") as HTMLElement | null);
|
||||
if (!el) {
|
||||
// Nowhere to anchor the toast yet; a later trigger can retry.
|
||||
return;
|
||||
}
|
||||
toastShown = true;
|
||||
// This toast is only shown while an editor is dirty, so it deliberately has
|
||||
// no immediate-reload action that could discard unsaved work: it reloads
|
||||
// automatically once the user saves or discards.
|
||||
const reloadWhenClean = () => {
|
||||
if (!window.isDirtyState) {
|
||||
window.removeEventListener("dirty-state-changed", reloadWhenClean);
|
||||
reloadFresh();
|
||||
}
|
||||
};
|
||||
window.addEventListener("dirty-state-changed", reloadWhenClean);
|
||||
showToast(el, {
|
||||
id: UPDATE_TOAST_ID,
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available_reload",
|
||||
},
|
||||
duration: -1,
|
||||
dismissable: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Reload onto the current build, but never over unsaved work: when an editor
|
||||
* is dirty, show a non-dismissable toast and defer the reload until the dirty
|
||||
* state clears. Returns `true` when a reload or the deferral toast was started.
|
||||
*/
|
||||
export const reloadForUpdate = (rootEl?: HTMLElement): boolean => {
|
||||
if (window.isDirtyState) {
|
||||
showStaleBuildToast(rootEl);
|
||||
return true;
|
||||
}
|
||||
return reloadFresh();
|
||||
};
|
||||
|
||||
/**
|
||||
* Recover from a failed lazy load caused by a stale build (a content-hashed
|
||||
* chunk that 404s after an upgrade while the app stayed open).
|
||||
*
|
||||
* - clean: reload onto the current build.
|
||||
* - dirty (an editor has unsaved changes): show a non-dismissable toast and
|
||||
* auto-reload once the user saves/discards, so unsaved work is never lost.
|
||||
*
|
||||
* Returns `true` when the error was a stale-build error and recovery was
|
||||
* actually started, so callers can skip their own error UI / logging. Returns
|
||||
* `false` for a non-stale error, or when the loop guard blocked the reload (so
|
||||
* the caller still surfaces/logs the failure).
|
||||
*/
|
||||
export const recoverFromStaleBuild = (
|
||||
urlOrMessage: string | undefined,
|
||||
rootEl?: HTMLElement
|
||||
): boolean => {
|
||||
// In dev/demo the entry files are unhashed and rebuild churn can throw
|
||||
// transient import errors; never auto-reload there.
|
||||
if (__DEV__ || __DEMO__) {
|
||||
return false;
|
||||
}
|
||||
if (!isStaleBuildError(urlOrMessage)) {
|
||||
return false;
|
||||
}
|
||||
return reloadForUpdate(rootEl);
|
||||
};
|
||||
@@ -17,10 +17,24 @@ const generatePoints = (seed: number, count: number): [number, number][] => {
|
||||
return points;
|
||||
};
|
||||
|
||||
// The chart data modules break the line with a null value. A handful of them
|
||||
// stands for an entity that went unavailable; a series that is mostly null
|
||||
// stands for the climate heating dataset, which emits one per inactive state.
|
||||
const withGaps = (
|
||||
points: [number, number][],
|
||||
isGap: (index: number) => boolean
|
||||
): [number, number | null][] =>
|
||||
points.map(([x, y], index) => (isGap(index) ? [x, null] : [x, y]));
|
||||
|
||||
const small = generatePoints(1, SCALES.small);
|
||||
const medium = generatePoints(2, SCALES.medium);
|
||||
const large = generatePoints(3, SCALES.large);
|
||||
const largeObjects = large.map((value) => ({ value }));
|
||||
const largeFewGaps = withGaps(large, (index) => index % 20_000 === 0);
|
||||
const largeMostlyGaps = withGaps(
|
||||
large,
|
||||
(index) => Math.floor(index / 50) % 3 !== 0
|
||||
);
|
||||
|
||||
describe("downSampleLineData", () => {
|
||||
bench("min/max small (1k points)", () => {
|
||||
@@ -54,4 +68,20 @@ describe("downSampleLineData", () => {
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
|
||||
bench(
|
||||
"min/max large with a few gaps (100k points)",
|
||||
() => {
|
||||
downSampleLineData(largeFewGaps, MAX_DETAILS);
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
|
||||
bench(
|
||||
"min/max large mostly gaps (100k points)",
|
||||
() => {
|
||||
downSampleLineData(largeMostlyGaps, MAX_DETAILS);
|
||||
},
|
||||
{ time: 1000, warmupIterations: 2 }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { NavigateOptions } from "../../src/common/navigate";
|
||||
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
|
||||
|
||||
// navigate() closes open dialogs before touching history.
|
||||
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
|
||||
closeAllDialogs: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
// Fabricates a raw entry like a document load, which navigate() never produces.
|
||||
const setEntry = (path: string, state: unknown = null) => {
|
||||
window.history.replaceState(state, "", path);
|
||||
};
|
||||
|
||||
describe("navigate", () => {
|
||||
beforeEach(() => {
|
||||
setEntry("/config");
|
||||
});
|
||||
|
||||
it("stamps the path we came from on pushed entries", async () => {
|
||||
await navigate("/config/devices/dashboard");
|
||||
|
||||
expect(window.location.pathname).toEqual("/config/devices/dashboard");
|
||||
expect(window.history.state).toMatchObject({ from: "/config" });
|
||||
});
|
||||
|
||||
it("keeps caller data alongside the stamp", async () => {
|
||||
await navigate("/config/areas", { data: { scrollPosition: 42 } });
|
||||
|
||||
expect(window.history.state).toMatchObject({
|
||||
scrollPosition: 42,
|
||||
from: "/config",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores caller data that is not an object", async () => {
|
||||
await navigate("/config/areas", { data: 42 } as unknown as NavigateOptions);
|
||||
|
||||
expect(window.history.state).toEqual({ from: "/config" });
|
||||
});
|
||||
|
||||
it("keeps the stamp when replacing, the predecessor is unchanged", async () => {
|
||||
await navigate("/config/cloud");
|
||||
await navigate("/config/cloud/account", { replace: true });
|
||||
|
||||
expect(window.location.pathname).toEqual("/config/cloud/account");
|
||||
expect(window.history.state).toMatchObject({ from: "/config" });
|
||||
});
|
||||
|
||||
it("does not stamp an entry the app did not push", () => {
|
||||
expect(window.history.state).toBeNull();
|
||||
expect(canGoBack()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("goBack", () => {
|
||||
beforeEach(() => {
|
||||
setEntry("/config/cloud/remote");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("goes back when we came from another page in the app", async () => {
|
||||
const back = vi
|
||||
.spyOn(window.history, "back")
|
||||
.mockImplementation(() => undefined);
|
||||
await navigate("/config/cloud/remote");
|
||||
|
||||
await goBack("/config/cloud/account");
|
||||
|
||||
expect(back).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to the given path when the previous entry is not ours", async () => {
|
||||
const back = vi
|
||||
.spyOn(window.history, "back")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
await goBack("/config/cloud/account");
|
||||
|
||||
expect(back).not.toHaveBeenCalled();
|
||||
expect(window.location.pathname).toEqual("/config/cloud/account");
|
||||
});
|
||||
|
||||
it("falls back to the root when no path is given", async () => {
|
||||
vi.spyOn(window.history, "back").mockImplementation(() => undefined);
|
||||
|
||||
await goBack();
|
||||
|
||||
expect(window.location.pathname).toEqual("/");
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,52 @@ const generatePoints = (
|
||||
return points;
|
||||
};
|
||||
|
||||
const toObjectPoints = (points: [number, number][]) =>
|
||||
// Gap markers: the chart data modules push a null value to break the line
|
||||
// where an entity was unavailable.
|
||||
type GappedPoint = [number, number | null | undefined];
|
||||
|
||||
const toObjectPoints = (points: GappedPoint[]) =>
|
||||
points.map((value) => ({ value }));
|
||||
|
||||
const expectXOrdered = (result: { [0]: number }[]) => {
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
|
||||
}
|
||||
};
|
||||
|
||||
// A series whose readings are all positive, with an unavailable stretch that
|
||||
// starts inside the first frame. Mirrors the point sequence
|
||||
// state-history-chart-line-data.ts emits for a gap.
|
||||
const gappedPoints: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90], // frame maximum
|
||||
[FIXED_EPOCH_MS + 2_000, 60],
|
||||
[FIXED_EPOCH_MS + 3_000, 10], // frame minimum
|
||||
[FIXED_EPOCH_MS + 4_000, 20], // last reading before the gap
|
||||
[FIXED_EPOCH_MS + 4_001, null], // gap marker
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
|
||||
// A generated series with three unavailable stretches of different lengths.
|
||||
const generateGappedPoints = (seed: number, count: number) => {
|
||||
const points: GappedPoint[] = generatePoints(seed, count);
|
||||
for (const [start, length] of [
|
||||
[Math.floor(count * 0.13), 3],
|
||||
[Math.floor(count * 0.4), 25],
|
||||
[Math.floor(count * 0.83), 1],
|
||||
]) {
|
||||
const gapStart = points[start][0];
|
||||
points.splice(
|
||||
start + 1,
|
||||
length,
|
||||
[gapStart + 1, points[start][1]],
|
||||
[gapStart + 1, null]
|
||||
);
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
describe("downSampleLineData", () => {
|
||||
it("returns empty array for undefined data", () => {
|
||||
expect(downSampleLineData(undefined, 100)).toEqual([]);
|
||||
@@ -66,11 +109,7 @@ describe("downSampleLineData", () => {
|
||||
});
|
||||
|
||||
it("min/max mode preserves x-order for sorted input", () => {
|
||||
const points = generatePoints(4, 1000);
|
||||
const result = downSampleLineData(points, 50);
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
expect(result[i][0]).toBeGreaterThanOrEqual(result[i - 1][0]);
|
||||
}
|
||||
expectXOrdered(downSampleLineData(generatePoints(4, 1000), 50));
|
||||
});
|
||||
|
||||
it("min/max mode matches characterization snapshot", () => {
|
||||
@@ -193,6 +232,165 @@ describe("downSampleLineData", () => {
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("keeps the frame minimum when a gap marker shares the frame", () => {
|
||||
// Without special handling the marker becomes y=0, wins the minimum slot
|
||||
// and the real minimum (10) is dropped.
|
||||
expect(downSampleLineData(gappedPoints, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 3_000, 10],
|
||||
[FIXED_EPOCH_MS + 4_001, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops a marker whose gap closes within the same frame", () => {
|
||||
// A frame spans about one device pixel, so a gap that opens and closes
|
||||
// inside one is too narrow to show. The values around it stay.
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 10], // frame minimum, before the marker
|
||||
[FIXED_EPOCH_MS + 1_001, null],
|
||||
[FIXED_EPOCH_MS + 2_000, 90], // frame maximum, after the marker
|
||||
[FIXED_EPOCH_MS + 3_000, 60],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 10],
|
||||
[FIXED_EPOCH_MS + 2_000, 90],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a marker sharing its x with a value after that value", () => {
|
||||
// statistics-chart-data.ts ends the line and breaks it at the same x
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 2_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 2_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a gap marker whose frame holds no values", () => {
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 60],
|
||||
[FIXED_EPOCH_MS + 15_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 15_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a single gap marker per frame", () => {
|
||||
// A run of nulls inside one frame renders the same as a single null: the
|
||||
// break only depends on which points the marker sits between.
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 50],
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 3_000, null],
|
||||
[FIXED_EPOCH_MS + 4_000, null],
|
||||
[FIXED_EPOCH_MS + 5_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
];
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
[FIXED_EPOCH_MS + 1_000, 90],
|
||||
[FIXED_EPOCH_MS + 2_000, 10],
|
||||
[FIXED_EPOCH_MS + 5_000, null],
|
||||
[FIXED_EPOCH_MS + 30_000, 55],
|
||||
[FIXED_EPOCH_MS + 31_000, 45],
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds the output on a series where most points are null", () => {
|
||||
// The climate heating/cooling datasets push a null for every state where
|
||||
// the mode is inactive, so markers must not escape the frame budget.
|
||||
const values = generatePoints(20, SCALES.medium);
|
||||
const random = createSeededRandom(21);
|
||||
const gapped: GappedPoint[] = values.map(([x, y]) =>
|
||||
random() < 0.65 ? [x, null] : [x, y]
|
||||
);
|
||||
const gapless = downSampleLineData(values, 500);
|
||||
const result = downSampleLineData(gapped, 500);
|
||||
// Both series share an x grid, so they share frames, and the gapless one
|
||||
// emits at least one point per frame. A gapped frame emits at most three:
|
||||
// min, max and a single marker.
|
||||
expect(result.length).toBeLessThanOrEqual(3 * gapless.length);
|
||||
expect(
|
||||
result.filter((point) => point[1] === null).length
|
||||
).toBeLessThanOrEqual(gapless.length);
|
||||
});
|
||||
|
||||
it("handles gap markers on object-shaped points", () => {
|
||||
const points = toObjectPoints(gappedPoints);
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
points[1],
|
||||
points[3],
|
||||
points[5],
|
||||
points[6],
|
||||
points[7],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles gap markers on Date x values", () => {
|
||||
// statistics charts use Date objects for x
|
||||
const points = gappedPoints.map(
|
||||
([x, y]) => [new Date(x), y] as [Date, number | null | undefined]
|
||||
);
|
||||
expect(downSampleLineData(points, 3)).toEqual([
|
||||
points[1],
|
||||
points[3],
|
||||
points[5],
|
||||
points[6],
|
||||
points[7],
|
||||
]);
|
||||
});
|
||||
|
||||
it("mean mode leaves gap markers out of the average", () => {
|
||||
const points: GappedPoint[] = [
|
||||
[FIXED_EPOCH_MS, 10],
|
||||
[FIXED_EPOCH_MS + 1_000, 20],
|
||||
[FIXED_EPOCH_MS + 1_001, null],
|
||||
[FIXED_EPOCH_MS + 2_000, 30],
|
||||
[FIXED_EPOCH_MS + 30_000, 100],
|
||||
[FIXED_EPOCH_MS + 31_000, 100],
|
||||
];
|
||||
expect(downSampleLineData(points, 3, undefined, undefined, true)).toEqual([
|
||||
// (10 + 20 + 30) / 3, not (10 + 20 + 0 + 30) / 4
|
||||
[FIXED_EPOCH_MS + 1_000, 20],
|
||||
[FIXED_EPOCH_MS + 30_500, 100],
|
||||
]);
|
||||
});
|
||||
|
||||
it("min/max mode preserves x-order for gapped input", () => {
|
||||
const result = downSampleLineData(generateGappedPoints(22, 1000), 50);
|
||||
// Of the three gaps only the 25 point one outlasts its frame; the one and
|
||||
// three point gaps close within theirs and are dropped.
|
||||
expect(result.filter((point) => point[1] === null)).toHaveLength(1);
|
||||
expectXOrdered(result);
|
||||
});
|
||||
|
||||
it("large scale mean-mode digest is stable", () => {
|
||||
expect(
|
||||
digestResult(
|
||||
|
||||
@@ -28,7 +28,7 @@ afterEach(() => {
|
||||
describe("hass-subpage back path", () => {
|
||||
it("links to a path on the current origin", async () => {
|
||||
const backButton = await mount("/config/system");
|
||||
expect(backButton!.href).toEqual("/config/system");
|
||||
expect(backButton!.getAttribute("href")).toEqual("/config/system");
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
@@ -36,7 +36,7 @@ describe("hass-subpage back path", () => {
|
||||
"does not link to %s",
|
||||
async (backPath) => {
|
||||
const backButton = await mount(backPath);
|
||||
expect(backButton!.href).toBeUndefined();
|
||||
expect(backButton!.hasAttribute("href")).toBe(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { supportsLightEffectCardFeature } from "../../../../src/panels/lovelace/card-features/hui-light-effect-card-feature";
|
||||
import { LightEntityFeature } from "../../../../src/data/light";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const entity = (
|
||||
entityId: string,
|
||||
attributes: HassEntity["attributes"] = {}
|
||||
): HassEntity =>
|
||||
({
|
||||
entity_id: entityId,
|
||||
state: "on",
|
||||
attributes,
|
||||
last_changed: "",
|
||||
last_updated: "",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
}) as HassEntity;
|
||||
|
||||
const hassWith = (...entities: HassEntity[]): HomeAssistant =>
|
||||
({
|
||||
states: Object.fromEntries(entities.map((e) => [e.entity_id, e])),
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
describe("supportsLightEffectCardFeature", () => {
|
||||
it("supports a light entity with EFFECT and a populated effect_list", () => {
|
||||
const stateObj = entity("light.test", {
|
||||
supported_features: LightEntityFeature.EFFECT,
|
||||
effect_list: ["candle", "fire"],
|
||||
});
|
||||
const hass = hassWith(stateObj);
|
||||
expect(
|
||||
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not support a light entity with EFFECT but an empty effect_list", () => {
|
||||
const stateObj = entity("light.test", {
|
||||
supported_features: LightEntityFeature.EFFECT,
|
||||
effect_list: [],
|
||||
});
|
||||
const hass = hassWith(stateObj);
|
||||
expect(
|
||||
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not support a light entity with EFFECT but no effect_list", () => {
|
||||
const stateObj = entity("light.test", {
|
||||
supported_features: LightEntityFeature.EFFECT,
|
||||
});
|
||||
const hass = hassWith(stateObj);
|
||||
expect(
|
||||
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not support a light entity without the EFFECT feature flag", () => {
|
||||
const stateObj = entity("light.test", {
|
||||
supported_features: 0,
|
||||
effect_list: ["candle", "fire"],
|
||||
});
|
||||
const hass = hassWith(stateObj);
|
||||
expect(
|
||||
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not support other domains", () => {
|
||||
const stateObj = entity("switch.test");
|
||||
const hass = hassWith(stateObj);
|
||||
expect(
|
||||
supportsLightEffectCardFeature(hass, { entity_id: stateObj.entity_id })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not support a context with no entity_id", () => {
|
||||
const hass = hassWith();
|
||||
expect(supportsLightEffectCardFeature(hass, {})).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,220 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ShowToastParams } from "../../src/managers/notification-manager";
|
||||
import type {
|
||||
isStaleBuildError,
|
||||
recoverFromStaleBuild,
|
||||
} from "../../src/util/recover-stale-build";
|
||||
|
||||
interface RecoverModule {
|
||||
isStaleBuildError: typeof isStaleBuildError;
|
||||
recoverFromStaleBuild: typeof recoverFromStaleBuild;
|
||||
}
|
||||
|
||||
const STALE_URL = "/frontend_latest/core.abc12345.js";
|
||||
// Set by reloadFresh() right before it navigates; used here to observe that
|
||||
// the reload path ran without having to mock window.location (jsdom forbids
|
||||
// redefining it).
|
||||
const RELOAD_KEY = "haStaleBuildReload";
|
||||
|
||||
describe("recover-stale-build", () => {
|
||||
let mod: RecoverModule;
|
||||
let root: HTMLElement;
|
||||
let notifications: ShowToastParams[];
|
||||
let serviceWorkerDescriptor: PropertyDescriptor | undefined;
|
||||
let cachesDescriptor: PropertyDescriptor | undefined;
|
||||
|
||||
const latestNotification = () => notifications[notifications.length - 1];
|
||||
const reloadMarker = () => sessionStorage.getItem(RELOAD_KEY);
|
||||
|
||||
beforeEach(async () => {
|
||||
globalThis.__DEV__ = false;
|
||||
globalThis.__DEMO__ = false;
|
||||
window.isDirtyState = false;
|
||||
sessionStorage.clear();
|
||||
|
||||
// No controlling service worker → reloadFresh() takes the synchronous
|
||||
// path straight to the (jsdom no-op) navigate.
|
||||
serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(
|
||||
navigator,
|
||||
"serviceWorker"
|
||||
);
|
||||
cachesDescriptor = Object.getOwnPropertyDescriptor(window, "caches");
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: { controller: null },
|
||||
});
|
||||
|
||||
// Capture toasts fired via showToast (a "hass-notification" event).
|
||||
notifications = [];
|
||||
root = document.createElement("home-assistant");
|
||||
root.addEventListener("hass-notification", (event) => {
|
||||
notifications.push((event as CustomEvent<ShowToastParams>).detail);
|
||||
});
|
||||
document.body.append(root);
|
||||
|
||||
// Fresh module state per test (resets the reloading/toastShown singletons).
|
||||
vi.resetModules();
|
||||
mod = await import("../../src/util/recover-stale-build");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
root.remove();
|
||||
if (serviceWorkerDescriptor) {
|
||||
Object.defineProperty(
|
||||
navigator,
|
||||
"serviceWorker",
|
||||
serviceWorkerDescriptor
|
||||
);
|
||||
} else {
|
||||
Reflect.deleteProperty(navigator, "serviceWorker");
|
||||
}
|
||||
if (cachesDescriptor) {
|
||||
Object.defineProperty(window, "caches", cachesDescriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(window, "caches");
|
||||
}
|
||||
Reflect.deleteProperty(window, "externalApp");
|
||||
Reflect.deleteProperty(window, "webkit");
|
||||
window.isDirtyState = false;
|
||||
globalThis.__DEV__ = false;
|
||||
globalThis.__DEMO__ = false;
|
||||
});
|
||||
|
||||
describe("isStaleBuildError", () => {
|
||||
it.each([
|
||||
"/frontend_latest/core.abc12345.js",
|
||||
"https://ha.local/frontend_es5/panel-config.deadbeef99.js",
|
||||
"Failed to fetch dynamically imported module: /frontend_latest/x.abcdef12.js",
|
||||
"Importing a module script failed.",
|
||||
"error loading dynamically imported module",
|
||||
"ChunkLoadError: Loading chunk 5 failed",
|
||||
])("detects a stale-build error: %s", (message) => {
|
||||
expect(mod.isStaleBuildError(message)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"/frontend_latest/core.js", // dev, unhashed
|
||||
"/static/translations/en-abc12345.json", // hashed, but not an entry chunk
|
||||
"TypeError: x is not a function",
|
||||
])("ignores non-stale input: %s", (message) => {
|
||||
expect(mod.isStaleBuildError(message)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recoverFromStaleBuild", () => {
|
||||
it("does nothing in development", () => {
|
||||
globalThis.__DEV__ = true;
|
||||
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(false);
|
||||
expect(reloadMarker()).toBeNull();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does nothing in demo", () => {
|
||||
globalThis.__DEMO__ = true;
|
||||
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(false);
|
||||
expect(reloadMarker()).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a non-stale error", () => {
|
||||
expect(mod.recoverFromStaleBuild("TypeError: boom", root)).toBe(false);
|
||||
expect(reloadMarker()).toBeNull();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reloads onto the current build when clean", () => {
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
|
||||
|
||||
// reloadFresh() ran (marker written before navigating) and did not toast.
|
||||
expect(reloadMarker()).not.toBeNull();
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("drops the service worker and caches before reloading", async () => {
|
||||
const unregister = vi.fn().mockResolvedValue(true);
|
||||
Object.defineProperty(navigator, "serviceWorker", {
|
||||
configurable: true,
|
||||
value: {
|
||||
controller: {},
|
||||
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
|
||||
},
|
||||
});
|
||||
const cacheDelete = vi.fn().mockResolvedValue(true);
|
||||
Object.defineProperty(window, "caches", {
|
||||
configurable: true,
|
||||
value: {
|
||||
keys: vi.fn().mockResolvedValue(["a", "b"]),
|
||||
delete: cacheDelete,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
|
||||
|
||||
await vi.waitFor(() => expect(unregister).toHaveBeenCalledOnce());
|
||||
expect(cacheDelete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the companion-app command when the WebKit bridge is present", () => {
|
||||
const postMessage = vi.fn();
|
||||
(
|
||||
window as unknown as {
|
||||
webkit: {
|
||||
messageHandlers: {
|
||||
externalBus: { postMessage: typeof postMessage };
|
||||
};
|
||||
};
|
||||
}
|
||||
).webkit = { messageHandlers: { externalBus: { postMessage } } };
|
||||
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
|
||||
|
||||
// Asks the native app to purge its cache and reload instead of the
|
||||
// browser path.
|
||||
expect(postMessage).toHaveBeenCalledOnce();
|
||||
expect(postMessage.mock.calls[0][0]).toMatchObject({
|
||||
type: "frontend/reload_and_clear_cache",
|
||||
});
|
||||
expect(notifications).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("defers with a toast instead of reloading when dirty", () => {
|
||||
window.isDirtyState = true;
|
||||
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
|
||||
|
||||
// Took the toast branch, not the reload branch, and the toast has no
|
||||
// immediate-reload action that could discard unsaved work.
|
||||
expect(reloadMarker()).toBeNull();
|
||||
expect(latestNotification()).toMatchObject({
|
||||
id: "frontend-update-available",
|
||||
message: {
|
||||
translationKey: "ui.notification_toast.new_version_available_reload",
|
||||
},
|
||||
duration: -1,
|
||||
dismissable: false,
|
||||
});
|
||||
expect(latestNotification().action).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not reload again while the cooldown marker is set (loop guard)", async () => {
|
||||
expect(mod.recoverFromStaleBuild(STALE_URL, root)).toBe(true);
|
||||
const firstMarker = reloadMarker();
|
||||
expect(firstMarker).not.toBeNull();
|
||||
|
||||
// Simulate the reloaded page: a fresh module (the in-memory reloading
|
||||
// flag is reset) but the sessionStorage cooldown marker persists.
|
||||
vi.resetModules();
|
||||
const reloaded: RecoverModule =
|
||||
await import("../../src/util/recover-stale-build");
|
||||
|
||||
// Blocked by the cooldown → returns false so the caller still surfaces it.
|
||||
expect(
|
||||
reloaded.recoverFromStaleBuild("/frontend_latest/app.def67890.js", root)
|
||||
).toBe(false);
|
||||
expect(reloadMarker()).toBe(firstMarker);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user