mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-29 02:16:15 +00:00
Compare commits
13
Commits
dev
..
20260826.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f73718a35 | ||
|
|
4bf2acdb43 | ||
|
|
f622e5eeb9 | ||
|
|
595068597f | ||
|
|
c708f35ab6 | ||
|
|
205874c800 | ||
|
|
47ddcbe2e5 | ||
|
|
c43b8bfbe0 | ||
|
|
e349b00b15 | ||
|
|
0bb8bdc98a | ||
|
|
bbc5385843 | ||
|
|
43dc5651d3 | ||
|
|
18dcf7abc0 |
@@ -108,10 +108,6 @@ async function copyMapPanel(staticDir) {
|
||||
// Style, glyphs and sprites for the vector base map
|
||||
await ensureMapAssets();
|
||||
fs.copySync(mapAssetsDir, staticPath("map/"));
|
||||
copyFileDir(
|
||||
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
|
||||
staticPath("map/")
|
||||
);
|
||||
}
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import { extract } from "tar";
|
||||
import paths from "../paths.cjs";
|
||||
import { addLatinLabels } from "./map-labels.js";
|
||||
|
||||
// The tile URL is deliberately never named here: the OSMF asks consumers to
|
||||
// resolve it through the TileJSON so they can move the tiles without every
|
||||
@@ -196,7 +195,7 @@ const buildMapAssets = async () => {
|
||||
["light", colorful],
|
||||
["dark", eclipse],
|
||||
].map(([name, builder]) => {
|
||||
const style = addLatinLabels(useTileJson(name, builder(styleOptions)));
|
||||
const style = useTileJson(name, builder(styleOptions));
|
||||
assertBoldStaysOnRefs(name, style);
|
||||
return writeFile(
|
||||
path.join(outputDir, `${name}.json`),
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// Adds the English name to labels whose local name is not in Latin script.
|
||||
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
|
||||
|
||||
const NAME = ["get", "name"];
|
||||
const NAME_EN = ["get", "name_en"];
|
||||
|
||||
// Strings compare by code point: anything from Basic Latin up to Latin
|
||||
// Extended-B, digits and punctuation included.
|
||||
const IS_LATIN = ["<", NAME, "ɐ"];
|
||||
|
||||
const ENGLISH_SCALE = 0.8;
|
||||
|
||||
// Streets are line-placed and cannot break lines.
|
||||
const withEnglish = (placement) =>
|
||||
placement === "line"
|
||||
? ["concat", NAME, " (", NAME_EN, ")"]
|
||||
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
|
||||
|
||||
const isNameLabel = (layer) =>
|
||||
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
|
||||
|
||||
export const addLatinLabels = (style) => ({
|
||||
...style,
|
||||
layers: style.layers.map((layer) =>
|
||||
isNameLabel(layer)
|
||||
? {
|
||||
...layer,
|
||||
layout: {
|
||||
...layer.layout,
|
||||
"text-field": [
|
||||
"case",
|
||||
IS_LATIN,
|
||||
NAME,
|
||||
["!", ["has", "name_en"]],
|
||||
NAME,
|
||||
withEnglish(layer.layout["symbol-placement"]),
|
||||
],
|
||||
},
|
||||
}
|
||||
: layer
|
||||
),
|
||||
});
|
||||
+7
-8
@@ -75,7 +75,6 @@
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
@@ -110,7 +109,7 @@
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.4.0",
|
||||
"js-yaml": "5.3.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -118,7 +117,7 @@
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.11",
|
||||
"marked": "18.0.10",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -150,7 +149,7 @@
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.65.0",
|
||||
"@lokalise/node-api": "16.4.0",
|
||||
"@lokalise/node-api": "16.3.0",
|
||||
"@octokit/auth-oauth-device": "8.0.4",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
@@ -180,7 +179,7 @@
|
||||
"browserslist": "4.28.8",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.9.1",
|
||||
"eslint": "10.9.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -190,7 +189,7 @@
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.3",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.11.0",
|
||||
"gulp": "5.0.1",
|
||||
@@ -207,7 +206,7 @@
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.2.0",
|
||||
"minify-literals": "2.1.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
@@ -217,7 +216,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.68.0",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.11",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260826.0"
|
||||
version = "20260826.1"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
|
||||
import type { Map as LeafletMap } from "leaflet";
|
||||
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
|
||||
import type { StyleSpecification } from "maplibre-gl";
|
||||
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
|
||||
|
||||
// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile
|
||||
@@ -13,10 +13,6 @@ const VECTOR_STYLES = {
|
||||
dark: "/static/map/dark.json",
|
||||
} as const;
|
||||
|
||||
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
|
||||
// worker, hence a URL rather than an import.
|
||||
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
|
||||
|
||||
// Fallback for browsers without WebGL2, which MapLibre needs even for raster,
|
||||
// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's
|
||||
// raster blocks a browser that sends no Referer, and the only referrer a browser
|
||||
@@ -75,20 +71,6 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
return style;
|
||||
};
|
||||
|
||||
// Global to MapLibre, and it throws when set twice.
|
||||
let rtlTextPluginRequested = false;
|
||||
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
|
||||
if (rtlTextPluginRequested) {
|
||||
return;
|
||||
}
|
||||
rtlTextPluginRequested = true;
|
||||
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
|
||||
() => {
|
||||
// RTL labels stay reversed; everything else still renders.
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const createVectorLayer = async (
|
||||
createLayer: typeof maplibreGL,
|
||||
leaflet: LeafletModuleType,
|
||||
@@ -229,11 +211,8 @@ export const createBaseLayer = async (
|
||||
if (supportsWebGL2()) {
|
||||
let vectorLayer: MapBaseLayer | undefined;
|
||||
try {
|
||||
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
|
||||
import("@maplibre/maplibre-gl-leaflet"),
|
||||
import("maplibre-gl"),
|
||||
]);
|
||||
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
|
||||
const { maplibreGL: createLayer } =
|
||||
await import("@maplibre/maplibre-gl-leaflet");
|
||||
vectorLayer = await createVectorLayer(
|
||||
createLayer,
|
||||
leaflet,
|
||||
|
||||
+2
-85
@@ -80,67 +80,6 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
|
||||
return ensureDialogsClosed(timestamp);
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a page with unsaved changes (e.g. the automation editor) veto
|
||||
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
|
||||
* true when navigation may proceed.
|
||||
*/
|
||||
export interface UnsavedChangesGuard {
|
||||
isDirty(): boolean;
|
||||
prompt(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
|
||||
|
||||
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.add(guard);
|
||||
};
|
||||
|
||||
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.delete(guard);
|
||||
};
|
||||
|
||||
let pendingUnsavedPrompt: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* Counts navigations that changed the history entry, so a navigation held up
|
||||
* by an unsaved-changes prompt can tell whether a newer one has moved the app
|
||||
* on in the meantime.
|
||||
*/
|
||||
let committedNavigations = 0;
|
||||
|
||||
/**
|
||||
* Asks each dirty guard whether navigation may proceed. Returns true when
|
||||
* nothing is dirty or every prompt was confirmed. Concurrent navigations
|
||||
* share one pending prompt instead of stacking dialogs; the dirty check runs
|
||||
* before joining it, so a navigation triggered from inside a prompt (e.g. by
|
||||
* its save action) cannot deadlock on its own promise.
|
||||
*/
|
||||
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
|
||||
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
|
||||
guard.isDirty()
|
||||
);
|
||||
if (!dirtyGuards.length) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (!pendingUnsavedPrompt) {
|
||||
pendingUnsavedPrompt = (async () => {
|
||||
try {
|
||||
for (const guard of dirtyGuards) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await guard.prompt())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
pendingUnsavedPrompt = undefined;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return pendingUnsavedPrompt;
|
||||
};
|
||||
|
||||
const buildHistoryState = (
|
||||
data: Record<string, unknown> | undefined,
|
||||
from?: string
|
||||
@@ -152,7 +91,7 @@ const buildHistoryState = (
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
@@ -188,28 +127,9 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
committedNavigations += 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
// Only guard actual departures: navigating to the current path keeps the
|
||||
// page, and any unsaved state on it, mounted.
|
||||
if (path !== currentPath()) {
|
||||
const navigationsBeforePrompt = committedNavigations;
|
||||
if (!(await ensureUnsavedChangesConfirmed())) {
|
||||
return false;
|
||||
}
|
||||
if (committedNavigations !== navigationsBeforePrompt) {
|
||||
// Another navigation landed while the prompt was waiting for an answer,
|
||||
// so this destination is stale. Dropping it keeps a late answer from
|
||||
// pulling the user back off the page they are on now.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return performNavigation(path, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the previous history entry is a page this app navigated away from.
|
||||
* `history.length` cannot answer this: a login redirect goes through
|
||||
@@ -222,9 +142,6 @@ export const canGoBack = (): boolean =>
|
||||
/**
|
||||
* 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).
|
||||
* Deliberately not guarded against unsaved changes: pages with such a guard
|
||||
* confirm in their own back handlers, and delete flows leave through here
|
||||
* after the edited item is already gone.
|
||||
*/
|
||||
export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
@@ -239,5 +156,5 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
await performNavigation(fallbackPath || "/", { replace: true });
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
};
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import deepFreeze from "deep-freeze";
|
||||
|
||||
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
|
||||
|
||||
export const shareInFlightRequest = <T>(
|
||||
owner: object,
|
||||
key: string,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
let requests = inFlightRequests.get(owner);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
inFlightRequests.set(owner, requests);
|
||||
}
|
||||
|
||||
const ownerRequests = requests;
|
||||
const existing = ownerRequests.get(key);
|
||||
if (existing) {
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = fetcher()
|
||||
.then((result) => deepFreeze(result) as T)
|
||||
.finally(() => {
|
||||
if (ownerRequests.get(key) !== request) {
|
||||
return;
|
||||
}
|
||||
|
||||
ownerRequests.delete(key);
|
||||
if (ownerRequests.size === 0) {
|
||||
inFlightRequests.delete(owner);
|
||||
}
|
||||
});
|
||||
|
||||
ownerRequests.set(key, request);
|
||||
return request;
|
||||
};
|
||||
@@ -3,27 +3,6 @@ import type { TooltipPositionCallback } from "echarts/types/dist/shared";
|
||||
export const TOOLTIP_GAP_PX = 12;
|
||||
export const TOOLTIP_TOP_OFFSET_PX = 10;
|
||||
|
||||
const offsetFromCursor = (
|
||||
cursorX: number,
|
||||
dom: unknown,
|
||||
viewW: number,
|
||||
tipW: number
|
||||
) => {
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
return Math.max(0, Math.min(x, viewW - tipW));
|
||||
};
|
||||
|
||||
/**
|
||||
* Pins the tooltip near the top of the chart and offsets it horizontally
|
||||
* from the cursor so it never covers the data point being inspected.
|
||||
@@ -41,29 +20,21 @@ export const sideTooltipPosition: TooltipPositionCallback = (
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
x = Math.max(0, Math.min(x, viewW - tipW));
|
||||
|
||||
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
/**
|
||||
* Offsets the tooltip horizontally from the cursor and keeps it level with it.
|
||||
* For item-trigger tooltips where the cursor's row is what the tooltip shows.
|
||||
*/
|
||||
export const itemTooltipPosition: TooltipPositionCallback = (
|
||||
point,
|
||||
_params,
|
||||
dom,
|
||||
_rect,
|
||||
size
|
||||
) => {
|
||||
const [cursorX, cursorY] = point;
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
|
||||
const y = Math.max(0, Math.min(cursorY - tipH / 2, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
@@ -328,7 +328,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
unit: this.unit,
|
||||
onFractionDigits: (digits) => {
|
||||
if (digits !== this._yAxisFractionDigits) {
|
||||
this._yAxisFractionDigits = digits;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -12,7 +13,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { itemTooltipPosition } from "./chart-tooltip-position";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
|
||||
@@ -23,6 +24,7 @@ import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
const ROW_HEIGHT_INSIDE_LABELS = 64;
|
||||
const GRID_BOTTOM = 30;
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
@@ -41,6 +43,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw each row's name above its bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -63,6 +69,13 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
private _width = 0;
|
||||
|
||||
private _resize = new ResizeController(this, {
|
||||
skipInitial: true,
|
||||
callback: (entries) => entries[0]?.contentRect.width,
|
||||
});
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
protected render() {
|
||||
@@ -70,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
|
||||
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -180,13 +193,19 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
this._generateData();
|
||||
}
|
||||
|
||||
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
|
||||
const widthChanged = width !== this._width;
|
||||
this._width = width;
|
||||
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
changedProps.has("_yWidth") ||
|
||||
widthChanged
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
@@ -196,14 +215,22 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
// Keeps the plot aligned with the line charts sharing the y-axis padding.
|
||||
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
|
||||
// A zero width hides the labels instead of truncating them.
|
||||
const insideLabelWidth = this._width
|
||||
? Math.max(0, this._width - plotPadding - labelMargin)
|
||||
: undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -227,41 +254,56 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
axisLabel: insideLabels
|
||||
? {
|
||||
show: showNames,
|
||||
inside: true,
|
||||
margin: 0,
|
||||
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
|
||||
align: rtl ? "right" : "left",
|
||||
verticalAlign: "bottom",
|
||||
width: insideLabelWidth,
|
||||
overflow: "truncate",
|
||||
formatter: (id: string) =>
|
||||
(this._chartData.find((d) => d.id === id)?.name as string) ??
|
||||
"",
|
||||
hideOverlap: true,
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
top: insideLabels ? 20 : 10,
|
||||
bottom: GRID_BOTTOM,
|
||||
left: rtl ? 1 : labelWidth,
|
||||
right: rtl ? labelWidth : 1,
|
||||
left: rtl ? 1 : plotPadding,
|
||||
right: rtl ? plotPadding : 1,
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
position: itemTooltipPosition,
|
||||
position: sideTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -401,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-chart-base {
|
||||
--chart-max-height: none;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw timeline row names above their bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -227,6 +231,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
.startTime=${this._computedStartTime}
|
||||
.endTime=${this._computedEndTime}
|
||||
.showNames=${this.showNames}
|
||||
.insideLabels=${this.insideLabels}
|
||||
.names=${this.names}
|
||||
.narrow=${this.narrow}
|
||||
.chunked=${this.virtualize}
|
||||
@@ -424,6 +429,12 @@ export class StateHistoryCharts extends LitElement {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Names inside the plot sit close to the chart above them, so the groups
|
||||
need more room between them to stay apart. */
|
||||
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
|
||||
margin-top: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.entry-container:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -446,7 +446,6 @@ export class StatisticsChart extends LitElement {
|
||||
...createYAxisPrecisionBounds({
|
||||
min: this._clampYAxis(minYAxis),
|
||||
max: this._clampYAxis(maxYAxis),
|
||||
unit: this.unit,
|
||||
// Bar charts stay anchored at 0, so precision must reflect the
|
||||
// 0-based range that is actually rendered.
|
||||
includeZero: !yAxisScale,
|
||||
|
||||
@@ -1,32 +1,13 @@
|
||||
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
|
||||
import { getPrecision, nice, round } from "echarts/lib/util/number";
|
||||
|
||||
// A range smaller than this fraction of the axis magnitude is floating-point
|
||||
// noise (e.g. from summed statistics), not real precision.
|
||||
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
|
||||
|
||||
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
|
||||
// precision derived here cannot drift from the ticks it renders.
|
||||
const SPLIT_NUMBER = 5;
|
||||
|
||||
// How thin a gap between the data and the plot edge counts as no gap at all,
|
||||
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
|
||||
// maximum to a tick multiple, which usually leaves headroom, but quantized
|
||||
// states often land exactly on a tick and get none — collapsing area-filled
|
||||
// series, which are drawn from their value down to the axis minimum. Widening
|
||||
// the extent by this much before that rounding bumps any axis with less
|
||||
// headroom out to a full tick, and leaves the rest where they are.
|
||||
const GAP_FRACTION_OF_SPAN = 0.02;
|
||||
|
||||
// A percentage has a real ceiling the way zero is a real floor, so the gap must
|
||||
// not push the axis past it. Not every `%` sensor is bounded — power factor is
|
||||
// signed and can read over 100 — so this only applies while the data stays under.
|
||||
const PERCENT_MAX = 100;
|
||||
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range, by asking ECharts for the same tick interval it will
|
||||
// render. This matches the precision it actually draws, so labels are neither
|
||||
// truncated to identical values nor padded with extra zeros.
|
||||
// observed data range. We mirror how ECharts sizes its ticks: it splits the
|
||||
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
|
||||
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
|
||||
// interval needs. This matches the precision ECharts actually renders, so
|
||||
// labels are neither truncated to identical values nor padded with extra zeros.
|
||||
export function computeYAxisFractionDigits(
|
||||
min: number,
|
||||
max: number,
|
||||
@@ -41,7 +22,13 @@ export function computeYAxisFractionDigits(
|
||||
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
|
||||
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
|
||||
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
|
||||
return getPrecision(nice(range / SPLIT_NUMBER, true));
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
// Rounding the mantissa to a nice value only ever carries to the next power
|
||||
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
|
||||
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
|
||||
return Math.max(0, -niceExponent);
|
||||
}
|
||||
|
||||
interface YAxisExtentValues {
|
||||
@@ -57,116 +44,34 @@ const resolveYAxisBound = (
|
||||
values: YAxisExtentValues
|
||||
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
|
||||
|
||||
// A constant series has no span for the gap to be a fraction of, so ECharts
|
||||
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
|
||||
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
|
||||
// expansion a flat series relies on for its window. Run that expansion here and
|
||||
// return fixed bounds, which also suppresses the gap.
|
||||
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
|
||||
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
|
||||
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
|
||||
const precision = getPrecision(interval);
|
||||
return {
|
||||
min: round(Math.floor(lo / interval) * interval, precision),
|
||||
max: round(Math.ceil(hi / interval) * interval, precision),
|
||||
};
|
||||
};
|
||||
|
||||
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
|
||||
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
|
||||
// callbacks with the extent of the visible (zoom-filtered) data on every
|
||||
// dataZoom, and always before the label formatter runs, so the fraction digits
|
||||
// recomputed here track the zoomed range. A callback returns `undefined`
|
||||
// wherever auto-scaling should stand, and a number only where the axis has to be
|
||||
// pinned: an explicit bound, the zero anchor, or a constant series.
|
||||
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
|
||||
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
|
||||
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
|
||||
// always before the label formatter runs, so recomputing the fraction digits
|
||||
// here keeps zoomed-in labels distinct. The callbacks return the original
|
||||
// bounds unchanged, so auto-scaling still applies when a bound is not set.
|
||||
export function createYAxisPrecisionBounds(options: {
|
||||
min?: YAxisBound;
|
||||
max?: YAxisBound;
|
||||
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
|
||||
// Such an axis also gets no gap: pushing it below zero would defeat the zero
|
||||
// anchoring and leave the bars floating above the axis.
|
||||
includeZero?: boolean;
|
||||
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
|
||||
// a limit the data itself never crosses.
|
||||
unit?: string;
|
||||
onFractionDigits: (digits: number) => void;
|
||||
}): {
|
||||
min: (values: YAxisExtentValues) => number | undefined;
|
||||
max: (values: YAxisExtentValues) => number | undefined;
|
||||
boundaryGap: [number, number];
|
||||
splitNumber: number;
|
||||
} {
|
||||
const { min, max, includeZero, unit, onFractionDigits } = options;
|
||||
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
|
||||
|
||||
const resolveBounds = (values: YAxisExtentValues) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
if (
|
||||
includeZero ||
|
||||
!Number.isFinite(values.min) ||
|
||||
!Number.isFinite(values.max)
|
||||
) {
|
||||
return { min: resolvedMin, max: resolvedMax, gap: 0 };
|
||||
}
|
||||
if (values.min === values.max) {
|
||||
const flat = flatSeriesExtent(values.min, [
|
||||
resolvedMin !== undefined,
|
||||
resolvedMax !== undefined,
|
||||
]);
|
||||
// The expansion is a fraction of the magnitude, so a constant series near
|
||||
// the ceiling would otherwise overshoot it too.
|
||||
const flatMax =
|
||||
naturalMax !== undefined && values.max <= naturalMax
|
||||
? Math.min(flat.max, naturalMax)
|
||||
: flat.max;
|
||||
return {
|
||||
min: resolvedMin ?? flat.min,
|
||||
max: resolvedMax ?? flatMax,
|
||||
gap: 0,
|
||||
};
|
||||
}
|
||||
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
|
||||
// Never let the gap carry a series past a boundary it does not itself cross.
|
||||
const floor = values.min >= 0 ? 0 : undefined;
|
||||
const ceiling =
|
||||
naturalMax !== undefined && values.max <= naturalMax
|
||||
? naturalMax
|
||||
: values.max <= 0
|
||||
? 0
|
||||
: undefined;
|
||||
return {
|
||||
min:
|
||||
resolvedMin ??
|
||||
(floor !== undefined && values.min - floor < gap ? floor : undefined),
|
||||
max:
|
||||
resolvedMax ??
|
||||
(ceiling !== undefined && ceiling - values.max < gap
|
||||
? ceiling
|
||||
: undefined),
|
||||
gap,
|
||||
};
|
||||
};
|
||||
|
||||
const { min, max, includeZero, onFractionDigits } = options;
|
||||
return {
|
||||
// Always emit the key. `setOption` merges the Y axis rather than replacing
|
||||
// it, so a conditionally spread gap would survive a chart switching to a
|
||||
// zero-anchored type and leave its bars floating.
|
||||
boundaryGap: includeZero
|
||||
? [0, 0]
|
||||
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
|
||||
splitNumber: SPLIT_NUMBER,
|
||||
min: (values) => {
|
||||
const bounds = resolveBounds(values);
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
const extentMin = resolvedMin ?? values.min;
|
||||
const extentMax = resolvedMax ?? values.max;
|
||||
onFractionDigits(
|
||||
computeYAxisFractionDigits(
|
||||
bounds.min ?? values.min - bounds.gap,
|
||||
bounds.max ?? values.max + bounds.gap,
|
||||
includeZero
|
||||
)
|
||||
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
|
||||
);
|
||||
return bounds.min;
|
||||
return resolvedMin;
|
||||
},
|
||||
max: (values) => resolveBounds(values).max,
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
getStatisticIds,
|
||||
getStatisticLabel,
|
||||
type StatisticsMetaData,
|
||||
} from "../../data/recorder";
|
||||
import { getStatisticIds } from "../../data/recorder_statistic_ids";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import "../ha-combo-box-item";
|
||||
|
||||
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
|
||||
static styles = css`
|
||||
.issue-type {
|
||||
position: relative;
|
||||
padding: var(--ha-alert-padding, 8px);
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
}
|
||||
.icon {
|
||||
|
||||
@@ -95,10 +95,6 @@ export class HaDrawer extends LitElement {
|
||||
}
|
||||
|
||||
private _handleAfterHide(ev: Event) {
|
||||
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
|
||||
if (ev.target !== ev.currentTarget) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this.open = false;
|
||||
fireEvent(this, "hass-drawer-closed");
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
getSelectorInitialValue,
|
||||
getSelectorInitialValueOrUndefined,
|
||||
} from "./get-selector-initial-value";
|
||||
import type { Selector } from "../../data/selector";
|
||||
import type { HaFormData, HaFormSchema } from "./types";
|
||||
|
||||
interface ComputeInitialHaFormDataOptions {
|
||||
skipUnsupportedSelectors?: boolean;
|
||||
}
|
||||
|
||||
const setDefaultValue = (
|
||||
field: HaFormSchema,
|
||||
value: HaFormData | undefined
|
||||
@@ -25,8 +18,7 @@ const setDefaultValue = (
|
||||
};
|
||||
|
||||
export const computeInitialHaFormData = (
|
||||
schema: HaFormSchema[] | readonly HaFormSchema[],
|
||||
options?: ComputeInitialHaFormDataOptions
|
||||
schema: HaFormSchema[] | readonly HaFormSchema[]
|
||||
): Record<string, any> => {
|
||||
const data = {};
|
||||
schema.forEach((field) => {
|
||||
@@ -41,7 +33,7 @@ export const computeInitialHaFormData = (
|
||||
} else if ("default" in field) {
|
||||
data[field.name] = setDefaultValue(field, field.default);
|
||||
} else if (field.type === "expandable") {
|
||||
const expandableData = computeInitialHaFormData(field.schema, options);
|
||||
const expandableData = computeInitialHaFormData(field.schema);
|
||||
if (field.required || Object.keys(expandableData).length) {
|
||||
// Only add expandable data if it's required or any of its children have initial values.
|
||||
data[field.name] = expandableData;
|
||||
@@ -70,11 +62,104 @@ export const computeInitialHaFormData = (
|
||||
seconds: 0,
|
||||
};
|
||||
} else if ("selector" in field) {
|
||||
const initialValue = options?.skipUnsupportedSelectors
|
||||
? getSelectorInitialValueOrUndefined(field.selector)
|
||||
: getSelectorInitialValue(field.selector);
|
||||
if (initialValue !== undefined) {
|
||||
data[field.name] = initialValue;
|
||||
const selector: Selector = field.selector;
|
||||
|
||||
if ("device" in selector) {
|
||||
data[field.name] = selector.device?.multiple ? [] : "";
|
||||
} else if ("entity" in selector) {
|
||||
data[field.name] = selector.entity?.multiple ? [] : "";
|
||||
} else if ("area" in selector) {
|
||||
data[field.name] = selector.area?.multiple ? [] : "";
|
||||
} else if ("label" in selector) {
|
||||
data[field.name] = selector.label?.multiple ? [] : "";
|
||||
} else if ("boolean" in selector) {
|
||||
data[field.name] = false;
|
||||
} else if (
|
||||
"addon" in selector ||
|
||||
"attribute" in selector ||
|
||||
"file" in selector ||
|
||||
"icon" in selector ||
|
||||
"serial_port" in selector ||
|
||||
"template" in selector ||
|
||||
"text" in selector ||
|
||||
"theme" in selector ||
|
||||
"object" in selector
|
||||
) {
|
||||
data[field.name] = "";
|
||||
} else if ("number" in selector) {
|
||||
data[field.name] = selector.number?.min ?? 0;
|
||||
} else if ("select" in selector) {
|
||||
if (selector.select?.options.length) {
|
||||
const firstOption = selector.select.options[0];
|
||||
const val =
|
||||
typeof firstOption === "string" ? firstOption : firstOption.value;
|
||||
data[field.name] = selector.select.multiple ? [val] : val;
|
||||
}
|
||||
} else if ("country" in selector) {
|
||||
if (selector.country?.countries?.length) {
|
||||
data[field.name] = selector.country.countries[0];
|
||||
}
|
||||
} else if ("language" in selector) {
|
||||
if (selector.language?.languages?.length) {
|
||||
data[field.name] = selector.language.languages[0];
|
||||
}
|
||||
} else if ("duration" in selector) {
|
||||
data[field.name] = {
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
};
|
||||
} else if ("time" in selector) {
|
||||
data[field.name] = "00:00:00";
|
||||
} else if ("date" in selector || "datetime" in selector) {
|
||||
const now = new Date().toISOString().slice(0, 10);
|
||||
data[field.name] = `${now}T00:00:00`;
|
||||
} else if ("color_rgb" in selector) {
|
||||
data[field.name] = [0, 0, 0];
|
||||
} else if ("color_temp" in selector) {
|
||||
data[field.name] = selector.color_temp?.min_mireds ?? 153;
|
||||
} else if (
|
||||
"action" in selector ||
|
||||
"trigger" in selector ||
|
||||
"condition" in selector
|
||||
) {
|
||||
data[field.name] = [];
|
||||
} else if ("media" in selector || "target" in selector) {
|
||||
data[field.name] = {};
|
||||
} else if ("state" in selector) {
|
||||
data[field.name] = selector.state?.multiple ? [] : "";
|
||||
} else if ("choose" in selector) {
|
||||
const firstChoice = Object.keys(selector.choose.choices)[0];
|
||||
if (!firstChoice) {
|
||||
data[field.name] = {};
|
||||
} else {
|
||||
data[field.name] = {
|
||||
active_choice: firstChoice,
|
||||
[firstChoice]: computeInitialHaFormData([
|
||||
{
|
||||
name: firstChoice,
|
||||
selector: selector.choose.choices[firstChoice].selector,
|
||||
},
|
||||
])[firstChoice],
|
||||
};
|
||||
}
|
||||
} else if ("numeric_threshold" in selector) {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
data[field.name] =
|
||||
type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,89 +1,25 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
SelectorType,
|
||||
} from "../../data/selector";
|
||||
|
||||
type SelectorFallbackValues = {
|
||||
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
|
||||
};
|
||||
|
||||
const SELECTOR_FALLBACK_VALUES = {
|
||||
action: undefined,
|
||||
addon: undefined,
|
||||
automation_behavior: undefined,
|
||||
app: undefined,
|
||||
area: undefined,
|
||||
areas_display: undefined,
|
||||
attribute: undefined,
|
||||
assist_pipeline: undefined,
|
||||
boolean: () => false,
|
||||
choose: undefined,
|
||||
color_rgb: undefined,
|
||||
condition: undefined,
|
||||
config_entry: undefined,
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: undefined,
|
||||
date: undefined,
|
||||
datetime: undefined,
|
||||
device: undefined,
|
||||
device_class: undefined,
|
||||
duration: undefined,
|
||||
entity: undefined,
|
||||
entity_name: undefined,
|
||||
statistic: undefined,
|
||||
file: undefined,
|
||||
floor: undefined,
|
||||
label: undefined,
|
||||
language: undefined,
|
||||
navigation: undefined,
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: undefined,
|
||||
object: undefined,
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: undefined,
|
||||
selector: undefined,
|
||||
serial_port: undefined,
|
||||
state: undefined,
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: undefined,
|
||||
template: undefined,
|
||||
text: undefined,
|
||||
time: undefined,
|
||||
icon: undefined,
|
||||
media: undefined,
|
||||
theme: undefined,
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
trigger: undefined,
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
},
|
||||
ui_action: undefined,
|
||||
ui_clock_date_format: undefined,
|
||||
ui_color: undefined,
|
||||
ui_state_content: undefined,
|
||||
ui_time_format: undefined,
|
||||
} satisfies SelectorFallbackValues;
|
||||
import type { Selector } from "../../data/selector";
|
||||
|
||||
/**
|
||||
* Value a selector already displays when no field value is set.
|
||||
* Used when enabling an optional service/trigger/condition field.
|
||||
*/
|
||||
export const getSelectorFallbackValue = (selector: Selector): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
|
||||
|
||||
return fallbackValue?.(selector as never);
|
||||
if ("constant" in selector) {
|
||||
return selector.constant?.value;
|
||||
}
|
||||
if ("boolean" in selector) {
|
||||
return false;
|
||||
}
|
||||
if ("number" in selector) {
|
||||
return selector.number?.min ?? 0;
|
||||
}
|
||||
if ("color_temp" in selector) {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import type {
|
||||
Selector,
|
||||
SelectorForType,
|
||||
SelectorType,
|
||||
} from "../../data/selector";
|
||||
|
||||
type SelectorInitialValues = {
|
||||
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
|
||||
};
|
||||
|
||||
const SELECTOR_INITIAL_VALUES = {
|
||||
action: () => [],
|
||||
addon: () => "",
|
||||
automation_behavior: undefined,
|
||||
app: undefined,
|
||||
area: (selector) => (selector.area?.multiple ? [] : ""),
|
||||
areas_display: undefined,
|
||||
attribute: () => "",
|
||||
assist_pipeline: undefined,
|
||||
boolean: () => false,
|
||||
choose: (selector) => {
|
||||
const firstChoice = Object.keys(selector.choose.choices)[0];
|
||||
|
||||
if (!firstChoice) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const childValue = getSelectorInitialValueOrUndefined(
|
||||
selector.choose.choices[firstChoice].selector
|
||||
);
|
||||
|
||||
return childValue === undefined
|
||||
? { active_choice: firstChoice }
|
||||
: { active_choice: firstChoice, [firstChoice]: childValue };
|
||||
},
|
||||
color_rgb: () => [0, 0, 0],
|
||||
condition: () => [],
|
||||
config_entry: undefined,
|
||||
conversation_agent: undefined,
|
||||
constant: (selector) => selector.constant?.value,
|
||||
country: (selector) => selector.country?.countries?.[0],
|
||||
date: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
|
||||
datetime: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
|
||||
device: (selector) => (selector.device?.multiple ? [] : ""),
|
||||
device_class: (selector) =>
|
||||
selector.device_class?.multiple ? [] : undefined,
|
||||
duration: () => ({
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
seconds: 0,
|
||||
}),
|
||||
entity: (selector) => (selector.entity?.multiple ? [] : ""),
|
||||
entity_name: undefined,
|
||||
statistic: undefined,
|
||||
file: () => "",
|
||||
floor: undefined,
|
||||
label: (selector) => (selector.label?.multiple ? [] : ""),
|
||||
language: (selector) => selector.language?.languages?.[0],
|
||||
navigation: undefined,
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: (selector) => {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
|
||||
return type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
},
|
||||
object: (selector) => (selector.object?.multiple ? [] : ""),
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: (selector) => {
|
||||
const select = selector.select;
|
||||
|
||||
if (!select?.options.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const firstOption = select.options[0];
|
||||
const value =
|
||||
typeof firstOption === "string" ? firstOption : firstOption.value;
|
||||
|
||||
return select.multiple ? [value] : value;
|
||||
},
|
||||
selector: undefined,
|
||||
serial_port: () => "",
|
||||
state: (selector) => (selector.state?.multiple ? [] : ""),
|
||||
backup_location: undefined,
|
||||
stt: undefined,
|
||||
target: () => ({}),
|
||||
template: () => "",
|
||||
text: (selector) => (selector.text?.multiple ? [] : ""),
|
||||
time: () => "00:00:00",
|
||||
icon: () => "",
|
||||
media: () => ({}),
|
||||
theme: () => "",
|
||||
timezone: undefined,
|
||||
button_toggle: undefined,
|
||||
trigger: () => [],
|
||||
tts: undefined,
|
||||
tts_voice: undefined,
|
||||
location: undefined,
|
||||
color_temp: (selector) => selector.color_temp?.min_mireds ?? 153,
|
||||
ui_action: undefined,
|
||||
ui_clock_date_format: undefined,
|
||||
ui_color: undefined,
|
||||
ui_state_content: undefined,
|
||||
ui_time_format: undefined,
|
||||
} satisfies SelectorInitialValues;
|
||||
|
||||
export const getSelectorInitialValueOrUndefined = (
|
||||
selector: Selector
|
||||
): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
return SELECTOR_INITIAL_VALUES[type]?.(selector as never);
|
||||
};
|
||||
|
||||
export const getSelectorInitialValue = (selector: Selector): unknown => {
|
||||
const type = Object.keys(selector)[0] as SelectorType;
|
||||
const initialValue = SELECTOR_INITIAL_VALUES[type];
|
||||
|
||||
if (!initialValue) {
|
||||
throw new Error(`Selector ${type} not supported in initial form data`);
|
||||
}
|
||||
|
||||
return initialValue(selector as never);
|
||||
};
|
||||
@@ -67,16 +67,15 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key.
|
||||
// Leaving inputmode unset on a number input gives the "Numbers and
|
||||
// Punctuation" keyboard there, which does include a minus. Other platforms
|
||||
// include a minus on their number keypads, so restrict this workaround to
|
||||
// Safari/WebKit and only when the selector allows negatives: either an
|
||||
// explicit negative min, or no min at all (e.g. the numeric threshold
|
||||
// selector used by the power triggers).
|
||||
const useSafariNegativeKeyboard =
|
||||
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
|
||||
// so negatives can only be typed with the full "text" keyboard. Other
|
||||
// platforms include a minus on their number keypads, so restrict this
|
||||
// workaround to Safari/WebKit and only when the selector allows negatives
|
||||
// (e.g. numeric_state triggers/conditions).
|
||||
const useTextInputMode =
|
||||
isSafari &&
|
||||
(this.selector.number?.min === undefined || this.selector.number.min < 0);
|
||||
this.selector.number?.min !== undefined &&
|
||||
this.selector.number.min < 0;
|
||||
|
||||
const translationKey = this.selector.number?.translation_key;
|
||||
let unit = this.selector.number?.unit_of_measurement;
|
||||
@@ -113,8 +112,8 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
<ha-input
|
||||
.inputmode=${
|
||||
useSafariNegativeKeyboard
|
||||
? undefined
|
||||
useTextInputMode
|
||||
? "text"
|
||||
: this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { ObjectSelector } from "../../data/selector";
|
||||
import { formatSelectorValue } from "../../data/selector/format_selector_value";
|
||||
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
|
||||
import type { HaFormSchema } from "../ha-form/types";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-md-list";
|
||||
@@ -238,14 +237,10 @@ export class HaObjectSelector extends LitElement {
|
||||
private async _addItem(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const schema = this._schema(this.selector);
|
||||
|
||||
const newItem = await showFormDialog(this, {
|
||||
title: this.hass.localize("ui.common.add"),
|
||||
schema,
|
||||
data: computeInitialHaFormData(schema, {
|
||||
skipUnsupportedSelectors: true,
|
||||
}),
|
||||
schema: this._schema(this.selector),
|
||||
data: {},
|
||||
computeLabel: this._computeLabel,
|
||||
computeHelper: this._computeHelper,
|
||||
submitText: this.hass.localize("ui.common.add"),
|
||||
@@ -298,7 +293,7 @@ export class HaObjectSelector extends LitElement {
|
||||
const index = ev.currentTarget.index;
|
||||
|
||||
if (!this.selector.object!.multiple) {
|
||||
fireEvent(this, "value-changed", { value: "" });
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import type { Selector, SelectorType } from "../../data/selector";
|
||||
import type { Selector } from "../../data/selector";
|
||||
import {
|
||||
handleLegacyDeviceSelector,
|
||||
handleLegacyEntitySelector,
|
||||
@@ -70,7 +70,7 @@ const LOAD_ELEMENTS = {
|
||||
ui_color: () => import("./ha-selector-ui-color"),
|
||||
ui_state_content: () => import("./ha-selector-ui-state-content"),
|
||||
ui_time_format: () => import("./ha-selector-ui-time-format"),
|
||||
} satisfies Record<SelectorType, () => Promise<unknown>>;
|
||||
};
|
||||
|
||||
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
|
||||
|
||||
|
||||
+107
-135
@@ -9,8 +9,7 @@ import {
|
||||
} from "../resources/theme/color/color.globals";
|
||||
import type { HomeAssistant, ThemeSettings, ValueChangedEvent } from "../types";
|
||||
import "./ha-button";
|
||||
import "./item/ha-list-item-base";
|
||||
import "./list/ha-list-base";
|
||||
import "./ha-settings-row";
|
||||
import "./ha-theme-picker";
|
||||
import "./input/ha-input";
|
||||
import "./radio/ha-radio-group";
|
||||
@@ -26,7 +25,6 @@ export interface ThemeSettingsLabels {
|
||||
autoMode?: string;
|
||||
lightMode?: string;
|
||||
darkMode?: string;
|
||||
colors?: string;
|
||||
primaryColor?: string;
|
||||
accentColor?: string;
|
||||
reset?: string;
|
||||
@@ -44,7 +42,7 @@ export class HaThemeSettings extends LitElement {
|
||||
|
||||
@property() public heading?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: "include-default", type: Boolean })
|
||||
public includeDefault = false;
|
||||
@@ -64,121 +62,98 @@ export class HaThemeSettings extends LitElement {
|
||||
? this.hass.themes.default_dark_theme || this.hass.themes.default_theme
|
||||
: this.hass.themes.default_theme;
|
||||
|
||||
const showDarkMode =
|
||||
curTheme === HOME_ASSISTANT_THEME ||
|
||||
(curThemeIsUseDefault &&
|
||||
this.hass.themes.default_dark_theme &&
|
||||
this.hass.themes.default_theme) ||
|
||||
this._supportsModeSelection(curTheme);
|
||||
|
||||
return html`
|
||||
<ha-list-base>
|
||||
<ha-settings-row .narrow=${this.narrow} ?empty=${!this.showThemePicker}>
|
||||
${
|
||||
this.heading
|
||||
? html`<span slot="heading">${this.heading}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.description
|
||||
? html`<span slot="description">${this.description}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.showThemePicker
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
${
|
||||
this.heading
|
||||
? html`<span slot="headline">${this.heading}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.description
|
||||
? html`<span slot="supporting-text"
|
||||
>${this.description}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<ha-theme-picker
|
||||
slot="end"
|
||||
.label=${this.labels?.theme}
|
||||
.noThemeLabel=${this.labels?.noTheme}
|
||||
.value=${themeSettings?.theme || undefined}
|
||||
.disabled=${this.themePickerDisabled}
|
||||
?include-default=${this.includeDefault}
|
||||
@value-changed=${this._handleThemeSelection}
|
||||
></ha-theme-picker>
|
||||
</ha-list-item-base>
|
||||
<ha-theme-picker
|
||||
.label=${this.labels?.theme}
|
||||
.noThemeLabel=${this.labels?.noTheme}
|
||||
.value=${themeSettings?.theme || undefined}
|
||||
.disabled=${this.themePickerDisabled}
|
||||
?include-default=${this.includeDefault}
|
||||
@value-changed=${this._handleThemeSelection}
|
||||
></ha-theme-picker>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
showDarkMode
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<span slot="headline"
|
||||
>${this.labels?.mode ?? "Theme mode"}</span
|
||||
>
|
||||
<ha-radio-group
|
||||
slot="end"
|
||||
@change=${this._handleDarkMode}
|
||||
name="dark_mode"
|
||||
.value=${
|
||||
themeSettings?.dark === undefined
|
||||
? "auto"
|
||||
: themeSettings.dark
|
||||
? "dark"
|
||||
: "light"
|
||||
}
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ha-radio-option value="auto">
|
||||
${this.labels?.autoMode ?? "Auto"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="light">
|
||||
${this.labels?.lightMode ?? "Light"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="dark">
|
||||
${this.labels?.darkMode ?? "Dark"}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
</ha-list-item-base>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
curTheme === HOME_ASSISTANT_THEME
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<span slot="headline"
|
||||
>${this.labels?.colors ?? "Custom colors"}</span
|
||||
>
|
||||
<div slot="end" class="color-pickers">
|
||||
<ha-input
|
||||
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
|
||||
type="color"
|
||||
.label=${this.labels?.primaryColor ?? "Primary color"}
|
||||
.name=${"primaryColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
<ha-input
|
||||
.value=${themeSettings?.accentColor || DefaultAccentColor}
|
||||
type="color"
|
||||
.label=${this.labels?.accentColor ?? "Accent color"}
|
||||
.name=${"accentColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
</div>
|
||||
</ha-list-item-base>
|
||||
${
|
||||
themeSettings?.primaryColor || themeSettings?.accentColor
|
||||
? html`
|
||||
<div class="reset-row">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._resetColors}
|
||||
>
|
||||
${this.labels?.reset ?? "Reset"}
|
||||
</ha-button>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
</ha-settings-row>
|
||||
${
|
||||
curTheme === HOME_ASSISTANT_THEME ||
|
||||
(curThemeIsUseDefault &&
|
||||
this.hass.themes.default_dark_theme &&
|
||||
this.hass.themes.default_theme) ||
|
||||
this._supportsModeSelection(curTheme)
|
||||
? html`<div class="inputs">
|
||||
<ha-radio-group
|
||||
@change=${this._handleDarkMode}
|
||||
name="dark_mode"
|
||||
.ariaLabel=${this.labels?.mode ?? "Theme mode"}
|
||||
.value=${
|
||||
themeSettings?.dark === undefined
|
||||
? "auto"
|
||||
: themeSettings.dark
|
||||
? "dark"
|
||||
: "light"
|
||||
}
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-base>
|
||||
orientation="horizontal"
|
||||
>
|
||||
<ha-radio-option value="auto">
|
||||
${this.labels?.autoMode ?? "Auto"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="light">
|
||||
${this.labels?.lightMode ?? "Light"}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="dark">
|
||||
${this.labels?.darkMode ?? "Dark"}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
${
|
||||
curTheme === HOME_ASSISTANT_THEME
|
||||
? html`<div class="color-pickers">
|
||||
<ha-input
|
||||
.value=${themeSettings?.primaryColor || DefaultPrimaryColor}
|
||||
type="color"
|
||||
.label=${this.labels?.primaryColor ?? "Primary color"}
|
||||
.name=${"primaryColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
<ha-input
|
||||
.value=${themeSettings?.accentColor || DefaultAccentColor}
|
||||
type="color"
|
||||
.label=${this.labels?.accentColor ?? "Accent color"}
|
||||
.name=${"accentColor"}
|
||||
@change=${this._handleColorChange}
|
||||
></ha-input>
|
||||
${
|
||||
themeSettings?.primaryColor ||
|
||||
themeSettings?.accentColor
|
||||
? html` <ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._resetColors}
|
||||
>
|
||||
${this.labels?.reset ?? "Reset"}
|
||||
</ha-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -256,35 +231,32 @@ export class HaThemeSettings extends LitElement {
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
ha-list-base {
|
||||
--ha-row-item-padding-block: var(--ha-space-2);
|
||||
.inputs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
margin: 0 var(--ha-space-3);
|
||||
}
|
||||
ha-theme-picker {
|
||||
min-width: 150px;
|
||||
ha-radio-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-inline-end: var(--ha-space-3);
|
||||
}
|
||||
.color-pickers {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.color-pickers ha-input {
|
||||
min-width: 150px;
|
||||
flex: 1;
|
||||
}
|
||||
:host([narrow]) ha-list-item-base::part(base) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
:host([narrow]) ha-list-item-base::part(end) {
|
||||
width: 100%;
|
||||
}
|
||||
:host([narrow]) .color-pickers {
|
||||
width: 100%;
|
||||
}
|
||||
.reset-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
padding-bottom: var(--ha-space-4);
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
ha-input {
|
||||
min-width: 75px;
|
||||
flex-grow: 1;
|
||||
margin: 0 var(--ha-space-1);
|
||||
}
|
||||
|
||||
ha-theme-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -564,73 +564,75 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.primaryEntitiesOnly
|
||||
);
|
||||
|
||||
let referencedAreas = entries.referenced_areas;
|
||||
const hiddenAreaIds: string[] = [];
|
||||
if (this.type === "floor" || this.type === "label") {
|
||||
referencedAreas = referencedAreas.filter((area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
entries.referenced_areas = entries.referenced_areas.filter(
|
||||
(area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenAreaIds.push(area_id);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenAreaIds.push(area_id);
|
||||
return false;
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
let referencedDevices = entries.referenced_devices;
|
||||
const hiddenDeviceIds: string[] = [];
|
||||
if (
|
||||
this.type === "floor" ||
|
||||
this.type === "area" ||
|
||||
this.type === "label"
|
||||
) {
|
||||
referencedDevices = referencedDevices.filter((device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
entries.referenced_devices = entries.referenced_devices.filter(
|
||||
(device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
return false;
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
const referencedEntities = entries.referenced_entities.filter(
|
||||
entries.referenced_entities = entries.referenced_entities.filter(
|
||||
(entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
// Core can reference entities that are absent from the display
|
||||
@@ -645,9 +647,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
(this.type === "area" && entity.area_id === this.itemId) ||
|
||||
(this.type === "floor" &&
|
||||
entity.area_id &&
|
||||
referencedAreas.includes(entity.area_id)) ||
|
||||
entries.referenced_areas.includes(entity.area_id)) ||
|
||||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
|
||||
referencedDevices.includes(entity.device_id || "")
|
||||
entries.referenced_devices.includes(entity.device_id || "")
|
||||
) {
|
||||
return entityRegMeetsFilter(
|
||||
entity,
|
||||
@@ -662,12 +664,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
this._entries = {
|
||||
...entries,
|
||||
referenced_areas: referencedAreas,
|
||||
referenced_devices: referencedDevices,
|
||||
referenced_entities: referencedEntities,
|
||||
};
|
||||
this._entries = entries;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to extract target", e);
|
||||
|
||||
+33
-50
@@ -1553,8 +1553,7 @@ export const formatConsumptionShort = (
|
||||
hass: HomeAssistant,
|
||||
consumption: number | null,
|
||||
unit: string,
|
||||
targetUnit?: string,
|
||||
displayPrecision?: number
|
||||
targetUnit?: string
|
||||
): string => {
|
||||
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
|
||||
let pickedUnit = unit;
|
||||
@@ -1584,19 +1583,10 @@ export const formatConsumptionShort = (
|
||||
pickedUnit = units[unitIndex];
|
||||
}
|
||||
return (
|
||||
formatNumber(
|
||||
val,
|
||||
hass.locale,
|
||||
displayPrecision !== undefined && pickedUnit === unit
|
||||
? {
|
||||
minimumFractionDigits: displayPrecision,
|
||||
maximumFractionDigits: displayPrecision,
|
||||
}
|
||||
: {
|
||||
maximumFractionDigits:
|
||||
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
|
||||
}
|
||||
) +
|
||||
formatNumber(val, hass.locale, {
|
||||
maximumFractionDigits:
|
||||
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
|
||||
}) +
|
||||
" " +
|
||||
pickedUnit
|
||||
);
|
||||
@@ -1919,13 +1909,20 @@ export const downloadEnergyData = (
|
||||
const device_consumption_water = energyData.prefs.device_consumption_water;
|
||||
const stats = energyData.state.stats;
|
||||
|
||||
interface CsvRow {
|
||||
id: string;
|
||||
type: string;
|
||||
unit: string;
|
||||
data: StatisticValue[];
|
||||
}
|
||||
const rows: CsvRow[] = [];
|
||||
const timeSet = new Set<number>();
|
||||
Object.values(stats).forEach((stat) => {
|
||||
stat.forEach((datapoint) => {
|
||||
timeSet.add(datapoint.start);
|
||||
});
|
||||
});
|
||||
const times = Array.from(timeSet).sort();
|
||||
|
||||
const headers =
|
||||
"entity_id,type,unit," +
|
||||
times.map((t) => new Date(t).toISOString()).join(",") +
|
||||
"\n";
|
||||
const csv: string[] = [];
|
||||
csv[0] = headers;
|
||||
|
||||
const processCsvRow = function (
|
||||
id: string,
|
||||
@@ -1933,7 +1930,20 @@ export const downloadEnergyData = (
|
||||
unit: string,
|
||||
data: StatisticValue[]
|
||||
) {
|
||||
rows.push({ id, type, unit, data });
|
||||
let n = 0;
|
||||
const row: string[] = [];
|
||||
row.push(id);
|
||||
row.push(type);
|
||||
row.push(unit.normalize("NFKD"));
|
||||
times.forEach((t) => {
|
||||
if (n < data.length && data[n].start === t) {
|
||||
row.push((data[n].change ?? "").toString());
|
||||
n++;
|
||||
} else {
|
||||
row.push("");
|
||||
}
|
||||
});
|
||||
csv.push(row.join(",") + "\n");
|
||||
};
|
||||
|
||||
const processStat = function (stat: string, type: string, unit: string) {
|
||||
@@ -2180,33 +2190,6 @@ export const downloadEnergyData = (
|
||||
);
|
||||
}
|
||||
|
||||
const timeSet = new Set<number>();
|
||||
rows.forEach((row) => {
|
||||
row.data.forEach((datapoint) => {
|
||||
timeSet.add(datapoint.start);
|
||||
});
|
||||
});
|
||||
const times = Array.from(timeSet).sort();
|
||||
|
||||
const csv: string[] = [
|
||||
"entity_id,type,unit," +
|
||||
times.map((t) => new Date(t).toISOString()).join(",") +
|
||||
"\n",
|
||||
];
|
||||
rows.forEach(({ id, type, unit, data }) => {
|
||||
let n = 0;
|
||||
const row: string[] = [id, type, unit.normalize("NFKD")];
|
||||
times.forEach((t) => {
|
||||
if (n < data.length && data[n].start === t) {
|
||||
row.push((data[n].change ?? "").toString());
|
||||
n++;
|
||||
} else {
|
||||
row.push("");
|
||||
}
|
||||
});
|
||||
csv.push(row.join(",") + "\n");
|
||||
});
|
||||
|
||||
const blob = new Blob(csv, {
|
||||
type: "text/csv",
|
||||
});
|
||||
|
||||
@@ -50,6 +50,9 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
|
||||
azimuth: "°",
|
||||
elevation: "°",
|
||||
},
|
||||
vacuum: {
|
||||
battery_level: "%",
|
||||
},
|
||||
valve: {
|
||||
current_position: "%",
|
||||
},
|
||||
|
||||
@@ -40,7 +40,8 @@ export const searchPlaces = (
|
||||
limit ? `&limit=${limit}` : ""
|
||||
}${addressdetails ? "&addressdetails=1" : ""}&accept-language=${
|
||||
hass.locale.language
|
||||
}&email=abuse@home-assistant.io`
|
||||
}&email=abuse@home-assistant.io`,
|
||||
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
|
||||
).then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
@@ -58,7 +59,8 @@ export const reverseGeocode = (
|
||||
location[1]
|
||||
}&accept-language=${hass.locale.language}&zoom=${
|
||||
zoom ?? 18
|
||||
}&format=jsonv2&email=abuse@home-assistant.io`
|
||||
}&format=jsonv2&email=abuse@home-assistant.io`,
|
||||
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
|
||||
).then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
|
||||
@@ -160,6 +160,15 @@ export const getRecorderEntityOptions = (
|
||||
entity_id,
|
||||
});
|
||||
|
||||
export const getStatisticIds = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
statistic_type?: "mean" | "sum"
|
||||
) =>
|
||||
hass.callWS<StatisticsMetaData[]>({
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type,
|
||||
});
|
||||
|
||||
export const getStatisticMetadata = (
|
||||
hass: HomeAssistant,
|
||||
statistic_ids?: string[]
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { StatisticsMetaData } from "./recorder";
|
||||
|
||||
type StatisticIdsType = "mean" | "sum";
|
||||
|
||||
type StatisticIdsApi = Pick<HomeAssistant, "callWS">;
|
||||
|
||||
export const getStatisticIds = (
|
||||
hass: StatisticIdsApi,
|
||||
statistic_type?: StatisticIdsType
|
||||
) =>
|
||||
shareInFlightRequest(
|
||||
hass.callWS,
|
||||
`recorder/list_statistic_ids:${statistic_type ?? "all"}`,
|
||||
() =>
|
||||
hass.callWS<StatisticsMetaData[]>({
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type,
|
||||
})
|
||||
);
|
||||
@@ -88,20 +88,6 @@ export type Selector =
|
||||
| UiTimeFormatSelector
|
||||
| BackupLocationSelector;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
export type SelectorType = KeysOfUnion<Selector>;
|
||||
|
||||
type UnionMemberWithKey<U, K extends PropertyKey> = U extends unknown
|
||||
? K extends keyof U
|
||||
? U
|
||||
: never
|
||||
: never;
|
||||
|
||||
export type SelectorForType<T extends SelectorType> = UnionMemberWithKey<
|
||||
Selector,
|
||||
T
|
||||
>;
|
||||
|
||||
export interface ActionSelector {
|
||||
action: {
|
||||
optionsInSidebar?: boolean;
|
||||
|
||||
+4
-10
@@ -14,7 +14,6 @@ import {
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
|
||||
import type { EntityComboBoxItem } from "./entity/entity_picker";
|
||||
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
|
||||
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
|
||||
|
||||
export const TARGET_SEPARATOR = "________";
|
||||
|
||||
@@ -55,18 +54,13 @@ export const extractFromTarget = async (
|
||||
target: HassServiceTarget,
|
||||
expandGroup = false,
|
||||
primaryEntitiesOnly = true
|
||||
) => {
|
||||
const request = {
|
||||
type: "extract_from_target" as const,
|
||||
) =>
|
||||
callWS<ExtractFromTargetResult>({
|
||||
type: "extract_from_target",
|
||||
target,
|
||||
expand_group: expandGroup,
|
||||
primary_entities_only: primaryEntitiesOnly,
|
||||
};
|
||||
|
||||
return shareInFlightRequest(callWS, JSON.stringify(request), () =>
|
||||
callWS<ExtractFromTargetResult>(request)
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export const getTargetEntityCount = (target?: HassServiceTarget): number => {
|
||||
const tempTarget = {
|
||||
|
||||
@@ -526,16 +526,6 @@ export const fetchZwaveNetworkStatus = (
|
||||
});
|
||||
};
|
||||
|
||||
/** Node IDs of the nodes each node can reach directly, keyed by node ID. */
|
||||
export const fetchZwaveNetworkNeighbors = (
|
||||
hass: HomeAssistant,
|
||||
entry_id: string
|
||||
): Promise<Record<number, number[]>> =>
|
||||
hass.callWS({
|
||||
type: "zwave_js/network_neighbors",
|
||||
entry_id,
|
||||
});
|
||||
|
||||
export const fetchZwaveDataCollectionStatus = (
|
||||
hass: HomeAssistant,
|
||||
entry_id: string
|
||||
|
||||
@@ -116,7 +116,10 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
|
||||
></ha-vacuum-segment-area-mapper>
|
||||
|
||||
<div class="footer">
|
||||
<ha-button @click=${this._save} .disabled=${this._submitting}>
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${!this._dirtyState?.isDirty || this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</div>
|
||||
|
||||
@@ -235,7 +235,7 @@ class DialogShortcuts extends DialogMixin(LitElement) {
|
||||
|
||||
<ha-alert slot="footer">
|
||||
${this._i18n.localize("ui.dialogs.shortcuts.enable_shortcuts_hint", {
|
||||
user_profile: html`<a href="/profile/browser#shortcuts"
|
||||
user_profile: html`<a href="/profile/general#shortcuts"
|
||||
>${this._i18n.localize(
|
||||
"ui.dialogs.shortcuts.enable_shortcuts_hint_user_profile"
|
||||
)}</a
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import type { LitElement, PropertyValues } from "lit";
|
||||
import type { UnsavedChangesGuard } from "../common/navigate";
|
||||
import {
|
||||
registerUnsavedChangesGuard,
|
||||
unregisterUnsavedChangesGuard,
|
||||
} from "../common/navigate";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import type { Constructor } from "../types";
|
||||
|
||||
export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
|
||||
@@ -13,34 +9,45 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
|
||||
/** Provided by `DirtyStateProviderMixin`. */
|
||||
declare isDirtyState: boolean;
|
||||
|
||||
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
private _handleClick = async (e: MouseEvent) => {
|
||||
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
|
||||
const target = e.composedPath()[0];
|
||||
if (!isNavigationClick(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
private _unsavedChangesGuard: UnsavedChangesGuard = {
|
||||
isDirty: () => this.isDirtyState,
|
||||
prompt: () => this.promptDiscardChanges(),
|
||||
const result = await this.promptDiscardChanges();
|
||||
if (result) {
|
||||
this._removeListeners();
|
||||
if (target) {
|
||||
const newEvent = new MouseEvent(e.type, e);
|
||||
target.dispatchEvent(newEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
|
||||
registerUnsavedChangesGuard(this._unsavedChangesGuard);
|
||||
private _removeListeners() {
|
||||
window.removeEventListener("click", this._handleClick, true);
|
||||
window.removeEventListener("beforeunload", this._handleUnload);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
if (this.isDirtyState && this.isConnected) {
|
||||
window.addEventListener("click", this._handleClick, true);
|
||||
window.addEventListener("beforeunload", this._handleUnload);
|
||||
} else {
|
||||
window.removeEventListener("beforeunload", this._handleUnload);
|
||||
this._removeListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
unregisterUnsavedChangesGuard(this._unsavedChangesGuard);
|
||||
window.removeEventListener("beforeunload", this._handleUnload);
|
||||
this._removeListeners();
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges(): Promise<boolean> {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoize from "memoize-one";
|
||||
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
|
||||
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
|
||||
@@ -78,10 +77,6 @@ export class HAFullCalendar extends LitElement {
|
||||
|
||||
@property({ attribute: "add-fab", type: Boolean }) public addFab = false;
|
||||
|
||||
@property({ attribute: "add-fab-size" }) public addFabSize = "large";
|
||||
|
||||
@property({ attribute: "add-fab-style" }) public addFabStyle = "on_top";
|
||||
|
||||
@property({ attribute: false }) public events: CalendarEvent[] = [];
|
||||
|
||||
@property({ attribute: false }) public calendars: CalendarData[] = [];
|
||||
@@ -177,34 +172,13 @@ export class HAFullCalendar extends LitElement {
|
||||
</ha-icon-button-next>
|
||||
</div>
|
||||
<h1>${this.calendar.view.title}</h1>
|
||||
<div>
|
||||
<ha-button-toggle-group
|
||||
.buttons=${viewToggleButtons}
|
||||
.active=${this._activeView}
|
||||
size="s"
|
||||
no-wrap
|
||||
@value-changed=${this._handleView}
|
||||
></ha-button-toggle-group>
|
||||
${
|
||||
this.addFab &&
|
||||
this._hasMutableCalendars &&
|
||||
this.addFabStyle === "header"
|
||||
? html`<ha-button
|
||||
size="s"
|
||||
class="fab-header"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.components.calendar.event.add"
|
||||
)}
|
||||
@click=${this._createEvent}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot=""
|
||||
.path=${mdiPlus}
|
||||
></ha-svg-icon>
|
||||
</ha-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-button-toggle-group
|
||||
.buttons=${viewToggleButtons}
|
||||
.active=${this._activeView}
|
||||
size="s"
|
||||
no-wrap
|
||||
@value-changed=${this._handleView}
|
||||
></ha-button-toggle-group>
|
||||
`
|
||||
: html`
|
||||
<div class="controls">
|
||||
@@ -234,34 +208,13 @@ export class HAFullCalendar extends LitElement {
|
||||
"ui.components.calendar.today"
|
||||
)}</ha-button
|
||||
>
|
||||
<div>
|
||||
<ha-button-toggle-group
|
||||
.buttons=${viewToggleButtons}
|
||||
.active=${this._activeView}
|
||||
size="s"
|
||||
no-wrap
|
||||
@value-changed=${this._handleView}
|
||||
></ha-button-toggle-group>
|
||||
${
|
||||
this.addFab &&
|
||||
this._hasMutableCalendars &&
|
||||
this.addFabStyle === "header"
|
||||
? html`<ha-button
|
||||
size="s"
|
||||
class="fab-header"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.components.calendar.event.add"
|
||||
)}
|
||||
@click=${this._createEvent}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot=""
|
||||
.path=${mdiPlus}
|
||||
></ha-svg-icon>
|
||||
</ha-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-button-toggle-group
|
||||
.buttons=${viewToggleButtons}
|
||||
.active=${this._activeView}
|
||||
size="s"
|
||||
no-wrap
|
||||
@value-changed=${this._handleView}
|
||||
></ha-button-toggle-group>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -272,15 +225,8 @@ export class HAFullCalendar extends LitElement {
|
||||
|
||||
<div id="calendar"></div>
|
||||
${
|
||||
this.addFab &&
|
||||
this._hasMutableCalendars &&
|
||||
this.addFabStyle !== "header"
|
||||
? html`<ha-button
|
||||
size=${this.addFabSize.charAt(0)}
|
||||
class=${classMap({ below: this.addFabStyle === "below" })}
|
||||
slot="fab"
|
||||
@click=${this._createEvent}
|
||||
>
|
||||
this.addFab && this._hasMutableCalendars
|
||||
? html`<ha-button size="l" slot="fab" @click=${this._createEvent}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.calendar.event.add")}
|
||||
</ha-button>`
|
||||
@@ -628,17 +574,6 @@ export class HAFullCalendar extends LitElement {
|
||||
--ha-button-box-shadow: var(--ha-box-shadow-l);
|
||||
}
|
||||
|
||||
ha-button.below[slot="fab"] {
|
||||
position: relative;
|
||||
margin-inline-start: auto;
|
||||
padding-top: var(--ha-space-2);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
--ha-button-box-shadow: none;
|
||||
}
|
||||
|
||||
#calendar {
|
||||
flex-grow: 1;
|
||||
background-color: var(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import "@home-assistant/webawesome/dist/components/tree-item/tree-item";
|
||||
import type WaTreeItem from "@home-assistant/webawesome/dist/components/tree-item/tree-item";
|
||||
import "@home-assistant/webawesome/dist/components/tree/tree";
|
||||
import type { WaSelectionChangeEvent } from "@home-assistant/webawesome/dist/events/selection-change";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
@@ -361,7 +360,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
? html`<ha-list-base>${floorAreas}</ha-list-base>`
|
||||
: html`<wa-tree
|
||||
@wa-selection-change=${this._handleSelectionChange}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
>${floorAreas}</wa-tree
|
||||
>`
|
||||
}`
|
||||
@@ -1446,26 +1444,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
this._toggleItem(targetId, false);
|
||||
}
|
||||
|
||||
private _handleDoubleClick(ev: MouseEvent) {
|
||||
// the expand button and non-selectable items already toggle on single click
|
||||
if (
|
||||
ev
|
||||
.composedPath()
|
||||
.some((el) => (el as HTMLElement).classList?.contains("expand-button"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const item = (ev.target as HTMLElement).closest<
|
||||
WaTreeItem & { target: string }
|
||||
>("wa-tree-item");
|
||||
if (!item || item.isLeaf || item.preventSelection) {
|
||||
return;
|
||||
}
|
||||
// avoid leaving the label text selected by the double click
|
||||
window.getSelection()?.removeAllRanges();
|
||||
this._toggleItem(item.target, !item.expanded);
|
||||
}
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
|
||||
@@ -896,15 +896,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
return;
|
||||
}
|
||||
|
||||
this.yamlErrors = undefined;
|
||||
resolve(true);
|
||||
},
|
||||
onClose: () => resolve(false),
|
||||
onDiscard: () => {
|
||||
this.yamlErrors = undefined;
|
||||
this._markDirtyStateClean();
|
||||
resolve(true);
|
||||
},
|
||||
onDiscard: () => resolve(true),
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
entityRegistryEntry: this.registryEntry,
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -57,7 +57,7 @@ export class CloudAccountOverview extends LitElement {
|
||||
|
||||
private _renderTopCard(): TemplateResult {
|
||||
return html`
|
||||
<ha-card outlined class="summary-card">
|
||||
<ha-card outlined>
|
||||
<div class="card-content">
|
||||
<div
|
||||
class="thank-you-header"
|
||||
@@ -121,8 +121,8 @@ export class CloudAccountOverview extends LitElement {
|
||||
<p class="muted">
|
||||
${this.hass.localize("ui.panel.config.cloud.account.funding_note")}
|
||||
</p>
|
||||
${this._renderSubscriptionState()}
|
||||
</div>
|
||||
${this._renderSubscriptionState()}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
@@ -132,7 +132,6 @@ export class CloudAccountOverview extends LitElement {
|
||||
case "trial":
|
||||
return html`
|
||||
<ha-alert
|
||||
class="subscription-alert"
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.trial_title"
|
||||
@@ -157,7 +156,6 @@ export class CloudAccountOverview extends LitElement {
|
||||
case "canceled":
|
||||
return html`
|
||||
<ha-alert
|
||||
class="subscription-alert"
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.canceled_title"
|
||||
@@ -183,7 +181,6 @@ export class CloudAccountOverview extends LitElement {
|
||||
case "expired":
|
||||
return html`
|
||||
<ha-alert
|
||||
class="subscription-alert"
|
||||
alert-type="error"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.expired_title"
|
||||
@@ -639,15 +636,11 @@ export class CloudAccountOverview extends LitElement {
|
||||
width: auto;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
/* Prevent the embedded .subscription-alert ha-alert from bleeding outside the card */
|
||||
.summary-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
.subscription-alert {
|
||||
ha-alert {
|
||||
display: block;
|
||||
--ha-alert-padding: var(--ha-space-3);
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
.subscription-alert ha-button[slot="action"] {
|
||||
ha-alert ha-button[slot="action"] {
|
||||
width: max-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
+6
-6
@@ -1,12 +1,12 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "./ha-icon-next";
|
||||
import "./ha-svg-icon";
|
||||
import "./item/ha-list-item-button";
|
||||
import "./list/ha-list-nav";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import "../../../components/list/ha-list-nav";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
@customElement("ha-config-navigation-list")
|
||||
class HaConfigNavigationList extends LitElement {
|
||||
@@ -28,7 +28,7 @@ import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"
|
||||
import "../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../../../components/ha-config-navigation-list";
|
||||
import "../components/ha-config-navigation-list";
|
||||
import "../ha-config-section";
|
||||
import { configSections } from "../config-sections";
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type RegisterChildPanelReady,
|
||||
} from "../../../layouts/panel-ready";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../../../components/ha-config-navigation-list";
|
||||
import "../components/ha-config-navigation-list";
|
||||
|
||||
@customElement("ha-config-navigation")
|
||||
class HaConfigNavigation extends LitElement {
|
||||
|
||||
@@ -201,7 +201,7 @@ export class DialogVacuumSegmentMapping
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._submitting}
|
||||
.disabled=${this._submitting || !this.isDirtyState}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
|
||||
@@ -12,15 +12,6 @@ function getLQIWidth(lqi: number): number {
|
||||
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
|
||||
}
|
||||
|
||||
const RELATIONSHIP_PRIORITY: Record<string, number> = {
|
||||
Parent: 0,
|
||||
Sibling: 1,
|
||||
NoneOfTheAbove: 2,
|
||||
Child: 3,
|
||||
PreviousChild: 5,
|
||||
};
|
||||
const UNKNOWN_RELATIONSHIP_PRIORITY = 4;
|
||||
|
||||
export function createZHANetworkChartData(
|
||||
devices: ZHADevice[],
|
||||
hass: HomeAssistant,
|
||||
@@ -168,78 +159,38 @@ export function createZHANetworkChartData(
|
||||
existingLinks.push(link);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// For every device whose routing table was empty (so it got no link
|
||||
// above), independently compute its preferred fallback neighbor before
|
||||
// creating any of these links. Doing this device-by-device while
|
||||
// creating links (as before) meant whichever device the backend happens
|
||||
// to list first "claims" the connection, and a sibling router listed
|
||||
// later never gets to contribute its own — often better — choice,
|
||||
// splitting the graph into islands depending on backend device order.
|
||||
const fallbackTargets = new Map<string, { ieee: string; lqi: string }>();
|
||||
devices.forEach((device) => {
|
||||
const hasLink = links.some(
|
||||
(link) => link.source === device.ieee || link.target === device.ieee
|
||||
);
|
||||
if (hasLink) {
|
||||
return;
|
||||
}
|
||||
const neighbors: { ieee: string; lqi: string; relationship?: string }[] =
|
||||
device.neighbors ?? [];
|
||||
if (neighbors.length === 0) {
|
||||
// If there are no neighbors, look for links from other devices
|
||||
devices.forEach((d) => {
|
||||
if (d.neighbors && d.neighbors.length > 0) {
|
||||
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
|
||||
if (neighbor) {
|
||||
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
|
||||
} else if (existingLinks.length === 0) {
|
||||
// If there are no links, create a link to the closest neighbor
|
||||
const neighbors: { ieee: string; lqi: string }[] = device.neighbors ?? [];
|
||||
if (neighbors.length === 0) {
|
||||
// If there are no neighbors, look for links from other devices
|
||||
devices.forEach((d) => {
|
||||
if (d.neighbors && d.neighbors.length > 0) {
|
||||
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
|
||||
if (neighbor) {
|
||||
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
const closestNeighbor = neighbors.sort(
|
||||
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
|
||||
)[0];
|
||||
if (closestNeighbor) {
|
||||
links.push({
|
||||
source: device.ieee,
|
||||
target: closestNeighbor.ieee,
|
||||
value: parseInt(closestNeighbor.lqi),
|
||||
symbolSize: 5,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: style.getPropertyValue("--dark-primary-color"),
|
||||
type: "dotted",
|
||||
},
|
||||
ignoreForceLayout: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Prefer a neighbor the device's own Zigbee stack reports as its
|
||||
// parent: picking by raw LQI alone regularly favors a nearby child end
|
||||
// device (e.g. a plug sitting right next to its router) over the
|
||||
// router's actual uplink, which severs it from the rest of the mesh in
|
||||
// the visualization even though the real network is connected.
|
||||
const closestNeighbor = neighbors.sort((a, b) => {
|
||||
const aPriority =
|
||||
RELATIONSHIP_PRIORITY[a.relationship ?? ""] ??
|
||||
UNKNOWN_RELATIONSHIP_PRIORITY;
|
||||
const bPriority =
|
||||
RELATIONSHIP_PRIORITY[b.relationship ?? ""] ??
|
||||
UNKNOWN_RELATIONSHIP_PRIORITY;
|
||||
const priorityDiff = aPriority - bPriority;
|
||||
return priorityDiff !== 0
|
||||
? priorityDiff
|
||||
: parseInt(b.lqi) - parseInt(a.lqi);
|
||||
})[0];
|
||||
if (closestNeighbor) {
|
||||
fallbackTargets.set(device.ieee, closestNeighbor);
|
||||
}
|
||||
});
|
||||
|
||||
const addedFallbackPairs = new Set<string>();
|
||||
fallbackTargets.forEach((target, ieee) => {
|
||||
const pairKey = [ieee, target.ieee].sort().join("|");
|
||||
if (addedFallbackPairs.has(pairKey)) {
|
||||
return;
|
||||
}
|
||||
addedFallbackPairs.add(pairKey);
|
||||
links.push({
|
||||
source: ieee,
|
||||
target: target.ieee,
|
||||
value: parseInt(target.lqi),
|
||||
symbolSize: 5,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: style.getPropertyValue("--dark-primary-color"),
|
||||
type: "dotted",
|
||||
},
|
||||
ignoreForceLayout: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Now set ignoreForceLayout to false for the best connection of each device
|
||||
|
||||
+3
-112
@@ -1,4 +1,3 @@
|
||||
import { mdiSpiderWeb } from "@mdi/js";
|
||||
import type {
|
||||
CallbackDataParams,
|
||||
TopLevelFormatterParams,
|
||||
@@ -17,7 +16,6 @@ import type {
|
||||
NetworkLink,
|
||||
NetworkNode,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
|
||||
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
|
||||
@@ -27,7 +25,6 @@ import type {
|
||||
ZWaveJSNodeStatus,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import {
|
||||
fetchZwaveNetworkNeighbors,
|
||||
fetchZwaveNetworkStatus,
|
||||
getNodeIdFromDevice,
|
||||
NodeStatus,
|
||||
@@ -36,7 +33,6 @@ import {
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { showToast } from "../../../../../util/toast";
|
||||
|
||||
@customElement("zwave_js-network-visualization")
|
||||
export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
@@ -59,19 +55,11 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _devices: Record<string, DeviceRegistryEntry> = {};
|
||||
|
||||
@state() private _neighbors?: Record<number, number[]>;
|
||||
|
||||
@state() private _showNeighbors = false;
|
||||
|
||||
@state() private _searchFilter = "";
|
||||
|
||||
// Route statistics reference repeaters by device registry ID
|
||||
private _nodeIdsByDeviceId: Record<string, number> = {};
|
||||
|
||||
private _neighborLinks = new Set<string>();
|
||||
|
||||
private _loadingNeighbors = false;
|
||||
|
||||
public hassSubscribe() {
|
||||
const subscriptions: Promise<UnsubscribeFunc>[] = [];
|
||||
const devices: Record<number, DeviceRegistryEntry> = {};
|
||||
@@ -101,32 +89,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
private async _toggleNeighbors() {
|
||||
this._showNeighbors = !this._showNeighbors;
|
||||
if (!this._showNeighbors || this._neighbors || this._loadingNeighbors) {
|
||||
return;
|
||||
}
|
||||
// fetched on demand: reading neighbors turns the radio off briefly
|
||||
this._loadingNeighbors = true;
|
||||
try {
|
||||
this._neighbors = await fetchZwaveNetworkNeighbors(
|
||||
this.hass,
|
||||
this.configEntryId
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
this._showNeighbors = false;
|
||||
showToast(this, {
|
||||
message:
|
||||
(err as { message?: string }).message ??
|
||||
this.hass.localize(
|
||||
"ui.panel.config.zwave_js.visualization.neighbors_error"
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
this._loadingNeighbors = false;
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._fetchNetworkStatus();
|
||||
@@ -154,23 +116,13 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
.searchFilter=${this._searchFilter}
|
||||
.data=${this._getNetworkData(
|
||||
this._nodeStatuses,
|
||||
this._nodeStatistics,
|
||||
this._showNeighbors ? this._neighbors : undefined
|
||||
this._nodeStatistics
|
||||
)}
|
||||
.searchableAttributes=${this._getSearchableAttributes}
|
||||
.tooltipFormatter=${this._tooltipFormatter}
|
||||
@chart-click=${this._handleChartClick}
|
||||
>
|
||||
${!this.narrow ? this._renderInputSearch("search") : nothing}
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class=${this._showNeighbors ? "active" : "inactive"}
|
||||
.path=${mdiSpiderWeb}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.visualization.toggle_neighbors"
|
||||
)}
|
||||
@click=${this._toggleNeighbors}
|
||||
></ha-icon-button>
|
||||
</ha-network-graph>
|
||||
</hass-subpage>
|
||||
`;
|
||||
@@ -232,13 +184,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
|
||||
const targetName =
|
||||
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
|
||||
if (this._neighborLinks.has(`${source}>${target}`)) {
|
||||
return html`${sourceName} ↔ ${targetName}<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.visualization.neighbor"
|
||||
)}</b
|
||||
>`;
|
||||
}
|
||||
// links point away from the controller, so the route belongs to the target
|
||||
const stats =
|
||||
this._nodeStatistics[target] ?? this._nodeStatistics[source];
|
||||
@@ -318,8 +263,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
private _getNetworkData = memoizeOne(
|
||||
(
|
||||
nodeStatuses: Record<number, ZWaveJSNodeStatus>,
|
||||
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>,
|
||||
neighbors: Record<number, number[]> | undefined
|
||||
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>
|
||||
): NetworkData => {
|
||||
const style = getComputedStyle(this);
|
||||
const nodes: NetworkNode[] = [];
|
||||
@@ -476,49 +420,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
});
|
||||
|
||||
// Neighbors are the nodes a node can reach directly. They are symmetric
|
||||
// and carry no signal information, so they fill in the mesh underneath
|
||||
// the measured routes without overriding them.
|
||||
const neighborLinks: NetworkLink[] = [];
|
||||
const neighborKeys = new Set<string>();
|
||||
Object.entries(neighbors ?? {}).forEach(([nodeId, neighborIds]) => {
|
||||
neighborIds.forEach((neighborId) => {
|
||||
const target = String(neighborId);
|
||||
if (!nodeStatuses[neighborId] || target === nodeId) {
|
||||
return;
|
||||
}
|
||||
const [a, b] = [nodeId, target].sort();
|
||||
const key = `${a}>${b}`;
|
||||
if (
|
||||
neighborKeys.has(key) ||
|
||||
links.some(
|
||||
(link) =>
|
||||
(link.source === nodeId && link.target === target) ||
|
||||
(link.source === target && link.target === nodeId)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
neighborKeys.add(key);
|
||||
neighborLinks.push({
|
||||
source: a,
|
||||
target: b,
|
||||
// equal values in both directions render the link without an arrow
|
||||
value: 1,
|
||||
reverseValue: 1,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: style.getPropertyValue("--disabled-color"),
|
||||
type: "dashed",
|
||||
},
|
||||
// neighbors are plentiful, let the routes shape the layout
|
||||
ignoreForceLayout: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
this._neighborLinks = neighborKeys;
|
||||
|
||||
return { nodes, links: [...neighborLinks, ...links], categories };
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -558,17 +460,6 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
ha-input-search {
|
||||
flex: 1;
|
||||
}
|
||||
/* ha-chart-base can't style re-slotted buttons, so mirror its look */
|
||||
ha-icon-button[slot="button"] {
|
||||
background: var(--card-background-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
--ha-icon-button-size: 32px;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
ha-icon-button[slot="button"].inactive {
|
||||
color: var(--state-inactive-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1126,24 +1126,20 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
}
|
||||
|
||||
private async _confirmUnsavedChanged(): Promise<boolean> {
|
||||
if (!this.isDirtyState) {
|
||||
return true;
|
||||
if (this.isDirtyState) {
|
||||
return showConfirmationDialog(this, {
|
||||
title: this.hass!.localize(
|
||||
"ui.panel.config.scene.editor.unsaved_confirm_title"
|
||||
),
|
||||
text: this.hass!.localize(
|
||||
"ui.panel.config.scene.editor.unsaved_confirm_text"
|
||||
),
|
||||
confirmText: this.hass!.localize("ui.common.leave"),
|
||||
dismissText: this.hass!.localize("ui.common.stay"),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
title: this.hass!.localize(
|
||||
"ui.panel.config.scene.editor.unsaved_confirm_title"
|
||||
),
|
||||
text: this.hass!.localize(
|
||||
"ui.panel.config.scene.editor.unsaved_confirm_text"
|
||||
),
|
||||
confirmText: this.hass!.localize("ui.common.leave"),
|
||||
dismissText: this.hass!.localize("ui.common.stay"),
|
||||
destructive: true,
|
||||
});
|
||||
if (confirmed) {
|
||||
this._markDirtyStateClean();
|
||||
}
|
||||
return confirmed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _duplicate() {
|
||||
|
||||
@@ -804,15 +804,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
return;
|
||||
}
|
||||
|
||||
this.yamlErrors = undefined;
|
||||
resolve(true);
|
||||
},
|
||||
onClose: () => resolve(false),
|
||||
onDiscard: () => {
|
||||
this.yamlErrors = undefined;
|
||||
this._markDirtyStateClean();
|
||||
resolve(true);
|
||||
},
|
||||
onDiscard: () => resolve(true),
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
entityRegistryEntry: this.registryEntry,
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -52,11 +52,11 @@ import type {
|
||||
} from "../../../../data/recorder";
|
||||
import {
|
||||
clearStatistics,
|
||||
getStatisticIds,
|
||||
StatisticMeanType,
|
||||
updateStatisticsIssues,
|
||||
validateStatistics,
|
||||
} from "../../../../data/recorder";
|
||||
import { getStatisticIds } from "../../../../data/recorder_statistic_ids";
|
||||
import {
|
||||
apiContext,
|
||||
internationalizationContext,
|
||||
|
||||
@@ -395,11 +395,9 @@ export class VoiceAssistantsExpose extends LitElement {
|
||||
aliases: entry?.aliases || [],
|
||||
};
|
||||
}
|
||||
if (result[entityId]) {
|
||||
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
|
||||
result[entityId].assistants
|
||||
);
|
||||
}
|
||||
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
|
||||
result[entityId].assistants
|
||||
);
|
||||
});
|
||||
}
|
||||
return Object.values(result);
|
||||
|
||||
@@ -266,6 +266,7 @@ class HaPanelHistory extends LitElement {
|
||||
.startTime=${this._startDate}
|
||||
.endTime=${this._endDate}
|
||||
.narrow=${this.narrow}
|
||||
inside-labels
|
||||
sync-charts
|
||||
>
|
||||
</state-history-charts>
|
||||
@@ -809,7 +810,12 @@ class HaPanelHistory extends LitElement {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden auto;
|
||||
padding: 16px;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
/* Line the charts up with the toolbar when there are no axis labels. */
|
||||
:host([narrow]) .results {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
|
||||
@@ -188,7 +188,7 @@ class HuiHistoryChartCardFeature
|
||||
}
|
||||
if (this._coordinates && !this._coordinates.length) {
|
||||
return html`
|
||||
<div class="container no-history-found">
|
||||
<div class="container">
|
||||
<div class="info">
|
||||
${this.hass!.localize(
|
||||
"ui.components.history_charts.no_history_found"
|
||||
@@ -280,14 +280,6 @@ class HuiHistoryChartCardFeature
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.no-history-found {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
hui-graph-base {
|
||||
width: 100%;
|
||||
--accent-color: var(--feature-color);
|
||||
|
||||
@@ -217,34 +217,22 @@ export class HuiClockCardDigital extends LitElement {
|
||||
"hour minute second"
|
||||
"hour minute am-pm";
|
||||
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-small,
|
||||
var(--ha-font-size-2xl)
|
||||
);
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: 0.8;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.time-title + .time-parts {
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-small,
|
||||
var(--ha-font-size-2xl)
|
||||
);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.time-parts.size-medium {
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-medium,
|
||||
calc(48px * var(--ha-font-size-scale))
|
||||
);
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.time-parts.size-large {
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-large,
|
||||
calc(64px * var(--ha-font-size-scale))
|
||||
);
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.time-parts.size-medium .time-part.second,
|
||||
|
||||
@@ -112,11 +112,7 @@ class HuiEnergyDistrubutionCard
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (!oldHass) {
|
||||
return true;
|
||||
}
|
||||
const oldStates = oldHass.states;
|
||||
const oldStates = changedProps.get("hass").states;
|
||||
if (
|
||||
this._data?.co2SignalEntity &&
|
||||
this.hass.states[this._data.co2SignalEntity] !==
|
||||
@@ -124,20 +120,6 @@ class HuiEnergyDistrubutionCard
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
this._data &&
|
||||
energySourcesByType(this._data.prefs).gas?.some((source) => {
|
||||
const statId = source.stat_energy_from;
|
||||
return (
|
||||
this.hass.entities[statId]?.display_precision !==
|
||||
oldHass.entities[statId]?.display_precision ||
|
||||
this.hass.states[statId]?.attributes.unit_of_measurement !==
|
||||
oldHass.states[statId]?.attributes.unit_of_measurement
|
||||
);
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (this._data && periodIncludesNow(this._data)) {
|
||||
const batteries = energySourcesByType(this._data.prefs).battery;
|
||||
if (
|
||||
@@ -180,21 +162,6 @@ class HuiEnergyDistrubutionCard
|
||||
const hasBattery = types.battery !== undefined;
|
||||
const hasGas = types.gas !== undefined;
|
||||
const hasWater = types.water !== undefined;
|
||||
const gasUnit = this._data.gasUnit;
|
||||
const gasDisplayPrecisions = types.gas
|
||||
?.filter(
|
||||
(source) =>
|
||||
this.hass.states[source.stat_energy_from]?.attributes
|
||||
.unit_of_measurement === gasUnit
|
||||
)
|
||||
.map(
|
||||
(source) =>
|
||||
this.hass.entities[source.stat_energy_from]?.display_precision
|
||||
)
|
||||
.filter((precision): precision is number => precision !== undefined);
|
||||
const gasDisplayPrecision = gasDisplayPrecisions?.length
|
||||
? Math.max(...gasDisplayPrecisions)
|
||||
: undefined;
|
||||
const hasReturnToGrid =
|
||||
types.grid?.some((source) => source.stat_energy_to) ?? false;
|
||||
|
||||
@@ -470,9 +437,7 @@ class HuiEnergyDistrubutionCard
|
||||
${formatConsumptionShort(
|
||||
this.hass,
|
||||
gasUsage,
|
||||
this._data.gasUnit,
|
||||
undefined,
|
||||
gasDisplayPrecision
|
||||
this._data.gasUnit
|
||||
)}
|
||||
</div>
|
||||
<svg width="80" height="30">
|
||||
|
||||
@@ -11,7 +11,6 @@ import "../../../../components/chart/ha-chart-base";
|
||||
import "../../../../components/ha-card";
|
||||
import type { EnergyData } from "../../../../data/energy";
|
||||
import {
|
||||
energySourcesByType,
|
||||
getEnergyDataCollection,
|
||||
validateEnergyCollectionKey,
|
||||
} from "../../../../data/energy";
|
||||
@@ -67,8 +66,6 @@ export class HuiEnergyGasGraphCard
|
||||
|
||||
@state() private _total?: number;
|
||||
|
||||
private _energyData?: EnergyData;
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["_config"];
|
||||
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
@@ -91,68 +88,11 @@ export class HuiEnergyGasGraphCard
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
if (
|
||||
return (
|
||||
hasConfigChanged(this, changedProps) ||
|
||||
changedProps.size > 1 ||
|
||||
!changedProps.has("hass")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (!oldHass) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
this._energyData &&
|
||||
energySourcesByType(this._energyData.prefs).gas?.some((source) => {
|
||||
const statId = source.stat_energy_from;
|
||||
return (
|
||||
this.hass.entities[statId]?.display_precision !==
|
||||
oldHass.entities[statId]?.display_precision ||
|
||||
this.hass.states[statId]?.attributes.unit_of_measurement !==
|
||||
oldHass.states[statId]?.attributes.unit_of_measurement
|
||||
);
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private get _displayPrecision(): number | undefined {
|
||||
if (!this._energyData) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const gasDisplayPrecisions = energySourcesByType(this._energyData.prefs)
|
||||
.gas?.filter(
|
||||
(source) =>
|
||||
this.hass.states[source.stat_energy_from]?.attributes
|
||||
.unit_of_measurement === this._unit
|
||||
)
|
||||
.map(
|
||||
(source) =>
|
||||
this.hass.entities[source.stat_energy_from]?.display_precision
|
||||
)
|
||||
.filter((precision): precision is number => precision !== undefined);
|
||||
|
||||
return gasDisplayPrecisions?.length
|
||||
? Math.max(...gasDisplayPrecisions)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private get _gasFormatOptions(): Intl.NumberFormatOptions | undefined {
|
||||
const displayPrecision = this._displayPrecision;
|
||||
|
||||
return displayPrecision !== undefined
|
||||
? {
|
||||
minimumFractionDigits: displayPrecision,
|
||||
maximumFractionDigits: displayPrecision,
|
||||
}
|
||||
: undefined;
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -171,11 +111,7 @@ export class HuiEnergyGasGraphCard
|
||||
? html`<hui-energy-graph-chip
|
||||
.tooltip=${this._formatTotal(this._total)}
|
||||
>
|
||||
${formatNumber(
|
||||
this._total,
|
||||
this.hass.locale,
|
||||
this._gasFormatOptions
|
||||
)}
|
||||
${formatNumber(this._total, this.hass.locale)}
|
||||
${this._unit}
|
||||
</hui-energy-graph-chip>`
|
||||
: nothing
|
||||
@@ -199,7 +135,7 @@ export class HuiEnergyGasGraphCard
|
||||
this._unit,
|
||||
this._compareStart,
|
||||
this._compareEnd,
|
||||
this._displayPrecision ?? this._yAxisFractionDigits
|
||||
this._yAxisFractionDigits
|
||||
)}
|
||||
chart-type="bar"
|
||||
></ha-chart-base>
|
||||
@@ -226,10 +162,7 @@ export class HuiEnergyGasGraphCard
|
||||
private _formatTotal = (total: number) =>
|
||||
this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_gas_graph.total_consumed",
|
||||
{
|
||||
num: formatNumber(total, this.hass.locale, this._gasFormatOptions),
|
||||
unit: this._unit,
|
||||
}
|
||||
{ num: formatNumber(total, this.hass.locale), unit: this._unit }
|
||||
);
|
||||
|
||||
private _createOptions = memoizeOne(
|
||||
@@ -258,8 +191,6 @@ export class HuiEnergyGasGraphCard
|
||||
);
|
||||
|
||||
private async _getStatistics(energyData: EnergyData): Promise<void> {
|
||||
this._energyData = energyData;
|
||||
|
||||
const result = generateEnergyGasGraphData({
|
||||
hass: this.hass,
|
||||
energyData,
|
||||
|
||||
@@ -87,35 +87,11 @@ export class HuiEnergySourcesTableCard
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
if (
|
||||
return (
|
||||
hasConfigChanged(this, changedProps) ||
|
||||
changedProps.size > 1 ||
|
||||
!changedProps.has("hass")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (!oldHass) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
this._data &&
|
||||
energySourcesByType(this._data.prefs).gas?.some((source) => {
|
||||
const statId = source.stat_energy_from;
|
||||
return (
|
||||
this.hass.entities[statId]?.display_precision !==
|
||||
oldHass.entities[statId]?.display_precision ||
|
||||
this.hass.states[statId]?.attributes.unit_of_measurement !==
|
||||
oldHass.states[statId]?.attributes.unit_of_measurement
|
||||
);
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
);
|
||||
}
|
||||
|
||||
protected _renderRow(
|
||||
@@ -132,20 +108,6 @@ export class HuiEnergySourcesTableCard
|
||||
compare: boolean,
|
||||
name?: string
|
||||
) {
|
||||
const displayPrecision =
|
||||
type === "gas" &&
|
||||
this.hass.states[statId]?.attributes.unit_of_measurement === energyUnit
|
||||
? this.hass.entities[statId]?.display_precision
|
||||
: undefined;
|
||||
|
||||
const formatOptions =
|
||||
displayPrecision !== undefined
|
||||
? {
|
||||
minimumFractionDigits: displayPrecision,
|
||||
maximumFractionDigits: displayPrecision,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return html`<tr
|
||||
class="mdc-data-table__row ${classMap({
|
||||
clickable: !isExternalStatistic(statId),
|
||||
@@ -189,8 +151,7 @@ export class HuiEnergySourcesTableCard
|
||||
${
|
||||
compare
|
||||
? html`<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
|
||||
${formatNumber(compareEnergy, this.hass.locale, formatOptions)}
|
||||
${energyUnit}
|
||||
${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}
|
||||
</td>
|
||||
${
|
||||
showCosts
|
||||
@@ -211,7 +172,7 @@ export class HuiEnergySourcesTableCard
|
||||
: ""
|
||||
}
|
||||
<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
|
||||
${formatNumber(energy, this.hass.locale, formatOptions)} ${energyUnit}
|
||||
${formatNumber(energy, this.hass.locale)} ${energyUnit}
|
||||
</td>
|
||||
${
|
||||
showCosts
|
||||
@@ -242,8 +203,7 @@ export class HuiEnergySourcesTableCard
|
||||
showCosts: boolean,
|
||||
compare: boolean,
|
||||
bulletColor?: { border: string; background: string },
|
||||
isFinalTotal?: boolean,
|
||||
formatOptions?: Intl.NumberFormatOptions
|
||||
isFinalTotal?: boolean
|
||||
) {
|
||||
return html` <tr
|
||||
class="mdc-data-table__row ${bulletColor && !isFinalTotal ? "" : "total"}"
|
||||
@@ -268,11 +228,7 @@ export class HuiEnergySourcesTableCard
|
||||
${
|
||||
compareEnergy === null
|
||||
? ""
|
||||
: `${formatNumber(
|
||||
compareEnergy,
|
||||
this.hass.locale,
|
||||
formatOptions
|
||||
)} ${energyUnit}`
|
||||
: `${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}`
|
||||
}
|
||||
</td>
|
||||
${
|
||||
@@ -297,11 +253,7 @@ export class HuiEnergySourcesTableCard
|
||||
${
|
||||
energy === null
|
||||
? ""
|
||||
: `${formatNumber(
|
||||
energy,
|
||||
this.hass.locale,
|
||||
formatOptions
|
||||
)} ${energyUnit}`
|
||||
: `${formatNumber(energy, this.hass.locale)} ${energyUnit}`
|
||||
}
|
||||
</td>
|
||||
${
|
||||
@@ -406,30 +358,6 @@ export class HuiEnergySourcesTableCard
|
||||
water: this._data.waterUnit,
|
||||
};
|
||||
|
||||
const gasDisplayPrecisions = types.gas
|
||||
?.filter(
|
||||
(source) =>
|
||||
this.hass.states[source.stat_energy_from]?.attributes
|
||||
.unit_of_measurement === units.gas
|
||||
)
|
||||
.map(
|
||||
(source) =>
|
||||
this.hass.entities[source.stat_energy_from]?.display_precision
|
||||
)
|
||||
.filter((precision): precision is number => precision !== undefined);
|
||||
|
||||
const gasDisplayPrecision = gasDisplayPrecisions?.length
|
||||
? Math.max(...gasDisplayPrecisions)
|
||||
: undefined;
|
||||
|
||||
const gasFormatOptions =
|
||||
gasDisplayPrecision !== undefined
|
||||
? {
|
||||
minimumFractionDigits: gasDisplayPrecision,
|
||||
maximumFractionDigits: gasDisplayPrecision,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const compare = this._data.statsCompare !== undefined;
|
||||
|
||||
const _extractStatData = (
|
||||
@@ -553,9 +481,7 @@ export class HuiEnergySourcesTableCard
|
||||
0
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
false,
|
||||
type === "gas" ? gasFormatOptions : undefined
|
||||
: undefined
|
||||
)
|
||||
: ""
|
||||
}`;
|
||||
|
||||
@@ -222,9 +222,6 @@ export class HuiCalendarCard
|
||||
"has-title": !!this._config.title,
|
||||
loading: loading,
|
||||
})}
|
||||
?add-fab=${this._config.show_add_event}
|
||||
add-fab-style=${this._config.show_add_event ? (this._config.add_event_style ?? "below") : nothing}
|
||||
add-fab-size=${this._config.show_add_event && this._config.add_event_style !== "header" ? (this._config.add_event_size ?? "small") : nothing}
|
||||
.narrow=${this._narrow}
|
||||
.events=${this._events}
|
||||
.calendars=${this._calendars}
|
||||
@@ -373,7 +370,11 @@ export class HuiCalendarCard
|
||||
}
|
||||
|
||||
private _measureCard() {
|
||||
this._narrow = this.offsetWidth < 870;
|
||||
const card = this.shadowRoot!.querySelector("ha-card");
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
this._narrow = card.offsetWidth < 870;
|
||||
}
|
||||
|
||||
private async _attachObserver(): Promise<void> {
|
||||
@@ -382,7 +383,12 @@ export class HuiCalendarCard
|
||||
debounce(() => this._measureCard(), 250, false)
|
||||
);
|
||||
}
|
||||
this._resizeObserver.observe(this);
|
||||
const card = this.shadowRoot!.querySelector("ha-card");
|
||||
// If we show an error or warning there is no ha-card
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
this._resizeObserver.observe(card);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -408,9 +414,9 @@ export class HuiCalendarCard
|
||||
|
||||
ha-full-calendar {
|
||||
--calendar-height: 400px;
|
||||
display: flex;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: var(--calendar-height);
|
||||
min-height: var(--calendar-height);
|
||||
}
|
||||
|
||||
@@ -425,14 +431,9 @@ export class HuiCalendarCard
|
||||
|
||||
ha-full-calendar.is-grid.has-title,
|
||||
ha-full-calendar.is-panel.has-title {
|
||||
--header-height: calc(
|
||||
var(--ha-card-header-font-size, var(--ha-font-size-2xl)) *
|
||||
var(--ha-line-height-condensed) + 16px
|
||||
);
|
||||
--calendar-height: calc(
|
||||
100% - var(--ha-card-header-font-size, var(--ha-font-size-2xl)) - 22px
|
||||
);
|
||||
height: calc(100% - var(--header-height));
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -52,9 +52,6 @@ export interface CalendarCardConfig extends LovelaceCardConfig {
|
||||
initial_view?: FullCalendarView;
|
||||
title?: string;
|
||||
theme?: string;
|
||||
show_add_event?: boolean;
|
||||
add_event_style?: "below" | "on_top" | "header";
|
||||
add_event_size?: "small" | "medium" | "large";
|
||||
}
|
||||
|
||||
export interface ConditionalCardConfig extends LovelaceCardConfig {
|
||||
|
||||
@@ -10,16 +10,12 @@ import {
|
||||
optional,
|
||||
string,
|
||||
union,
|
||||
literal,
|
||||
} from "superstruct";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/entity/ha-entities-picker";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import type { SchemaUnion } from "../../../../components/ha-form/types";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { CalendarCardConfig } from "../../cards/types";
|
||||
import type { LovelaceCardEditor } from "../../types";
|
||||
@@ -31,13 +27,6 @@ const cardConfigStruct = assign(
|
||||
title: optional(union([string(), boolean()])),
|
||||
initial_view: optional(string()),
|
||||
theme: optional(string()),
|
||||
show_add_event: optional(boolean()),
|
||||
add_event_style: optional(
|
||||
union([literal("below"), literal("on_top"), literal("header")])
|
||||
),
|
||||
add_event_size: optional(
|
||||
union([literal("small"), literal("medium"), literal("large")])
|
||||
),
|
||||
entities: array(string()),
|
||||
})
|
||||
);
|
||||
@@ -83,83 +72,7 @@ export class HuiCalendarCardEditor
|
||||
],
|
||||
},
|
||||
{ name: "theme", required: false, selector: { theme: {} } },
|
||||
{ name: "show_add_event", required: false, selector: { boolean: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "add_event_style",
|
||||
default: "below",
|
||||
visible: { field: "show_add_event", operator: "eq", value: true },
|
||||
required: false,
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{
|
||||
value: "below",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.style.below"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "on_top",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.style.on_top"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "header",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.style.header"
|
||||
),
|
||||
},
|
||||
],
|
||||
mode: "dropdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add_event_size",
|
||||
default: "small",
|
||||
visible: [
|
||||
{ field: "show_add_event", operator: "eq", value: true },
|
||||
{
|
||||
field: "add_event_style",
|
||||
operator: "not_eq",
|
||||
value: "header",
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
selector: {
|
||||
select: {
|
||||
options: [
|
||||
{
|
||||
value: "small",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.size.s"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "medium",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.size.m"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "large",
|
||||
label: localize(
|
||||
"ui.panel.lovelace.editor.card.calendar.add_event.size.l"
|
||||
),
|
||||
},
|
||||
],
|
||||
mode: "dropdown",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
] as const
|
||||
);
|
||||
|
||||
protected render() {
|
||||
|
||||
@@ -165,7 +165,7 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _currentBackgroundImage(): string | undefined {
|
||||
const background = this._backgroundData(this.config);
|
||||
const background = this._backgroundData(this._config);
|
||||
return typeof background.image === "object"
|
||||
? background.image.media_content_id
|
||||
: background.image;
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type {
|
||||
PictureEntityCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../cards/types";
|
||||
|
||||
export const computeFavoriteCardConfig = (
|
||||
entityId: string
|
||||
): LovelaceCardConfig => {
|
||||
// A camera tile would only show a thumbnail, so give the picture the room.
|
||||
if (computeDomain(entityId) === "camera") {
|
||||
return {
|
||||
type: "picture-entity",
|
||||
entity: entityId,
|
||||
show_name: false,
|
||||
show_state: false,
|
||||
grid_options: {
|
||||
columns: 6,
|
||||
rows: 2,
|
||||
},
|
||||
} satisfies PictureEntityCardConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
// Favorites come from all over the home, so name the area.
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
} satisfies TileCardConfig;
|
||||
};
|
||||
@@ -34,7 +34,6 @@ import type {
|
||||
TileCardConfig,
|
||||
UpdatesCardConfig,
|
||||
} from "../../cards/types";
|
||||
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
|
||||
import {
|
||||
LARGE_SCREEN_CONDITION,
|
||||
SMALL_SCREEN_CONDITION,
|
||||
@@ -272,7 +271,15 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
|
||||
column_span: maxColumns,
|
||||
cards: [
|
||||
favoritesHeadingCard,
|
||||
...favoriteEntities.map(computeFavoriteCardConfig),
|
||||
...favoriteEntities.map(
|
||||
(entityId) =>
|
||||
({
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
}) satisfies TileCardConfig
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+10
-6
@@ -4,9 +4,8 @@ import { isComponentLoaded } from "../../../../common/config/is_component_loaded
|
||||
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
|
||||
import { getCommonControlsUsagePrediction } from "../../../../data/usage_prediction";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { HeadingCardConfig } from "../../cards/types";
|
||||
import type { HeadingCardConfig, TileCardConfig } from "../../cards/types";
|
||||
import type { Condition } from "../../common/validate-condition";
|
||||
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
|
||||
import type { LovelaceStrategyDependency } from "../types";
|
||||
|
||||
const DEFAULT_LIMIT = 8;
|
||||
@@ -26,6 +25,13 @@ export interface CommonControlsSectionStrategyConfig {
|
||||
title_visibilty?: Condition[];
|
||||
}
|
||||
|
||||
const toTileCard = (entity: string): TileCardConfig => ({
|
||||
type: "tile",
|
||||
entity,
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
});
|
||||
|
||||
@customElement("common-controls-section-strategy")
|
||||
export class CommonControlsSectionStrategy extends ReactiveElement {
|
||||
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
|
||||
@@ -57,9 +63,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
|
||||
|
||||
// Pinned entities already fill the section, skip the prediction call.
|
||||
if (includedEntities.length >= limit) {
|
||||
section.cards!.push(
|
||||
...includedEntities.slice(0, limit).map(computeFavoriteCardConfig)
|
||||
);
|
||||
section.cards!.push(...includedEntities.slice(0, limit).map(toTileCard));
|
||||
return section;
|
||||
}
|
||||
|
||||
@@ -106,7 +110,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
|
||||
return section;
|
||||
}
|
||||
|
||||
section.cards!.push(...entities.map(computeFavoriteCardConfig));
|
||||
section.cards!.push(...entities.map(toTileCard));
|
||||
return section;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ import type { HomeAssistant } from "../../../types";
|
||||
import type { TileCardConfig } from "../../lovelace/cards/types";
|
||||
import { BINARY_STATE_ON } from "../../../common/const";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { getDeviceEntityDisplayLookup } from "../../../data/device/device_registry";
|
||||
import { findBatteryChargingEntity } from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
type EntityRegistryDisplayEntry,
|
||||
findBatteryChargingEntity,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
|
||||
export interface MaintenanceViewStrategyConfig {
|
||||
type: "maintenance";
|
||||
@@ -35,42 +37,44 @@ export const maintenanceEntityFilters: EntityFilter[] = [
|
||||
|
||||
const LOW_BATTERY_THRESHOLD = 20;
|
||||
|
||||
const _deviceEntityLookup = memoizeOne((entities: HomeAssistant["entities"]) =>
|
||||
getDeviceEntityDisplayLookup(Object.values(entities))
|
||||
const _deviceEntities = memoizeOne(
|
||||
(
|
||||
deviceId: string,
|
||||
entities: HomeAssistant["entities"]
|
||||
): EntityRegistryDisplayEntry[] => {
|
||||
const entries = Object.values(entities);
|
||||
return entries.filter((entity) => entity.device_id === deviceId);
|
||||
}
|
||||
);
|
||||
|
||||
export const filterLowBatteryEntities = (
|
||||
hass: HomeAssistant,
|
||||
entityIds: string[]
|
||||
): string[] => {
|
||||
return entityIds.filter((entityId) => {
|
||||
): string[] =>
|
||||
entityIds.filter((entityId) => {
|
||||
const state = hass.states[entityId]?.state ?? "";
|
||||
|
||||
if (computeDomain(entityId) === "binary_sensor") {
|
||||
return state === BINARY_STATE_ON;
|
||||
}
|
||||
|
||||
const stateValue = parseFloat(state);
|
||||
if (isNaN(stateValue) || stateValue > LOW_BATTERY_THRESHOLD) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deviceId = hass.entities[entityId]?.device_id;
|
||||
if (!deviceId) {
|
||||
return true;
|
||||
}
|
||||
const entities = deviceId ? _deviceEntities(deviceId, hass.entities) : [];
|
||||
|
||||
const batteryChargingEntity = findBatteryChargingEntity(
|
||||
hass.states,
|
||||
_deviceEntityLookup(hass.entities)[deviceId] ?? []
|
||||
entities
|
||||
);
|
||||
const batteryCharging = batteryChargingEntity
|
||||
? hass.states[batteryChargingEntity.entity_id]
|
||||
? hass.states[batteryChargingEntity?.entity_id]
|
||||
: undefined;
|
||||
|
||||
return batteryCharging?.state !== "on";
|
||||
if (batteryCharging && batteryCharging.state === "on") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stateValue = parseFloat(state);
|
||||
return !isNaN(stateValue) && stateValue <= LOW_BATTERY_THRESHOLD;
|
||||
});
|
||||
};
|
||||
|
||||
export const filterUnavailableBatteryEntities = (
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -322,15 +322,6 @@ export const getMyRedirects = (): Redirects => ({
|
||||
profile_security: {
|
||||
redirect: "/profile/security",
|
||||
},
|
||||
profile_preferences: {
|
||||
redirect: "/profile/preferences",
|
||||
},
|
||||
profile_localization: {
|
||||
redirect: "/profile/localization",
|
||||
},
|
||||
profile_browser: {
|
||||
redirect: "/profile/browser",
|
||||
},
|
||||
profile: {
|
||||
redirect: "/profile",
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@ class HaPanelNotFound extends LitElement {
|
||||
),
|
||||
tap_action: {
|
||||
action: "navigate",
|
||||
navigation_path: "/profile/dashboard",
|
||||
navigation_path: "/profile/general",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
import { mdiAccount, mdiLock } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import type { RouterOptions } from "../../layouts/hass-router-page";
|
||||
import { HassRouterPage } from "../../layouts/hass-router-page";
|
||||
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
|
||||
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export const profileSections: PageNavigation[] = [
|
||||
{
|
||||
path: "/profile/general",
|
||||
translationKey: "ui.panel.profile.tabs.general",
|
||||
iconPath: mdiAccount,
|
||||
},
|
||||
{
|
||||
path: "/profile/security",
|
||||
translationKey: "ui.panel.profile.tabs.security",
|
||||
iconPath: mdiLock,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("ha-panel-profile")
|
||||
class HaPanelProfile extends HassRouterPage {
|
||||
class HaPanelProfile extends SubscribeMixin(HassRouterPage) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected routerOptions: RouterOptions = {
|
||||
defaultPage: "dashboard",
|
||||
defaultPage: "general",
|
||||
routes: {
|
||||
dashboard: {
|
||||
tag: "ha-profile-dashboard",
|
||||
load: () => import("./ha-profile-dashboard"),
|
||||
},
|
||||
preferences: {
|
||||
tag: "ha-profile-section-preferences",
|
||||
load: () => import("./ha-profile-section-preferences"),
|
||||
},
|
||||
localization: {
|
||||
tag: "ha-profile-section-localization",
|
||||
load: () => import("./ha-profile-section-localization"),
|
||||
},
|
||||
browser: {
|
||||
tag: "ha-profile-section-browser",
|
||||
load: () => import("./ha-profile-section-browser"),
|
||||
general: {
|
||||
tag: "ha-profile-section-general",
|
||||
load: () => import("./ha-profile-section-general"),
|
||||
},
|
||||
security: {
|
||||
tag: "ha-profile-section-security",
|
||||
|
||||
@@ -81,7 +81,6 @@ export class HaPickThemeRow extends SubscribeMixin(LitElement) {
|
||||
darkMode: this.hass.localize(
|
||||
"ui.panel.profile.themes.dark_mode.dark"
|
||||
),
|
||||
colors: this.hass.localize("ui.panel.profile.themes.colors"),
|
||||
primaryColor: this.hass.localize(
|
||||
"ui.panel.profile.themes.primary_color"
|
||||
),
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import {
|
||||
mdiAccount,
|
||||
mdiCellphone,
|
||||
mdiEarth,
|
||||
mdiLock,
|
||||
mdiMonitor,
|
||||
mdiTune,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-config-navigation-list";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "./ha-pick-theme-row";
|
||||
import { isExternal } from "../../data/external";
|
||||
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import "../../layouts/hass-subpage";
|
||||
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../types";
|
||||
|
||||
@customElement("ha-profile-dashboard")
|
||||
class HaProfileDashboard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const pages: PageNavigation[] = [
|
||||
{
|
||||
path: "/profile/preferences",
|
||||
name: this.hass.localize("ui.panel.profile.user_preferences_header"),
|
||||
description: this.hass.localize(
|
||||
"ui.panel.profile.user_preferences_description"
|
||||
),
|
||||
iconPath: mdiTune,
|
||||
},
|
||||
{
|
||||
path: "/profile/localization",
|
||||
name: this.hass.localize("ui.panel.profile.localization_header"),
|
||||
description: this.hass.localize(
|
||||
"ui.panel.profile.localization_description"
|
||||
),
|
||||
iconPath: mdiEarth,
|
||||
},
|
||||
{
|
||||
path: "/profile/browser",
|
||||
name: this.hass.localize(
|
||||
isExternal
|
||||
? "ui.panel.profile.mobile_app_settings"
|
||||
: "ui.panel.profile.browser_settings"
|
||||
),
|
||||
description: this.hass.localize("ui.panel.profile.browser_description"),
|
||||
iconPath: isExternal ? mdiCellphone : mdiMonitor,
|
||||
},
|
||||
{
|
||||
path: "/profile/security",
|
||||
name: this.hass.localize("ui.panel.profile.tabs.security"),
|
||||
description: this.hass.localize(
|
||||
"ui.panel.profile.security_description"
|
||||
),
|
||||
iconPath: mdiLock,
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
main-page
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("panel.profile")}
|
||||
>
|
||||
<div class="container">
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<div class="heading">
|
||||
<div class="icon">
|
||||
<ha-svg-icon .path=${mdiAccount}></ha-svg-icon>
|
||||
</div>
|
||||
<div class="details">
|
||||
${this.hass.user!.name}
|
||||
${
|
||||
this.hass.user!.is_owner
|
||||
? html`<br /><small
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.is_owner"
|
||||
)}</small
|
||||
>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<ha-button
|
||||
variant="danger"
|
||||
appearance="plain"
|
||||
@click=${this._handleLogOut}
|
||||
>
|
||||
${this.hass.localize("ui.panel.profile.logout")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card
|
||||
.header=${this.hass.localize("ui.panel.profile.themes.header")}
|
||||
>
|
||||
<ha-pick-theme-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-theme-row>
|
||||
</ha-card>
|
||||
<ha-card outlined>
|
||||
<ha-config-navigation-list
|
||||
.hass=${this.hass}
|
||||
.pages=${pages}
|
||||
has-secondary
|
||||
.label=${this.hass.localize("panel.profile")}
|
||||
></ha-config-navigation-list>
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleLogOut() {
|
||||
showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.profile.logout_title"),
|
||||
text: this.hass.localize("ui.panel.profile.logout_text"),
|
||||
confirmText: this.hass.localize("ui.panel.profile.logout"),
|
||||
confirm: () => fireEvent(this, "hass-logout"),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4)
|
||||
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
ha-card {
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-card[outlined] {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
border-radius: var(--ha-border-radius-2xl);
|
||||
width: var(--ha-space-10);
|
||||
height: var(--ha-space-10);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon::before {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--primary-color);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.icon ha-svg-icon {
|
||||
color: var(--primary-color);
|
||||
width: var(--ha-space-6);
|
||||
height: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.details {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.details small {
|
||||
font-size: var(--ha-font-size-m);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-button {
|
||||
margin-inline-start: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-profile-dashboard": HaProfileDashboard;
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-md-list";
|
||||
import { isExternal } from "../../data/external";
|
||||
import "../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../types";
|
||||
import { isMobileClient } from "../../util/is_mobile";
|
||||
import "./ha-enable-shortcuts-row";
|
||||
import "./ha-force-narrow-row";
|
||||
import "./ha-push-notifications-row";
|
||||
import "./ha-set-suspend-row";
|
||||
import "./ha-set-vibrate-row";
|
||||
|
||||
@customElement("ha-profile-section-browser")
|
||||
class HaProfileSectionBrowser extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/profile"
|
||||
.header=${this.hass.localize(
|
||||
isExternal
|
||||
? "ui.panel.profile.mobile_app_settings"
|
||||
: "ui.panel.profile.browser_settings"
|
||||
)}
|
||||
>
|
||||
<div class="container">
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
isExternal
|
||||
? "ui.panel.profile.mobile_app_settings"
|
||||
: "ui.panel.profile.browser_settings"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${this.hass.localize("ui.panel.profile.client_settings_detail")}
|
||||
</div>
|
||||
<ha-md-list>
|
||||
${
|
||||
this.hass.dockedSidebar !== "auto" || !this.narrow
|
||||
? html`
|
||||
<ha-force-narrow-row
|
||||
.hass=${this.hass}
|
||||
></ha-force-narrow-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
"vibrate" in navigator
|
||||
? html`
|
||||
<ha-set-vibrate-row
|
||||
.hass=${this.hass}
|
||||
></ha-set-vibrate-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
!isExternal &&
|
||||
isComponentLoaded(this.hass.config, "html5.notify")
|
||||
? html`
|
||||
<ha-push-notifications-row
|
||||
.hass=${this.hass}
|
||||
></ha-push-notifications-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
|
||||
${
|
||||
!isMobileClient
|
||||
? html`
|
||||
<ha-enable-shortcuts-row
|
||||
id="shortcuts"
|
||||
.hass=${this.hass}
|
||||
></ha-enable-shortcuts-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-md-list>
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4)
|
||||
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
ha-card {
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-profile-section-browser": HaProfileSectionBrowser;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
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";
|
||||
import "../../components/item/ha-row-item";
|
||||
import { isExternal } from "../../data/external";
|
||||
import type { CoreFrontendUserData } from "../../data/frontend";
|
||||
import { subscribeFrontendUserData } from "../../data/frontend";
|
||||
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { showEditSidebarDialog } from "../../dialogs/sidebar/show-dialog-edit-sidebar";
|
||||
import "../../layouts/hass-tabs-subpage";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../types";
|
||||
import { isMobileClient } from "../../util/is_mobile";
|
||||
import "./ha-enable-shortcuts-row";
|
||||
import "./ha-entity-id-picker-row";
|
||||
import "./ha-force-narrow-row";
|
||||
import { profileSections } from "./ha-panel-profile";
|
||||
import "./ha-pick-dashboard-row";
|
||||
import "./ha-pick-date-format-row";
|
||||
import "./ha-pick-first-weekday-row";
|
||||
import "./ha-pick-language-row";
|
||||
import "./ha-pick-number-format-row";
|
||||
import "./ha-pick-theme-row";
|
||||
import "./ha-pick-time-format-row";
|
||||
import "./ha-pick-time-zone-row";
|
||||
import "./ha-push-notifications-row";
|
||||
import "./ha-set-suspend-row";
|
||||
import "./ha-set-vibrate-row";
|
||||
|
||||
@customElement("ha-profile-section-general")
|
||||
class HaProfileSectionGeneral extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _coreUserData?: CoreFrontendUserData | null;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
private _unsubCoreData?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _getCoreData() {
|
||||
this._unsubCoreData = subscribeFrontendUserData(
|
||||
this.hass.connection,
|
||||
"core",
|
||||
({ value }) => {
|
||||
this._coreUserData = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hass) {
|
||||
this._getCoreData();
|
||||
}
|
||||
|
||||
this._scrollToHash();
|
||||
}
|
||||
|
||||
public firstUpdated() {
|
||||
if (!this._unsubCoreData) {
|
||||
this._getCoreData();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._unsubCoreData) {
|
||||
this._unsubCoreData.then((unsub) => unsub());
|
||||
this._unsubCoreData = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _scrollToHash() {
|
||||
await nextRender();
|
||||
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
const element = this.shadowRoot!.getElementById(hash);
|
||||
element?.scrollIntoView();
|
||||
this._clearHash();
|
||||
}
|
||||
}
|
||||
|
||||
private _clearHash() {
|
||||
replaceCurrentUrl(window.location.pathname);
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-tabs-subpage
|
||||
main-page
|
||||
.hass=${this.hass}
|
||||
.tabs=${profileSections}
|
||||
.route=${this.route}
|
||||
>
|
||||
<div slot="title">${this.hass.localize("panel.profile")}</div>
|
||||
<div class="content">
|
||||
<ha-card .header=${this.hass.user!.name}>
|
||||
<div class="card-content">
|
||||
${this.hass.localize("ui.panel.profile.current_user", {
|
||||
fullName: this.hass.user!.name,
|
||||
})}
|
||||
${
|
||||
this.hass.user!.is_owner
|
||||
? this.hass.localize("ui.panel.profile.is_owner")
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
variant="danger"
|
||||
appearance="plain"
|
||||
@click=${this._handleLogOut}
|
||||
>
|
||||
${this.hass.localize("ui.panel.profile.logout")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.profile.user_preferences_header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${this.hass.localize("ui.panel.profile.user_preferences_detail")}
|
||||
</div>
|
||||
<ha-pick-language-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-language-row>
|
||||
<ha-pick-theme-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-theme-row>
|
||||
<ha-pick-dashboard-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-dashboard-row>
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.header"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.description"
|
||||
)}</span
|
||||
>
|
||||
<ha-button
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._customizeSidebar}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.button"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-row-item>
|
||||
${
|
||||
this.hass.user!.is_admin
|
||||
? html`
|
||||
<ha-entity-id-picker-row
|
||||
.hass=${this.hass}
|
||||
.coreUserData=${this._coreUserData}
|
||||
></ha-entity-id-picker-row>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-card>
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.profile.localization_header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${this.hass.localize("ui.panel.profile.localization_detail")}
|
||||
</div>
|
||||
<ha-pick-time-zone-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-time-zone-row>
|
||||
<ha-pick-number-format-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-number-format-row>
|
||||
<ha-pick-time-format-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-time-format-row>
|
||||
<ha-pick-date-format-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-date-format-row>
|
||||
<ha-pick-first-weekday-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-first-weekday-row>
|
||||
</ha-card>
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
isExternal
|
||||
? "ui.panel.profile.mobile_app_settings"
|
||||
: "ui.panel.profile.browser_settings"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${this.hass.localize("ui.panel.profile.client_settings_detail")}
|
||||
</div>
|
||||
${
|
||||
this.hass.dockedSidebar !== "auto" || !this.narrow
|
||||
? html`
|
||||
<ha-force-narrow-row
|
||||
.hass=${this.hass}
|
||||
></ha-force-narrow-row>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
"vibrate" in navigator
|
||||
? html`
|
||||
<ha-set-vibrate-row .hass=${this.hass}></ha-set-vibrate-row>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
!isExternal && isComponentLoaded(this.hass.config, "html5.notify")
|
||||
? html`
|
||||
<ha-push-notifications-row
|
||||
.hass=${this.hass}
|
||||
></ha-push-notifications-row>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-set-suspend-row .hass=${this.hass}></ha-set-suspend-row>
|
||||
${
|
||||
!isMobileClient
|
||||
? html`
|
||||
<ha-enable-shortcuts-row
|
||||
id="shortcuts"
|
||||
.hass=${this.hass}
|
||||
></ha-enable-shortcuts-row>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-tabs-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _customizeSidebar() {
|
||||
showEditSidebarDialog(this);
|
||||
}
|
||||
|
||||
private _handleLogOut() {
|
||||
showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.profile.logout_title"),
|
||||
text: this.hass.localize("ui.panel.profile.logout_text"),
|
||||
confirmText: this.hass.localize("ui.panel.profile.logout"),
|
||||
confirm: () => fireEvent(this, "hass-logout"),
|
||||
destructive: true,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: block;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.content > * {
|
||||
display: block;
|
||||
margin: 24px 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-profile-section-general": HaProfileSectionGeneral;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../components/ha-card";
|
||||
import "../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../types";
|
||||
import "./ha-pick-date-format-row";
|
||||
import "./ha-pick-first-weekday-row";
|
||||
import "./ha-pick-language-row";
|
||||
import "./ha-pick-number-format-row";
|
||||
import "./ha-pick-time-format-row";
|
||||
import "./ha-pick-time-zone-row";
|
||||
|
||||
@customElement("ha-profile-section-localization")
|
||||
class HaProfileSectionLocalization extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/profile"
|
||||
.header=${this.hass.localize("ui.panel.profile.localization_header")}
|
||||
>
|
||||
<div class="container">
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.profile.localization_header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${this.hass.localize("ui.panel.profile.localization_detail")}
|
||||
</div>
|
||||
<ha-pick-language-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-language-row>
|
||||
<ha-pick-time-zone-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-time-zone-row>
|
||||
<ha-pick-number-format-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-number-format-row>
|
||||
<ha-pick-time-format-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-time-format-row>
|
||||
<ha-pick-date-format-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-date-format-row>
|
||||
<ha-pick-first-weekday-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-first-weekday-row>
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4)
|
||||
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
ha-card {
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-profile-section-localization": HaProfileSectionLocalization;
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-md-list";
|
||||
import "../../components/ha-md-list-item";
|
||||
import type { CoreFrontendUserData } from "../../data/frontend";
|
||||
import { subscribeFrontendUserData } from "../../data/frontend";
|
||||
import { showEditSidebarDialog } from "../../dialogs/sidebar/show-dialog-edit-sidebar";
|
||||
import "../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../types";
|
||||
import "./ha-entity-id-picker-row";
|
||||
import "./ha-pick-dashboard-row";
|
||||
|
||||
@customElement("ha-profile-section-preferences")
|
||||
class HaProfileSectionPreferences extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state() private _coreUserData?: CoreFrontendUserData | null;
|
||||
|
||||
private _unsubCoreData?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _getCoreData() {
|
||||
this._unsubCoreData = subscribeFrontendUserData(
|
||||
this.hass.connection,
|
||||
"core",
|
||||
({ value }) => {
|
||||
this._coreUserData = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hass) {
|
||||
this._getCoreData();
|
||||
}
|
||||
}
|
||||
|
||||
public firstUpdated() {
|
||||
if (!this._unsubCoreData) {
|
||||
this._getCoreData();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._unsubCoreData) {
|
||||
this._unsubCoreData.then((unsub) => unsub());
|
||||
this._unsubCoreData = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/profile"
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.profile.user_preferences_header"
|
||||
)}
|
||||
>
|
||||
<div class="container">
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.profile.user_preferences_header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
${this.hass.localize("ui.panel.profile.user_preferences_detail")}
|
||||
</div>
|
||||
<ha-pick-dashboard-row
|
||||
.narrow=${this.narrow}
|
||||
.hass=${this.hass}
|
||||
></ha-pick-dashboard-row>
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.header"
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.description"
|
||||
)}</span
|
||||
>
|
||||
<ha-button
|
||||
slot="end"
|
||||
appearance="plain"
|
||||
size="s"
|
||||
@click=${this._customizeSidebar}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.customize_sidebar.button"
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-md-list-item>
|
||||
${
|
||||
this.hass.user!.is_admin
|
||||
? html`
|
||||
<ha-entity-id-picker-row
|
||||
.hass=${this.hass}
|
||||
.coreUserData=${this._coreUserData}
|
||||
></ha-entity-id-picker-row>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-md-list>
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _customizeSidebar() {
|
||||
showEditSidebarDialog(this);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4)
|
||||
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
ha-card {
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-profile-section-preferences": HaProfileSectionPreferences;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../layouts/hass-subpage";
|
||||
import "../../layouts/hass-tabs-subpage";
|
||||
import { profileSections } from "./ha-panel-profile";
|
||||
import type { RefreshToken } from "../../data/refresh_token";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../types";
|
||||
@@ -31,12 +32,14 @@ class HaProfileSectionSecurity extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<hass-subpage
|
||||
<hass-tabs-subpage
|
||||
main-page
|
||||
.hass=${this.hass}
|
||||
back-path="/profile"
|
||||
.header=${this.hass.localize("ui.panel.profile.tabs.security")}
|
||||
.tabs=${profileSections}
|
||||
.route=${this.route}
|
||||
>
|
||||
<div class="container">
|
||||
<div slot="title">${this.hass.localize("panel.profile")}</div>
|
||||
<div class="content">
|
||||
${
|
||||
this.hass.user!.credentials.some(
|
||||
(cred) => cred.auth_provider_type === "homeassistant"
|
||||
@@ -67,7 +70,7 @@ class HaProfileSectionSecurity extends LitElement {
|
||||
@hass-refresh-tokens=${this._refreshRefreshTokens}
|
||||
></ha-long-lived-access-tokens-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
</hass-tabs-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -90,15 +93,21 @@ class HaProfileSectionSecurity extends LitElement {
|
||||
-moz-user-select: initial;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: var(--ha-space-2) var(--ha-space-4)
|
||||
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
|
||||
.content {
|
||||
display: block;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: var(--safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
.container > * {
|
||||
.content > * {
|
||||
display: block;
|
||||
margin: 0 auto var(--ha-space-4);
|
||||
max-width: 600px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.promo-advanced {
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -18,9 +18,11 @@ import type {
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LogbookCardConfig } from "../../lovelace/cards/types";
|
||||
import type {
|
||||
LogbookCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../lovelace/cards/types";
|
||||
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
|
||||
import { computeSecurityAlertCardConfig } from "./security-alerts";
|
||||
|
||||
export interface SecurityViewStrategyConfig {
|
||||
@@ -184,7 +186,15 @@ export class SecurityViewStrategy extends ReactiveElement {
|
||||
),
|
||||
heading_style: "title",
|
||||
},
|
||||
...favoriteEntities.map(computeFavoriteCardConfig),
|
||||
...favoriteEntities.map(
|
||||
(entityId) =>
|
||||
({
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
state_content: ["state", "area_name"],
|
||||
show_entity_picture: true,
|
||||
}) satisfies TileCardConfig
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8403,10 +8403,7 @@
|
||||
"status": "Status",
|
||||
"version": "Version",
|
||||
"data_rate": "Data rate",
|
||||
"area": "Area",
|
||||
"neighbor": "In range of each other",
|
||||
"toggle_neighbors": "Toggle neighbor connections. Loading neighbor data temporarily turns off the Z-Wave adapter.",
|
||||
"neighbors_error": "Failed to load neighbor connections"
|
||||
"area": "Area"
|
||||
},
|
||||
"node_status": {
|
||||
"0": "Unknown",
|
||||
@@ -9924,21 +9921,6 @@
|
||||
"dayGridWeek": "Week",
|
||||
"dayGridDay": "Day",
|
||||
"listWeek": "List (7 days)"
|
||||
},
|
||||
"show_add_event": "Show add event button",
|
||||
"add_event_style": "Add event button style",
|
||||
"add_event_size": "Add event button size",
|
||||
"add_event": {
|
||||
"style": {
|
||||
"header": "Header",
|
||||
"below": "Below",
|
||||
"on_top": "On top"
|
||||
},
|
||||
"size": {
|
||||
"s": "Small",
|
||||
"m": "Medium",
|
||||
"l": "Large"
|
||||
}
|
||||
}
|
||||
},
|
||||
"conditional": {
|
||||
@@ -11195,19 +11177,17 @@
|
||||
},
|
||||
"profile": {
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
"security": "Security"
|
||||
},
|
||||
"current_user": "You are currently logged in as {fullName}.",
|
||||
"is_owner": "You are an owner.",
|
||||
"user_preferences_header": "Appearance",
|
||||
"user_preferences_description": "Default dashboard and sidebar",
|
||||
"user_preferences_header": "User preferences",
|
||||
"user_preferences_detail": "The following settings are tied to your account and will persist across all sessions and devices.",
|
||||
"localization_header": "Localization",
|
||||
"localization_description": "Language, time zone, date, time, and number formats",
|
||||
"localization_detail": "These settings are tied to your account and control how dates, times, and numbers are displayed.",
|
||||
"mobile_app_settings": "This mobile app",
|
||||
"browser_settings": "This browser",
|
||||
"browser_description": "Notifications, display, and shortcuts",
|
||||
"security_description": "Password, multi-factor authentication, and access tokens",
|
||||
"mobile_app_settings": "Mobile app settings",
|
||||
"browser_settings": "Browser settings",
|
||||
"client_settings_detail": "The following settings are local to this client only, and may reset to defaults on logout or when local data is cleared.",
|
||||
"logout": "Log out",
|
||||
"logout_title": "Log out?",
|
||||
@@ -11322,7 +11302,6 @@
|
||||
"migrate_success": "Theme settings migrated.",
|
||||
"migrate_failed": "Failed to migrate theme settings.",
|
||||
"theme_mode": "Theme mode",
|
||||
"colors": "Custom colors",
|
||||
"dark_mode": {
|
||||
"auto": "Auto",
|
||||
"light": "Light",
|
||||
@@ -11330,7 +11309,7 @@
|
||||
},
|
||||
"primary_color": "Primary color",
|
||||
"accent_color": "Accent color",
|
||||
"reset": "Reset colors",
|
||||
"reset": "Reset",
|
||||
"use_default": "Use default theme"
|
||||
},
|
||||
"dashboard": {
|
||||
|
||||
Vendored
-8
@@ -13,11 +13,3 @@ declare module "echarts/lib/util/states" {
|
||||
declare module "echarts/lib/chart/sankey/SankeyView" {
|
||||
export { default } from "echarts/types/src/chart/sankey/SankeyView";
|
||||
}
|
||||
|
||||
declare module "echarts/lib/util/number" {
|
||||
export * from "echarts/types/src/util/number";
|
||||
}
|
||||
|
||||
declare module "echarts/lib/scale/helper" {
|
||||
export * from "echarts/types/src/scale/helper";
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { addLatinLabels } from "../../build-scripts/gulp/map-labels.js";
|
||||
|
||||
// The rewrite keys on the exact `text-field` @versatiles/style emits; a bump
|
||||
// that changed it would silently ship local-only names again.
|
||||
|
||||
const layer = (id, layout) => ({ id, type: "symbol", layout });
|
||||
|
||||
const STYLE = {
|
||||
layers: [
|
||||
layer("label-place-city", { "text-field": ["get", "name"] }),
|
||||
layer("label-street-primary", {
|
||||
"symbol-placement": "line",
|
||||
"text-field": ["get", "name"],
|
||||
}),
|
||||
layer("label-motorway-shield", { "text-field": "{ref}" }),
|
||||
{ id: "water", type: "fill" },
|
||||
],
|
||||
};
|
||||
|
||||
// Just enough of the expression language for the expressions built here.
|
||||
const evaluate = (expression, properties) => {
|
||||
if (!Array.isArray(expression)) {
|
||||
return expression;
|
||||
}
|
||||
const [op, ...args] = expression;
|
||||
switch (op) {
|
||||
case "get":
|
||||
return properties[args[0]];
|
||||
case "has":
|
||||
return args[0] in properties;
|
||||
case "!":
|
||||
return !evaluate(args[0], properties);
|
||||
case "<":
|
||||
return evaluate(args[0], properties) < evaluate(args[1], properties);
|
||||
case "concat":
|
||||
return args.map((arg) => evaluate(arg, properties)).join("");
|
||||
case "format":
|
||||
return args
|
||||
.filter((_, i) => i % 2 === 0)
|
||||
.map((arg) => evaluate(arg, properties))
|
||||
.join("");
|
||||
case "case":
|
||||
for (let i = 0; i < args.length - 1; i += 2) {
|
||||
if (evaluate(args[i], properties)) {
|
||||
return evaluate(args[i + 1], properties);
|
||||
}
|
||||
}
|
||||
return evaluate(args[args.length - 1], properties);
|
||||
default:
|
||||
throw new Error(`Unexpected operator ${op}`);
|
||||
}
|
||||
};
|
||||
|
||||
const textField = (style, id) =>
|
||||
style.layers.find((l) => l.id === id).layout["text-field"];
|
||||
|
||||
describe("addLatinLabels", () => {
|
||||
const style = addLatinLabels(STYLE);
|
||||
const city = textField(style, "label-place-city");
|
||||
const street = textField(style, "label-street-primary");
|
||||
|
||||
it("leaves Latin names alone", () => {
|
||||
expect(evaluate(city, { name: "Köln", name_en: "Cologne" })).toBe("Köln");
|
||||
expect(evaluate(city, { name: "1er arrondissement" })).toBe(
|
||||
"1er arrondissement"
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the English name under a non-Latin one", () => {
|
||||
expect(evaluate(city, { name: "Москва", name_en: "Moscow" })).toBe(
|
||||
"Москва\nMoscow"
|
||||
);
|
||||
expect(evaluate(city, { name: "بيروت", name_en: "Beirut" })).toBe(
|
||||
"بيروت\nBeirut"
|
||||
);
|
||||
expect(evaluate(city, { name: "東京", name_en: "Tokyo" })).toBe(
|
||||
"東京\nTokyo"
|
||||
);
|
||||
});
|
||||
|
||||
it("sets the English line smaller", () => {
|
||||
expect(city.at(-1)).toEqual(
|
||||
expect.arrayContaining([["get", "name_en"], { "font-scale": 0.8 }])
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the local name without an English one", () => {
|
||||
expect(evaluate(city, { name: "בני ברק" })).toBe("בני ברק");
|
||||
});
|
||||
|
||||
it("keeps street labels on one line", () => {
|
||||
expect(
|
||||
evaluate(street, { name: "شارع الحمرا", name_en: "Hamra Street" })
|
||||
).toBe("شارع الحمرا (Hamra Street)");
|
||||
});
|
||||
|
||||
it("does not touch other layers", () => {
|
||||
expect(textField(style, "label-motorway-shield")).toBe("{ref}");
|
||||
expect(style.layers[3]).toEqual(STYLE.layers[3]);
|
||||
});
|
||||
});
|
||||
@@ -18,10 +18,6 @@ const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
|
||||
|
||||
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
|
||||
|
||||
const setRTLTextPlugin = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
vi.mock("maplibre-gl", () => ({ setRTLTextPlugin }));
|
||||
|
||||
const STYLE = {
|
||||
version: 8,
|
||||
sources: {},
|
||||
@@ -113,18 +109,6 @@ describe("createBaseLayer", () => {
|
||||
browser.retina = false;
|
||||
});
|
||||
|
||||
it("registers the RTL text plugin once, lazily, from our own host", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
|
||||
expect(setRTLTextPlugin).toHaveBeenCalledOnce();
|
||||
expect(setRTLTextPlugin).toHaveBeenCalledWith(
|
||||
`${location.origin}/static/map/mapbox-gl-rtl-text.js`,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("uses vector tiles when WebGL2 is available", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
NavigateOptions,
|
||||
UnsavedChangesGuard,
|
||||
} from "../../src/common/navigate";
|
||||
import {
|
||||
canGoBack,
|
||||
goBack,
|
||||
navigate,
|
||||
registerUnsavedChangesGuard,
|
||||
unregisterUnsavedChangesGuard,
|
||||
} from "../../src/common/navigate";
|
||||
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", () => ({
|
||||
@@ -63,145 +54,6 @@ describe("navigate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsaved changes guard", () => {
|
||||
const registeredGuards: UnsavedChangesGuard[] = [];
|
||||
|
||||
// Registering through this keeps afterEach able to clean up the module-level
|
||||
// registry, which outlives the test that filled it.
|
||||
const trackGuard = <T extends UnsavedChangesGuard>(guard: T): T => {
|
||||
registerUnsavedChangesGuard(guard);
|
||||
registeredGuards.push(guard);
|
||||
return guard;
|
||||
};
|
||||
|
||||
const registerGuard = (isDirty: boolean, promptResult = true) =>
|
||||
trackGuard({
|
||||
isDirty: vi.fn(() => isDirty),
|
||||
prompt: vi.fn(async () => promptResult),
|
||||
});
|
||||
|
||||
// A guard whose prompt stays open until the test answers it.
|
||||
const trackDeferredGuard = (isDirty: () => boolean) => {
|
||||
let answer!: (value: boolean) => void;
|
||||
const guard = trackGuard({
|
||||
isDirty,
|
||||
prompt: vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
answer = resolve;
|
||||
})
|
||||
),
|
||||
});
|
||||
return { guard, answerPrompt: (value: boolean) => answer(value) };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setEntry("/config");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
|
||||
});
|
||||
|
||||
it("navigates without prompting when no guard is dirty", async () => {
|
||||
const guard = registerGuard(false);
|
||||
|
||||
expect(await navigate("/config/areas")).toBe(true);
|
||||
|
||||
expect(window.location.pathname).toEqual("/config/areas");
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts a dirty guard and navigates when confirmed", async () => {
|
||||
const guard = registerGuard(true, true);
|
||||
|
||||
expect(await navigate("/config/areas")).toBe(true);
|
||||
|
||||
expect(guard.prompt).toHaveBeenCalledOnce();
|
||||
expect(window.location.pathname).toEqual("/config/areas");
|
||||
});
|
||||
|
||||
it("leaves history untouched when the prompt is declined", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("location-changed", listener);
|
||||
|
||||
expect(await navigate("/config/areas")).toBe(false);
|
||||
|
||||
window.removeEventListener("location-changed", listener);
|
||||
expect(guard.prompt).toHaveBeenCalledOnce();
|
||||
expect(window.location.pathname).toEqual("/config");
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not prompt when navigating to the current path", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
|
||||
expect(await navigate("/config")).toBe(true);
|
||||
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a pending prompt once no guard is dirty anymore", async () => {
|
||||
let dirty = true;
|
||||
const { guard, answerPrompt } = trackDeferredGuard(() => dirty);
|
||||
|
||||
const first = navigate("/config/areas");
|
||||
dirty = false;
|
||||
|
||||
expect(await navigate("/config/devices/dashboard")).toBe(true);
|
||||
expect(window.location.pathname).toEqual("/config/devices/dashboard");
|
||||
expect(guard.prompt).toHaveBeenCalledOnce();
|
||||
|
||||
answerPrompt(true);
|
||||
await first;
|
||||
});
|
||||
|
||||
it("drops a navigation superseded while its prompt was open", async () => {
|
||||
let dirty = true;
|
||||
const { answerPrompt } = trackDeferredGuard(() => dirty);
|
||||
|
||||
const superseded = navigate("/config/areas");
|
||||
dirty = false;
|
||||
await navigate("/config/devices/dashboard");
|
||||
|
||||
answerPrompt(true);
|
||||
|
||||
expect(await superseded).toBe(false);
|
||||
expect(window.location.pathname).toEqual("/config/devices/dashboard");
|
||||
});
|
||||
|
||||
it("shares one pending prompt between concurrent navigations", async () => {
|
||||
const { guard, answerPrompt } = trackDeferredGuard(() => true);
|
||||
|
||||
const first = navigate("/config/areas");
|
||||
const second = navigate("/config/devices/dashboard");
|
||||
answerPrompt(true);
|
||||
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(guard.prompt).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stops prompting once the guard is unregistered", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
unregisterUnsavedChangesGuard(guard);
|
||||
|
||||
expect(await navigate("/config/areas")).toBe(true);
|
||||
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not prompt for goBack's fallback navigation", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
|
||||
await goBack("/config/cloud/account");
|
||||
|
||||
expect(window.location.pathname).toEqual("/config/cloud/account");
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("goBack", () => {
|
||||
beforeEach(() => {
|
||||
setEntry("/config/cloud/remote");
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { shareInFlightRequest } from "../../../src/common/util/share-in-flight-request";
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
describe("shareInFlightRequest", () => {
|
||||
it("shares an in-flight request for the same owner and key", async () => {
|
||||
const owner = {};
|
||||
const request = deferred<number>();
|
||||
const fetcher = vi.fn(() => request.promise);
|
||||
|
||||
const first = shareInFlightRequest(owner, "resource:a", fetcher);
|
||||
const second = shareInFlightRequest(owner, "resource:a", fetcher);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
|
||||
request.resolve(42);
|
||||
|
||||
await expect(first).resolves.toBe(42);
|
||||
await expect(second).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it("does not share requests with different keys", async () => {
|
||||
const owner = {};
|
||||
const fetcherA = vi.fn(async () => "a");
|
||||
const fetcherB = vi.fn(async () => "b");
|
||||
|
||||
await Promise.all([
|
||||
shareInFlightRequest(owner, "resource:a", fetcherA),
|
||||
shareInFlightRequest(owner, "resource:b", fetcherB),
|
||||
]);
|
||||
|
||||
expect(fetcherA).toHaveBeenCalledTimes(1);
|
||||
expect(fetcherB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not share requests between different owners", async () => {
|
||||
const firstOwner = {};
|
||||
const secondOwner = {};
|
||||
const firstFetcher = vi.fn(async () => "first");
|
||||
const secondFetcher = vi.fn(async () => "second");
|
||||
|
||||
await Promise.all([
|
||||
shareInFlightRequest(firstOwner, "resource:a", firstFetcher),
|
||||
shareInFlightRequest(secondOwner, "resource:a", secondFetcher),
|
||||
]);
|
||||
|
||||
expect(firstFetcher).toHaveBeenCalledTimes(1);
|
||||
expect(secondFetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets a request after it resolves", async () => {
|
||||
const owner = {};
|
||||
const fetcher = vi.fn(async () => 42);
|
||||
|
||||
await shareInFlightRequest(owner, "resource:a", fetcher);
|
||||
await shareInFlightRequest(owner, "resource:a", fetcher);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("forgets a request after it rejects", async () => {
|
||||
const owner = {};
|
||||
const fetcher = vi
|
||||
.fn<() => Promise<number>>()
|
||||
.mockRejectedValueOnce(new Error("failed"))
|
||||
.mockResolvedValueOnce(42);
|
||||
|
||||
await expect(
|
||||
shareInFlightRequest(owner, "resource:a", fetcher)
|
||||
).rejects.toThrow("failed");
|
||||
|
||||
await expect(
|
||||
shareInFlightRequest(owner, "resource:a", fetcher)
|
||||
).resolves.toBe(42);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deep-freezes resolved results", async () => {
|
||||
const owner = {};
|
||||
const result = { items: ["a"] };
|
||||
const fetcher = vi.fn(async () => result);
|
||||
|
||||
const shared = await shareInFlightRequest(owner, "resource:a", fetcher);
|
||||
|
||||
expect(shared).toBe(result);
|
||||
expect(Object.isFrozen(shared)).toBe(true);
|
||||
expect(Object.isFrozen((shared as typeof result).items)).toBe(true);
|
||||
expect(() => {
|
||||
(shared as typeof result).items.push("b");
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SVGRenderer } from "echarts/renderers";
|
||||
import echarts from "../../../src/resources/echarts/echarts";
|
||||
import { createYAxisPrecisionBounds } from "../../../src/components/chart/y-axis-fraction-digits";
|
||||
|
||||
// jsdom has no canvas, and zrender reaches for one to measure label text.
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
measureText: () => ({ width: 10 }),
|
||||
})) as any;
|
||||
|
||||
// The app registers the canvas renderer; jsdom needs the SVG one.
|
||||
echarts.use([SVGRenderer]);
|
||||
|
||||
// The gap is opened by ECharts' own tick rounding, not by our helper, so these
|
||||
// assert the extent ECharts actually renders rather than what we hand it.
|
||||
const renderExtent = (
|
||||
data: number[],
|
||||
yAxis: Record<string, unknown>,
|
||||
nextYAxis?: Record<string, unknown>
|
||||
): [number, number] => {
|
||||
const chart = echarts.init(null, null, {
|
||||
ssr: true,
|
||||
renderer: "svg",
|
||||
width: 400,
|
||||
height: 300,
|
||||
});
|
||||
const option = (axis: Record<string, unknown>) => ({
|
||||
xAxis: { type: "category", data: data.map((_, index) => String(index)) },
|
||||
yAxis: { type: "value", ...axis },
|
||||
series: [{ type: "line", data }],
|
||||
});
|
||||
chart.setOption(option(yAxis));
|
||||
if (nextYAxis) {
|
||||
chart.setOption(option(nextYAxis), { replaceMerge: ["series"] });
|
||||
}
|
||||
// getModel() is internal, but it is the only way to read the rendered extent.
|
||||
const extent = (chart as any)
|
||||
.getModel()
|
||||
.getComponent("yAxis")
|
||||
.axis.scale.getExtent();
|
||||
chart.dispose();
|
||||
return extent;
|
||||
};
|
||||
|
||||
const withGap = (includeZero = false, unit?: string) => ({
|
||||
scale: !includeZero,
|
||||
...createYAxisPrecisionBounds({
|
||||
includeZero,
|
||||
unit,
|
||||
onFractionDigits: () => undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
describe("Y-axis tick nudge", () => {
|
||||
it("opens a gap under data that lands exactly on a tick", () => {
|
||||
// The reported bug: a thermostat's states are quantized, so the minimum is
|
||||
// an exact tick and the HVAC action band has nothing to fill.
|
||||
expect(renderExtent([18, 21.2, 19], { scale: true })).toEqual([18, 21.5]);
|
||||
expect(renderExtent([18, 21.2, 19], withGap())).toEqual([17.5, 21.5]);
|
||||
});
|
||||
|
||||
it("leaves an axis with real headroom untouched", () => {
|
||||
// Its minimum sits well clear of the tick below it, so the rounding it
|
||||
// already gets is enough and the widened extent floors to the same place.
|
||||
const data = [18.3, 21.2, 19.5];
|
||||
expect(renderExtent(data, withGap())).toEqual(
|
||||
renderExtent(data, { scale: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a non-negative series anchored at zero", () => {
|
||||
expect(renderExtent([0, 3500, 1200], withGap())[0]).toBe(0);
|
||||
// Without the clamp the nudged minimum floors a whole interval below zero.
|
||||
expect(
|
||||
renderExtent([0, 3500, 1200], {
|
||||
scale: true,
|
||||
boundaryGap: [0.02, 0.02],
|
||||
})[0]
|
||||
).toBe(-1000);
|
||||
});
|
||||
|
||||
it("keeps the window ECharts gives a constant series", () => {
|
||||
expect(renderExtent([21, 21, 21], withGap())).toEqual(
|
||||
renderExtent([21, 21, 21], { scale: true })
|
||||
);
|
||||
// A series flat at zero has no magnitude to expand by.
|
||||
expect(renderExtent([0, 0, 0], withGap())).toEqual(
|
||||
renderExtent([0, 0, 0], { scale: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("does not round a percentage axis past 100", () => {
|
||||
const battery = [20, 100, 60, 20, 80];
|
||||
expect(renderExtent(battery, withGap())).toEqual([0, 120]);
|
||||
expect(renderExtent(battery, withGap(false, "%"))).toEqual([0, 100]);
|
||||
});
|
||||
|
||||
it("re-anchors at zero when a chart switches to a zero-anchored type", () => {
|
||||
// setOption merges the Y axis, so a gap that is only emitted conditionally
|
||||
// survives the switch and leaves the bars floating.
|
||||
expect(renderExtent([20, 500, 300], withGap(), withGap(true))[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -72,25 +72,13 @@ describe("computeYAxisFractionDigits", () => {
|
||||
});
|
||||
|
||||
describe("createYAxisPrecisionBounds", () => {
|
||||
const makeBounds = (
|
||||
options: Omit<
|
||||
Parameters<typeof createYAxisPrecisionBounds>[0],
|
||||
"onFractionDigits"
|
||||
> = {}
|
||||
) => {
|
||||
const onFractionDigits = vi.fn();
|
||||
return {
|
||||
...createYAxisPrecisionBounds({ ...options, onFractionDigits }),
|
||||
onFractionDigits,
|
||||
};
|
||||
};
|
||||
|
||||
it("computes digits from the visible extent when no bounds are set", () => {
|
||||
const { min, max, onFractionDigits } = makeBounds();
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({ onFractionDigits });
|
||||
|
||||
// Zoomed-out extent -> coarse precision, callbacks leave scaling to ECharts
|
||||
expect(min({ min: 10, max: 100 })).toBeUndefined();
|
||||
expect(max({ min: 10, max: 100 })).toBeUndefined();
|
||||
expect(min({ min: 0, max: 100 })).toBeUndefined();
|
||||
expect(max({ min: 0, max: 100 })).toBeUndefined();
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
|
||||
|
||||
// Zoomed-in narrow extent -> more decimals so ticks stay distinct
|
||||
@@ -99,7 +87,12 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("computes digits from numeric bounds and returns them unchanged", () => {
|
||||
const { min, max, onFractionDigits } = makeBounds({ min: 1.85, max: 2 });
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({
|
||||
min: 1.85,
|
||||
max: 2,
|
||||
onFractionDigits,
|
||||
});
|
||||
|
||||
// Fixed bounds pin the range, so the visible extent is ignored
|
||||
expect(min({ min: 1.9, max: 1.95 })).toBe(1.85);
|
||||
@@ -108,9 +101,11 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("resolves function bounds and passes their result through", () => {
|
||||
const { min, max, onFractionDigits } = makeBounds({
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({
|
||||
min: ({ min: dataMin }) => dataMin - 1,
|
||||
max: ({ max: dataMax }) => dataMax + 1,
|
||||
onFractionDigits,
|
||||
});
|
||||
|
||||
expect(min({ min: 10, max: 11 })).toBe(9);
|
||||
@@ -120,7 +115,11 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("unions the extent with zero for anchored axes", () => {
|
||||
const { min, onFractionDigits } = makeBounds({ includeZero: true });
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min } = createYAxisPrecisionBounds({
|
||||
includeZero: true,
|
||||
onFractionDigits,
|
||||
});
|
||||
|
||||
// Data sits at 20..25, but a bar axis renders from 0 -> coarse precision
|
||||
min({ min: 20, max: 25 });
|
||||
@@ -132,102 +131,10 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("does not over-pad when the visible extent collapses to noise", () => {
|
||||
const { min, onFractionDigits } = makeBounds();
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min } = createYAxisPrecisionBounds({ onFractionDigits });
|
||||
|
||||
min({ min: 0.3, max: 0.3 + 1e-15 });
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(1);
|
||||
});
|
||||
|
||||
it("widens the extent so ECharts always rounds out a gap", () => {
|
||||
const { min, max, boundaryGap, splitNumber } = makeBounds();
|
||||
|
||||
// The gap is applied by ECharts, so the bounds stay auto-scaled.
|
||||
expect(boundaryGap).toEqual([0.02, 0.02]);
|
||||
// Pinned rather than assumed, since the precision here is derived from it.
|
||||
expect(splitNumber).toBe(5);
|
||||
expect(min({ min: 18, max: 21.2 })).toBeUndefined();
|
||||
expect(max({ min: 18, max: 21.2 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("anchors at zero instead of widening a non-negative series below it", () => {
|
||||
const { min, max } = makeBounds();
|
||||
|
||||
// Without this, ECharts floors the widened -0.0035 to a full interval below
|
||||
// zero, inventing a negative region on a power chart.
|
||||
expect(min({ min: 0, max: 3500 })).toBe(0);
|
||||
expect(max({ min: 0, max: 3500 })).toBeUndefined();
|
||||
|
||||
// A minimum clear of the gap is left to ECharts.
|
||||
expect(min({ min: 100, max: 3500 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("anchors an all-negative series at zero from above", () => {
|
||||
const { min, max } = makeBounds();
|
||||
|
||||
expect(max({ min: -3500, max: 0 })).toBe(0);
|
||||
expect(min({ min: -3500, max: 0 })).toBeUndefined();
|
||||
|
||||
// Mixed-sign data is widened at both ends.
|
||||
expect(min({ min: -20, max: 20 })).toBeUndefined();
|
||||
expect(max({ min: -20, max: 20 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the expanded window ECharts gives a constant series", () => {
|
||||
const { min, max, onFractionDigits } = makeBounds();
|
||||
|
||||
// ECharts only expands a flat extent while its bounds are equal, so the gap
|
||||
// would otherwise collapse this to 20.99998..21.00002 at five decimals.
|
||||
expect(min({ min: 21, max: 21 })).toBe(10);
|
||||
expect(max({ min: 21, max: 21 })).toBe(35);
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
|
||||
|
||||
// A series flat at zero has no magnitude to expand by.
|
||||
expect(min({ min: 0, max: 0 })).toBe(0);
|
||||
expect(max({ min: 0, max: 0 })).toBe(1);
|
||||
});
|
||||
|
||||
it("stops a percentage axis at 100 the way it stops at zero", () => {
|
||||
const { max } = makeBounds({ unit: "%" });
|
||||
|
||||
// A battery that reaches full would otherwise round out to an axis
|
||||
// labelled up to 120%.
|
||||
expect(max({ min: 20, max: 100 })).toBe(100);
|
||||
expect(max({ min: 20, max: 99 })).toBe(100);
|
||||
|
||||
// Well clear of the ceiling, so it still gets its gap.
|
||||
expect(max({ min: 40, max: 60 })).toBeUndefined();
|
||||
|
||||
// Not every % sensor is bounded: power factor is signed and can read over
|
||||
// 100, and a series already past the ceiling must be left alone.
|
||||
expect(max({ min: -80, max: 140 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("holds a constant percentage under the ceiling too", () => {
|
||||
const { max } = makeBounds({ unit: "%" });
|
||||
|
||||
// A device left on the charger reads a flat 100%, and the expansion a flat
|
||||
// series gets is a fraction of its magnitude, so it overshoots on its own.
|
||||
expect(max({ min: 100, max: 100 })).toBe(100);
|
||||
expect(max({ min: 80, max: 80 })).toBe(100);
|
||||
|
||||
// Far enough below that the expansion never reaches the ceiling.
|
||||
expect(max({ min: 54, max: 54 })).toBe(90);
|
||||
});
|
||||
|
||||
it("only treats a percentage as bounded", () => {
|
||||
// The same extent without the unit is widened as usual.
|
||||
expect(makeBounds().max({ min: 20, max: 100 })).toBeUndefined();
|
||||
expect(
|
||||
makeBounds({ unit: "W" }).max({ min: 20, max: 100 })
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("never widens a zero-anchored axis", () => {
|
||||
const { min, max, boundaryGap } = makeBounds({ includeZero: true });
|
||||
|
||||
// Widening below zero would defeat ECharts' anchoring and float the bars.
|
||||
expect(boundaryGap).toEqual([0, 0]);
|
||||
expect(min({ min: 0, max: 3500 })).toBeUndefined();
|
||||
expect(max({ min: 20, max: 20 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeInitialHaFormData } from "../../../src/components/ha-form/compute-initial-ha-form-data";
|
||||
import type { Selector } from "../../../src/data/selector";
|
||||
import type { HaFormSchema } from "../../../src/components/ha-form/types";
|
||||
|
||||
const requiredField = (selector: Selector): HaFormSchema => ({
|
||||
name: "value",
|
||||
required: true,
|
||||
selector,
|
||||
});
|
||||
|
||||
const optionalField = (selector: Selector): HaFormSchema => ({
|
||||
name: "value",
|
||||
required: false,
|
||||
selector,
|
||||
});
|
||||
|
||||
describe("computeInitialHaFormData", () => {
|
||||
it("initializes required text selector with an empty string", () => {
|
||||
expect(computeInitialHaFormData([requiredField({ text: {} })])).toEqual({
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes required multiple text selector with an empty array", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
text: {
|
||||
multiple: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
value: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes required object selector with an empty string", () => {
|
||||
expect(computeInitialHaFormData([requiredField({ object: {} })])).toEqual({
|
||||
value: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes required multiple object selector with an empty array", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
object: {
|
||||
multiple: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
value: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a required single device class selector unset", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("initializes a required multiple device class selector with an empty array", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
device_class: {
|
||||
domain: "sensor",
|
||||
multiple: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
value: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not initialize optional text selectors", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
optionalField({ text: {} }),
|
||||
{
|
||||
...optionalField({
|
||||
text: {
|
||||
multiple: true,
|
||||
},
|
||||
}),
|
||||
name: "multiple",
|
||||
},
|
||||
])
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("does not initialize optional object selectors", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
optionalField({ object: {} }),
|
||||
{
|
||||
...optionalField({
|
||||
object: {
|
||||
multiple: true,
|
||||
},
|
||||
}),
|
||||
name: "multiple",
|
||||
},
|
||||
])
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("initializes a required constant selector without losing falsy values", () => {
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
constant: {
|
||||
value: false,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
value: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
constant: {
|
||||
value: 0,
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toEqual({
|
||||
value: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes a required choose selector from a constant first choice", () => {
|
||||
const schema = [
|
||||
{
|
||||
name: "match",
|
||||
required: true,
|
||||
selector: {
|
||||
choose: {
|
||||
choices: {
|
||||
Disabled: {
|
||||
selector: {
|
||||
constant: {
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
Enabled: {
|
||||
selector: {
|
||||
number: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(computeInitialHaFormData(schema)).toEqual({
|
||||
match: {
|
||||
active_choice: "Disabled",
|
||||
Disabled: "",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes a required choose selector from its child selector", () => {
|
||||
const schema = [
|
||||
{
|
||||
name: "mode",
|
||||
required: true,
|
||||
selector: {
|
||||
choose: {
|
||||
choices: {
|
||||
First: {
|
||||
selector: {
|
||||
text: {
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Second: {
|
||||
selector: {
|
||||
text: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(computeInitialHaFormData(schema)).toEqual({
|
||||
mode: {
|
||||
active_choice: "First",
|
||||
First: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws for an unsupported required selector", () => {
|
||||
expect(() =>
|
||||
computeInitialHaFormData([
|
||||
requiredField({
|
||||
ui_action: {
|
||||
default_action: "none",
|
||||
},
|
||||
}),
|
||||
])
|
||||
).toThrow("Selector ui_action not supported in initial form data");
|
||||
});
|
||||
|
||||
it("omits a first choose child without an initial value", () => {
|
||||
const schema = [
|
||||
{
|
||||
name: "mode",
|
||||
required: true,
|
||||
selector: {
|
||||
choose: {
|
||||
choices: {
|
||||
First: {
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
Second: {
|
||||
selector: {
|
||||
text: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
expect(computeInitialHaFormData(schema)).toStrictEqual({
|
||||
mode: {
|
||||
active_choice: "First",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ObjectSelector } from "../../../src/data/selector";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
|
||||
import type {
|
||||
FormDialogData,
|
||||
FormDialogParams,
|
||||
} from "../../../src/dialogs/form/show-form-dialog";
|
||||
import type { FormDialogParams } from "../../../src/dialogs/form/show-form-dialog";
|
||||
import "../../../src/components/ha-selector/ha-selector-object";
|
||||
|
||||
vi.mock("../../../src/components/ha-input-helper-text", () => {
|
||||
@@ -55,23 +51,12 @@ const selectorConfig = {
|
||||
const getInternals = (selector: HaObjectSelector) =>
|
||||
selector as unknown as Record<string, unknown>;
|
||||
|
||||
interface ItemActionEvent {
|
||||
stopPropagation: () => void;
|
||||
currentTarget: {
|
||||
item?: FormDialogData;
|
||||
index?: number;
|
||||
};
|
||||
}
|
||||
|
||||
const mountSelector = async (
|
||||
value: FormDialogData | FormDialogData[] | "",
|
||||
config: ObjectSelector = selectorConfig
|
||||
) => {
|
||||
const mountSelector = async (value: Record<string, string>[]) => {
|
||||
const selector = document.createElement(
|
||||
"ha-selector-object"
|
||||
) as HaObjectSelector;
|
||||
selector.hass = hass;
|
||||
selector.selector = config;
|
||||
selector.selector = selectorConfig;
|
||||
selector.value = value;
|
||||
document.body.append(selector);
|
||||
await selector.updateComplete;
|
||||
@@ -81,8 +66,8 @@ const mountSelector = async (
|
||||
const resolveFormDialog = async (
|
||||
selector: HaObjectSelector,
|
||||
action: "_addItem" | "_editItem",
|
||||
result: FormDialogData | null,
|
||||
item?: FormDialogData,
|
||||
result: Record<string, string> | null,
|
||||
item?: Record<string, string>,
|
||||
index?: number
|
||||
) => {
|
||||
let params: FormDialogParams | undefined;
|
||||
@@ -102,14 +87,15 @@ const resolveFormDialog = async (
|
||||
);
|
||||
});
|
||||
|
||||
const event: ItemActionEvent = {
|
||||
const event = {
|
||||
stopPropagation: vi.fn(),
|
||||
currentTarget: { item, index },
|
||||
};
|
||||
const operation = (
|
||||
getInternals(selector)[action] as (event: ItemActionEvent) => Promise<void>
|
||||
getInternals(selector)[action] as (ev: typeof event) => Promise<void>
|
||||
)(event);
|
||||
await Promise.all([dialogShown, operation]);
|
||||
await dialogShown;
|
||||
await operation;
|
||||
return params!;
|
||||
};
|
||||
|
||||
@@ -119,105 +105,6 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("ha-selector-object form dialog flow", () => {
|
||||
it("initializes Add dialog data from the object field schema", async () => {
|
||||
const selector = await mountSelector([], {
|
||||
object: {
|
||||
multiple: true,
|
||||
fields: {
|
||||
name: {
|
||||
required: true,
|
||||
selector: { text: {} },
|
||||
},
|
||||
states: {
|
||||
required: true,
|
||||
selector: {
|
||||
text: {
|
||||
multiple: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const params = await resolveFormDialog(selector, "_addItem", {
|
||||
name: "A",
|
||||
states: ["on"],
|
||||
});
|
||||
|
||||
expect(params.data).toEqual({
|
||||
name: "",
|
||||
states: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves unsupported required fields unset in Add dialog data", async () => {
|
||||
const selector = await mountSelector([], {
|
||||
object: {
|
||||
multiple: true,
|
||||
fields: {
|
||||
name: {
|
||||
required: true,
|
||||
selector: { text: {} },
|
||||
},
|
||||
tap_action: {
|
||||
required: true,
|
||||
selector: {
|
||||
ui_action: {
|
||||
default_action: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const params = await resolveFormDialog(selector, "_addItem", {
|
||||
name: "A",
|
||||
tap_action: {
|
||||
action: "none",
|
||||
},
|
||||
});
|
||||
|
||||
expect(params.data).toEqual({
|
||||
name: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the initialized empty value after an add-delete round trip", async () => {
|
||||
const selector = await mountSelector("", {
|
||||
object: {
|
||||
fields: {
|
||||
name: {
|
||||
selector: { text: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const valueChanged = vi.fn();
|
||||
selector.addEventListener("value-changed", valueChanged);
|
||||
|
||||
await resolveFormDialog(selector, "_addItem", { name: "A" });
|
||||
|
||||
expect(valueChanged.mock.calls[0][0].detail.value).toEqual({ name: "A" });
|
||||
|
||||
selector.value = valueChanged.mock.calls[0][0].detail.value;
|
||||
await selector.updateComplete;
|
||||
|
||||
const event: ItemActionEvent = {
|
||||
stopPropagation: vi.fn(),
|
||||
currentTarget: {
|
||||
index: 0,
|
||||
},
|
||||
};
|
||||
|
||||
(getInternals(selector)._deleteItem as (event: ItemActionEvent) => void)(
|
||||
event
|
||||
);
|
||||
|
||||
expect(valueChanged.mock.calls[1][0].detail.value).toBe("");
|
||||
});
|
||||
|
||||
it("appends an item through the real Add flow", async () => {
|
||||
const first = { name: "A" };
|
||||
const selector = await mountSelector([first]);
|
||||
|
||||
@@ -173,31 +173,4 @@ describe("ha-target-picker-item-row target extraction", () => {
|
||||
expect(entries!.referenced_devices).toEqual(["dev_1"]);
|
||||
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
|
||||
});
|
||||
|
||||
it("does not mutate the extracted target result", async () => {
|
||||
const result = extractResult({
|
||||
referenced_areas: ["area_missing"],
|
||||
referenced_devices: ["dev_missing"],
|
||||
referenced_entities: ["light.missing"],
|
||||
});
|
||||
|
||||
const entries = await extractedBy(
|
||||
result,
|
||||
{
|
||||
areas: {},
|
||||
devices: {},
|
||||
entities: {},
|
||||
},
|
||||
{ type: "floor", itemId: "floor_1" }
|
||||
);
|
||||
|
||||
expect(entries).toBeDefined();
|
||||
expect(entries).not.toBe(result);
|
||||
expect(entries!.referenced_areas).toEqual([]);
|
||||
expect(entries!.referenced_devices).toEqual([]);
|
||||
expect(entries!.referenced_entities).toEqual([]);
|
||||
expect(result.referenced_areas).toEqual(["area_missing"]);
|
||||
expect(result.referenced_devices).toEqual(["dev_missing"]);
|
||||
expect(result.referenced_entities).toEqual(["light.missing"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { getStatisticIds } from "../../src/data/recorder_statistic_ids";
|
||||
import type { StatisticsMetaData } from "../../src/data/recorder";
|
||||
|
||||
const createHass = (callWS = vi.fn()) =>
|
||||
({
|
||||
callWS,
|
||||
}) as unknown as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
describe("getStatisticIds", () => {
|
||||
it("shares concurrent requests for the same statistic type", async () => {
|
||||
const request = deferred<StatisticsMetaData[]>();
|
||||
const callWS = vi.fn().mockReturnValue(request.promise);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
const first = getStatisticIds(hass);
|
||||
const second = getStatisticIds(hass);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
request.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("works with callers that only expose callWS", async () => {
|
||||
const request = deferred<StatisticsMetaData[]>();
|
||||
const callWS = vi.fn().mockReturnValue(request.promise);
|
||||
const api = { callWS } as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const first = getStatisticIds(api);
|
||||
const second = getStatisticIds(api);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
request.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("shares requests across callers with the same callWS", async () => {
|
||||
const request = deferred<StatisticsMetaData[]>();
|
||||
const callWS = vi.fn().mockReturnValue(request.promise);
|
||||
const firstHass = createHass(callWS);
|
||||
const secondHass = createHass(callWS);
|
||||
|
||||
const first = getStatisticIds(firstHass);
|
||||
const second = getStatisticIds(secondHass);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
request.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("does not share requests for different statistic types", async () => {
|
||||
const callWS = vi.fn().mockResolvedValue([]);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
await Promise.all([
|
||||
getStatisticIds(hass),
|
||||
getStatisticIds(hass, "mean"),
|
||||
getStatisticIds(hass, "sum"),
|
||||
]);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(3);
|
||||
expect(callWS).toHaveBeenNthCalledWith(1, {
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type: undefined,
|
||||
});
|
||||
expect(callWS).toHaveBeenNthCalledWith(2, {
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type: "mean",
|
||||
});
|
||||
expect(callWS).toHaveBeenNthCalledWith(3, {
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type: "sum",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not share requests across different callWS owners", async () => {
|
||||
const firstRequest = deferred<StatisticsMetaData[]>();
|
||||
const secondRequest = deferred<StatisticsMetaData[]>();
|
||||
|
||||
const firstCallWS = vi.fn().mockReturnValue(firstRequest.promise);
|
||||
const secondCallWS = vi.fn().mockReturnValue(secondRequest.promise);
|
||||
|
||||
const first = getStatisticIds(createHass(firstCallWS));
|
||||
const second = getStatisticIds(createHass(secondCallWS));
|
||||
|
||||
expect(firstCallWS).toHaveBeenCalledTimes(1);
|
||||
expect(secondCallWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).not.toBe(first);
|
||||
|
||||
firstRequest.resolve([]);
|
||||
secondRequest.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("fetches again after the previous request settles", async () => {
|
||||
const callWS = vi.fn().mockResolvedValue([]);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
await getStatisticIds(hass, "sum");
|
||||
await getStatisticIds(hass, "sum");
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries after a failed request", async () => {
|
||||
const callWS = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("failed"))
|
||||
.mockResolvedValueOnce([]);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
await expect(getStatisticIds(hass)).rejects.toThrow("failed");
|
||||
await getStatisticIds(hass);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { extractFromTarget } from "../../src/data/target";
|
||||
|
||||
type CallWS = Parameters<typeof extractFromTarget>[0];
|
||||
|
||||
const callWS = () => vi.fn().mockResolvedValue({}) as unknown as CallWS;
|
||||
|
||||
describe("extractFromTarget in-flight sharing", () => {
|
||||
it("shares identical concurrent requests", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, target),
|
||||
extractFromTarget(ws, target),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not share different targets", async () => {
|
||||
const ws = callWS();
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, { area_id: ["bathroom"] }),
|
||||
extractFromTarget(ws, { area_id: ["kitchen"] }),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share different expandGroup values", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, target, false),
|
||||
extractFromTarget(ws, target, true),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share different primaryEntitiesOnly values", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, target, false, true),
|
||||
extractFromTarget(ws, target, false, false),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share between different callWS owners", async () => {
|
||||
const first = callWS();
|
||||
const second = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(first, target),
|
||||
extractFromTarget(second, target),
|
||||
]);
|
||||
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fetches again after the request settles", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await extractFromTarget(ws, target);
|
||||
await extractFromTarget(ws, target);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -101,7 +101,7 @@ test.describe("Home Assistant Demo", () => {
|
||||
);
|
||||
}, DEMO_THEME_STORAGE_KEY);
|
||||
|
||||
await loadDemo(page, "/#/profile/dashboard");
|
||||
await loadDemo(page, "/#/profile/general");
|
||||
|
||||
const themeRow = page.locator("ha-pick-theme-row");
|
||||
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
|
||||
@@ -121,7 +121,7 @@ test.describe("Home Assistant Demo", () => {
|
||||
await expectStoredDemoTheme(page, { theme: "default", dark: true });
|
||||
await expectDemoDarkMode(page, true);
|
||||
|
||||
await loadDemo(page, "/#/profile/dashboard");
|
||||
await loadDemo(page, "/#/profile/general");
|
||||
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
|
||||
await expect(
|
||||
themeRow.locator('ha-radio-option[value="dark"]')
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { navigate } from "../../src/common/navigate";
|
||||
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
|
||||
import { PreventUnsavedMixin } from "../../src/mixins/prevent-unsaved-mixin";
|
||||
|
||||
// navigate() closes open dialogs before touching history.
|
||||
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
|
||||
closeAllDialogs: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
interface TestState {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Mirrors the editors: DirtyStateProviderMixin wraps PreventUnsavedMixin.
|
||||
class TestPreventUnsaved extends DirtyStateProviderMixin<TestState>()(
|
||||
PreventUnsavedMixin(LitElement)
|
||||
) {
|
||||
public promptResponse = true;
|
||||
|
||||
public promptCalls = 0;
|
||||
|
||||
public initialize(value: TestState) {
|
||||
this._initDirtyTracking({ type: "shallow" }, value);
|
||||
}
|
||||
|
||||
public setValue(value: TestState) {
|
||||
this._updateDirtyState(value);
|
||||
}
|
||||
|
||||
public markClean() {
|
||||
this._markDirtyStateClean();
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges(): Promise<boolean> {
|
||||
this.promptCalls++;
|
||||
return this.promptResponse;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("test-prevent-unsaved", TestPreventUnsaved);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-prevent-unsaved": TestPreventUnsaved;
|
||||
}
|
||||
}
|
||||
|
||||
const setEntry = (path: string) => {
|
||||
window.history.replaceState(null, "", path);
|
||||
};
|
||||
|
||||
const mountClean = async () => {
|
||||
const element = document.createElement("test-prevent-unsaved");
|
||||
document.body.append(element);
|
||||
element.initialize({ name: "Kitchen" });
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
const mountDirty = async () => {
|
||||
const element = await mountClean();
|
||||
element.setValue({ name: "Bedroom" });
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
describe("PreventUnsavedMixin", () => {
|
||||
beforeEach(() => {
|
||||
setEntry("/config/automation/edit/1234");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll("test-prevent-unsaved").forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks navigation while dirty when the prompt is declined", async () => {
|
||||
const element = await mountDirty();
|
||||
element.promptResponse = false;
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(false);
|
||||
|
||||
expect(element.promptCalls).toBe(1);
|
||||
expect(window.location.pathname).toEqual("/config/automation/edit/1234");
|
||||
});
|
||||
|
||||
it("navigates while dirty when the prompt is confirmed", async () => {
|
||||
const element = await mountDirty();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(1);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("does not prompt while clean", async () => {
|
||||
const element = await mountClean();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(0);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("does not prompt after changes are marked clean, before the next render", async () => {
|
||||
const element = await mountDirty();
|
||||
element.markClean();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(0);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("stops guarding after being disconnected", async () => {
|
||||
const element = await mountDirty();
|
||||
element.promptResponse = false;
|
||||
element.remove();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(0);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("arms beforeunload only while dirty", async () => {
|
||||
const element = await mountDirty();
|
||||
|
||||
let event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
|
||||
element.setValue({ name: "Kitchen" });
|
||||
await element.updateComplete;
|
||||
|
||||
event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,295 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createZHANetworkChartData } from "../../../../../../src/panels/config/integrations/integration-panels/zha/zha-network-data";
|
||||
import type { ZHADevice } from "../../../../../../src/data/zha";
|
||||
import type { HomeAssistant } from "../../../../../../src/types";
|
||||
|
||||
const hass = {
|
||||
devices: {},
|
||||
areas: {},
|
||||
localize: () => "",
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const device = (partial: Partial<ZHADevice>): ZHADevice =>
|
||||
({
|
||||
available: true,
|
||||
name: partial.ieee!,
|
||||
lqi: 0,
|
||||
rssi: "",
|
||||
last_seen: "",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
quirk_applied: false,
|
||||
quirk_class: "",
|
||||
entities: [],
|
||||
manufacturer_code: 0,
|
||||
device_reg_id: partial.ieee!,
|
||||
active_coordinator: false,
|
||||
signature: {},
|
||||
routes: [],
|
||||
neighbors: [],
|
||||
...partial,
|
||||
}) as ZHADevice;
|
||||
|
||||
describe("createZHANetworkChartData", () => {
|
||||
it("links a router to its upstream router instead of a nearby child device when routing tables are empty", () => {
|
||||
// Regression test: many Zigbee radios (e.g. TI CC2652 via zigpy-znp)
|
||||
// never populate Mgmt_Rtg routes, so the chart has to fall back to
|
||||
// picking a device's strongest RF neighbor. A router's own child end
|
||||
// device (e.g. a plug sitting right next to it) commonly reports a
|
||||
// stronger LQI than the router's real uplink, which must not be
|
||||
// allowed to hide the backbone connection.
|
||||
const coordinator = device({
|
||||
ieee: "coordinator",
|
||||
device_type: "Coordinator",
|
||||
nwk: 0,
|
||||
});
|
||||
const upstreamRouter = device({
|
||||
ieee: "upstream-router",
|
||||
device_type: "Router",
|
||||
nwk: 1,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "coordinator",
|
||||
nwk: "0x0000",
|
||||
lqi: "200",
|
||||
depth: "0",
|
||||
relationship: "Parent",
|
||||
},
|
||||
],
|
||||
});
|
||||
const downstreamRouter = device({
|
||||
ieee: "downstream-router",
|
||||
device_type: "Router",
|
||||
nwk: 2,
|
||||
neighbors: [
|
||||
// Real backbone link: weaker signal, correctly flagged as a sibling router
|
||||
{
|
||||
ieee: "upstream-router",
|
||||
nwk: "0x0001",
|
||||
lqi: "80",
|
||||
depth: "1",
|
||||
relationship: "Sibling",
|
||||
},
|
||||
// Nearby child end device with a much stronger signal than the real uplink
|
||||
{
|
||||
ieee: "child-end-device",
|
||||
nwk: "0x0003",
|
||||
lqi: "250",
|
||||
depth: "2",
|
||||
relationship: "Child",
|
||||
},
|
||||
],
|
||||
});
|
||||
const childEndDevice = device({
|
||||
ieee: "child-end-device",
|
||||
device_type: "EndDevice",
|
||||
nwk: 3,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "downstream-router",
|
||||
nwk: "0x0002",
|
||||
lqi: "250",
|
||||
depth: "2",
|
||||
relationship: "Parent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { links } = createZHANetworkChartData(
|
||||
[coordinator, upstreamRouter, downstreamRouter, childEndDevice],
|
||||
hass,
|
||||
document.createElement("div")
|
||||
);
|
||||
|
||||
const adjacency = new Map<string, string[]>();
|
||||
for (const link of links) {
|
||||
adjacency.set(link.source, [
|
||||
...(adjacency.get(link.source) ?? []),
|
||||
link.target,
|
||||
]);
|
||||
adjacency.set(link.target, [
|
||||
...(adjacency.get(link.target) ?? []),
|
||||
link.source,
|
||||
]);
|
||||
}
|
||||
|
||||
// Every device should be reachable from the coordinator. Before the
|
||||
// fix, downstream-router picked its child as its only link, leaving
|
||||
// it (and its children) as a disconnected island in the chart even
|
||||
// though the real network is fully connected.
|
||||
const reachable = new Set(["coordinator"]);
|
||||
const queue = ["coordinator"];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
for (const neighbor of adjacency.get(current) ?? []) {
|
||||
if (!reachable.has(neighbor)) {
|
||||
reachable.add(neighbor);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(reachable.has("upstream-router")).toBe(true);
|
||||
expect(reachable.has("downstream-router")).toBe(true);
|
||||
expect(reachable.has("child-end-device")).toBe(true);
|
||||
});
|
||||
|
||||
it("ranks NoneOfTheAbove above Child, and treats unrecognized relationships as better than PreviousChild", () => {
|
||||
const router = device({
|
||||
ieee: "router",
|
||||
device_type: "Router",
|
||||
nwk: 1,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "n-child",
|
||||
nwk: "0x0001",
|
||||
lqi: "100",
|
||||
depth: "1",
|
||||
relationship: "Child",
|
||||
},
|
||||
{
|
||||
ieee: "n-none-of-the-above",
|
||||
nwk: "0x0002",
|
||||
lqi: "100",
|
||||
depth: "1",
|
||||
relationship: "NoneOfTheAbove",
|
||||
},
|
||||
],
|
||||
});
|
||||
const unknownRelationship = device({
|
||||
ieee: "router-2",
|
||||
device_type: "Router",
|
||||
nwk: 2,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "n-unrecognized",
|
||||
nwk: "0x0003",
|
||||
lqi: "100",
|
||||
depth: "1",
|
||||
relationship: "SomeFutureValue",
|
||||
},
|
||||
{
|
||||
ieee: "n-previous-child",
|
||||
nwk: "0x0004",
|
||||
lqi: "100",
|
||||
depth: "1",
|
||||
relationship: "PreviousChild",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { links } = createZHANetworkChartData(
|
||||
[router, unknownRelationship],
|
||||
hass,
|
||||
document.createElement("div")
|
||||
);
|
||||
|
||||
const otherEndOf = (ieee: string) => {
|
||||
const link = links.find((l) => l.source === ieee || l.target === ieee);
|
||||
return link?.source === ieee ? link.target : link?.source;
|
||||
};
|
||||
|
||||
expect(otherEndOf("router")).toBe("n-none-of-the-above");
|
||||
expect(otherEndOf("router-2")).toBe("n-unrecognized");
|
||||
});
|
||||
|
||||
it("connects every device regardless of backend device order (child listed before its parent router)", () => {
|
||||
// Regression test for a subtler variant of the same bug: the fallback
|
||||
// link is only computed for a device if it doesn't already have a
|
||||
// link. If the backend lists a child end device before its parent
|
||||
// router, the child claims the link first, and the router - now
|
||||
// appearing to "already have a link" - never gets to evaluate its own
|
||||
// (better) neighbor choice, splitting the graph exactly as before but
|
||||
// triggered by device order instead of by LQI.
|
||||
const coordinator = device({
|
||||
ieee: "coordinator",
|
||||
device_type: "Coordinator",
|
||||
nwk: 0,
|
||||
});
|
||||
const upstreamRouter = device({
|
||||
ieee: "upstream-router",
|
||||
device_type: "Router",
|
||||
nwk: 1,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "coordinator",
|
||||
nwk: "0x0000",
|
||||
lqi: "200",
|
||||
depth: "0",
|
||||
relationship: "Parent",
|
||||
},
|
||||
],
|
||||
});
|
||||
const downstreamRouter = device({
|
||||
ieee: "downstream-router",
|
||||
device_type: "Router",
|
||||
nwk: 2,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "upstream-router",
|
||||
nwk: "0x0001",
|
||||
lqi: "80",
|
||||
depth: "1",
|
||||
relationship: "Sibling",
|
||||
},
|
||||
{
|
||||
ieee: "child-end-device",
|
||||
nwk: "0x0003",
|
||||
lqi: "250",
|
||||
depth: "2",
|
||||
relationship: "Child",
|
||||
},
|
||||
],
|
||||
});
|
||||
const childEndDevice = device({
|
||||
ieee: "child-end-device",
|
||||
device_type: "EndDevice",
|
||||
nwk: 3,
|
||||
neighbors: [
|
||||
{
|
||||
ieee: "downstream-router",
|
||||
nwk: "0x0002",
|
||||
lqi: "250",
|
||||
depth: "2",
|
||||
relationship: "Parent",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Same topology as the first test, but the child is listed before its
|
||||
// parent router this time.
|
||||
const { links } = createZHANetworkChartData(
|
||||
[coordinator, upstreamRouter, childEndDevice, downstreamRouter],
|
||||
hass,
|
||||
document.createElement("div")
|
||||
);
|
||||
|
||||
const adjacency = new Map<string, string[]>();
|
||||
for (const link of links) {
|
||||
adjacency.set(link.source, [
|
||||
...(adjacency.get(link.source) ?? []),
|
||||
link.target,
|
||||
]);
|
||||
adjacency.set(link.target, [
|
||||
...(adjacency.get(link.target) ?? []),
|
||||
link.source,
|
||||
]);
|
||||
}
|
||||
|
||||
const reachable = new Set(["coordinator"]);
|
||||
const queue = ["coordinator"];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
for (const neighbor of adjacency.get(current) ?? []) {
|
||||
if (!reachable.has(neighbor)) {
|
||||
reachable.add(neighbor);
|
||||
queue.push(neighbor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(reachable.has("upstream-router")).toBe(true);
|
||||
expect(reachable.has("downstream-router")).toBe(true);
|
||||
expect(reachable.has("child-end-device")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { filterLowBatteryEntities } from "../../../../src/panels/maintenance/strategies/maintenance-view-strategy";
|
||||
import { mockEntity } from "../../../common/entity/context/context-mock";
|
||||
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
|
||||
|
||||
describe("filterLowBatteryEntities", () => {
|
||||
it("filters numeric battery entities by the low battery threshold", () => {
|
||||
const hass = createMockHass({
|
||||
"sensor.low_battery": createMockEntityState("sensor.low_battery", "20", {
|
||||
device_class: "battery",
|
||||
}),
|
||||
"sensor.ok_battery": createMockEntityState("sensor.ok_battery", "21", {
|
||||
device_class: "battery",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
filterLowBatteryEntities(hass, [
|
||||
"sensor.low_battery",
|
||||
"sensor.ok_battery",
|
||||
])
|
||||
).toEqual(["sensor.low_battery"]);
|
||||
});
|
||||
|
||||
it("excludes a low battery when its device is charging", () => {
|
||||
const entities = {
|
||||
"sensor.device_1_battery": mockEntity({
|
||||
entity_id: "sensor.device_1_battery",
|
||||
device_id: "device_1",
|
||||
}),
|
||||
"binary_sensor.device_1_battery_charging": mockEntity({
|
||||
entity_id: "binary_sensor.device_1_battery_charging",
|
||||
device_id: "device_1",
|
||||
}),
|
||||
"sensor.device_2_battery": mockEntity({
|
||||
entity_id: "sensor.device_2_battery",
|
||||
device_id: "device_2",
|
||||
}),
|
||||
"binary_sensor.device_2_battery_charging": mockEntity({
|
||||
entity_id: "binary_sensor.device_2_battery_charging",
|
||||
device_id: "device_2",
|
||||
}),
|
||||
};
|
||||
|
||||
const hass = createMockHass(
|
||||
{
|
||||
"sensor.device_1_battery": createMockEntityState(
|
||||
"sensor.device_1_battery",
|
||||
"10",
|
||||
{ device_class: "battery" }
|
||||
),
|
||||
"binary_sensor.device_1_battery_charging": createMockEntityState(
|
||||
"binary_sensor.device_1_battery_charging",
|
||||
"on",
|
||||
{ device_class: "battery_charging" }
|
||||
),
|
||||
"sensor.device_2_battery": createMockEntityState(
|
||||
"sensor.device_2_battery",
|
||||
"10",
|
||||
{ device_class: "battery" }
|
||||
),
|
||||
"binary_sensor.device_2_battery_charging": createMockEntityState(
|
||||
"binary_sensor.device_2_battery_charging",
|
||||
"off",
|
||||
{ device_class: "battery_charging" }
|
||||
),
|
||||
},
|
||||
{ entities }
|
||||
);
|
||||
|
||||
expect(
|
||||
filterLowBatteryEntities(hass, [
|
||||
"sensor.device_1_battery",
|
||||
"sensor.device_2_battery",
|
||||
])
|
||||
).toEqual(["sensor.device_2_battery"]);
|
||||
});
|
||||
|
||||
it("keeps binary battery sensor behavior unchanged", () => {
|
||||
const hass = createMockHass({
|
||||
"binary_sensor.low_battery": createMockEntityState(
|
||||
"binary_sensor.low_battery",
|
||||
"on",
|
||||
{ device_class: "battery" }
|
||||
),
|
||||
"binary_sensor.ok_battery": createMockEntityState(
|
||||
"binary_sensor.ok_battery",
|
||||
"off",
|
||||
{ device_class: "battery" }
|
||||
),
|
||||
});
|
||||
|
||||
expect(
|
||||
filterLowBatteryEntities(hass, [
|
||||
"binary_sensor.low_battery",
|
||||
"binary_sensor.ok_battery",
|
||||
])
|
||||
).toEqual(["binary_sensor.low_battery"]);
|
||||
});
|
||||
|
||||
it("updates the device lookup when the entity registry changes", () => {
|
||||
const states = {
|
||||
"sensor.battery": createMockEntityState("sensor.battery", "10", {
|
||||
device_class: "battery",
|
||||
}),
|
||||
"binary_sensor.battery_charging": createMockEntityState(
|
||||
"binary_sensor.battery_charging",
|
||||
"on",
|
||||
{ device_class: "battery_charging" }
|
||||
),
|
||||
};
|
||||
|
||||
const firstHass = createMockHass(states, {
|
||||
entities: {
|
||||
"sensor.battery": mockEntity({
|
||||
entity_id: "sensor.battery",
|
||||
device_id: "device_1",
|
||||
}),
|
||||
"binary_sensor.battery_charging": mockEntity({
|
||||
entity_id: "binary_sensor.battery_charging",
|
||||
device_id: "device_1",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(filterLowBatteryEntities(firstHass, ["sensor.battery"])).toEqual([]);
|
||||
|
||||
const secondHass = createMockHass(states, {
|
||||
entities: {
|
||||
"sensor.battery": mockEntity({
|
||||
entity_id: "sensor.battery",
|
||||
device_id: "device_1",
|
||||
}),
|
||||
"binary_sensor.battery_charging": mockEntity({
|
||||
entity_id: "binary_sensor.battery_charging",
|
||||
device_id: "device_2",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(filterLowBatteryEntities(secondHass, ["sensor.battery"])).toEqual([
|
||||
"sensor.battery",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3488,10 +3488,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@lokalise/node-api@npm:16.4.0":
|
||||
version: 16.4.0
|
||||
resolution: "@lokalise/node-api@npm:16.4.0"
|
||||
checksum: 10/68bc43e4cdeec93b44ddbfc10999acda68938ef889c9f595a2849da7289e5a6b33e34f5c4d7234e12a730348eb3adff164145acfeba610785c6adc509b7e4cf4
|
||||
"@lokalise/node-api@npm:16.3.0":
|
||||
version: 16.3.0
|
||||
resolution: "@lokalise/node-api@npm:16.3.0"
|
||||
checksum: 10/9902033459cd3abb6dd0ad2846a4c464144caf890433456c2b966500201b6b692f751e9e23e925f968f9501f431ea6bbc07fdfb59f367fd6f3351d6bb7ae67bd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3502,13 +3502,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mapbox/mapbox-gl-rtl-text@npm:0.4.0":
|
||||
version: 0.4.0
|
||||
resolution: "@mapbox/mapbox-gl-rtl-text@npm:0.4.0"
|
||||
checksum: 10/a678240e4cc6f589726ef2f35fac64a7eb073451a8686c09db58041d3fb351d2e72100bb12607591efc298d8ac5e19691c593b3c213d121b2a0e84978254e1a8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mapbox/point-geometry@npm:^1.1.0, @mapbox/point-geometry@npm:~1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "@mapbox/point-geometry@npm:1.1.0"
|
||||
@@ -5375,7 +5368,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sveltejs/acorn-typescript@npm:^1.0.13":
|
||||
"@sveltejs/acorn-typescript@npm:^1.0.10":
|
||||
version: 1.0.13
|
||||
resolution: "@sveltejs/acorn-typescript@npm:1.0.13"
|
||||
peerDependencies:
|
||||
@@ -5958,105 +5951,105 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/eslint-plugin@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.68.0"
|
||||
"@typescript-eslint/eslint-plugin@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/eslint-plugin@npm:8.67.0"
|
||||
dependencies:
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
"@typescript-eslint/scope-manager": "npm:8.68.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.68.0"
|
||||
"@typescript-eslint/utils": "npm:8.68.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.68.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.67.0"
|
||||
"@typescript-eslint/type-utils": "npm:8.67.0"
|
||||
"@typescript-eslint/utils": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
ignore: "npm:^7.0.5"
|
||||
natural-compare: "npm:^1.4.0"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
"@typescript-eslint/parser": ^8.68.0
|
||||
"@typescript-eslint/parser": ^8.67.0
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/8776fd8ffde8eceae3b1aeca57e528e8ee61a683223c3bef455ca3c5e28e52275e6cd6ebf8ee284a46883e6220bbf297312084e1ee85e00cef017a78c169a674
|
||||
checksum: 10/d53f9e51c6be98ff4cdcb94de24e2951f5e2b7a1dfc56c4f8a2d81e61346c511c0478117626d9cfd5d5d63f5e7a0e0653dc1abe1e7d926a3f9806aaf1fa11bd2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/parser@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.68.0"
|
||||
"@typescript-eslint/parser@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/parser@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager": "npm:8.68.0"
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.68.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.68.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.67.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/979ae0d2c6a391fd5b9b0218189fda5112ff0b5ab2538eef1da4cd77ac8ae46f9b590db9779f2614bd193105b40cf12635b565c840f4cdbbb4a683e24cf5ec4b
|
||||
checksum: 10/fa156ade066d0886ea0daa0f872940cdb450a2b5dbd1687e0df64ec19aac86711dde51e17e6b20e72387aa38daec811736821e9db950e8557affa7faea63d65c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/project-service@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.68.0"
|
||||
"@typescript-eslint/project-service@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/project-service@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.68.0"
|
||||
"@typescript-eslint/types": "npm:^8.68.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:^8.67.0"
|
||||
"@typescript-eslint/types": "npm:^8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/37521df3731f9069c7df55362342dd0027f43947241de33c2a1c029b4553fe092e2ac69a534153aaf08c862e6b2390d864cb6c88a0714fc276395390df276fb6
|
||||
checksum: 10/3beccdee1e74060eac4e6c5e7edd14a800c8c98e3313dbfe5838a357e83babc22c41c3ebe637c331c4b93db2da50f5479ccf4677db7b6b1a760f3cd27795b36f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/scope-manager@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.68.0"
|
||||
"@typescript-eslint/scope-manager@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/scope-manager@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.68.0"
|
||||
checksum: 10/494d499315b4f3ab8eaa114aa53a4b2b9f533b82eb007cd5b5617cececfd8ad6938edb74f734479ae6e40a4086f510b6b984de8e66d06c518cb47817549db780
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
checksum: 10/a0ca8d52e6c5b2538f7df860acd5a36437cce8e6d5bcededdfde04c02489f4b523cb178b502c266d138cff6758ea375ebe311b2b6af373b127ded4876a697ffb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.68.0, @typescript-eslint/tsconfig-utils@npm:^8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.68.0"
|
||||
"@typescript-eslint/tsconfig-utils@npm:8.67.0, @typescript-eslint/tsconfig-utils@npm:^8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/tsconfig-utils@npm:8.67.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/b527a7c5deaad40bb72f78eec1bfea5997a7e792e64666984605e9a167246cf856c8d40290a7b3d39cefb3a242538e22a3a4fc136132e4669a314887e2de5f08
|
||||
checksum: 10/736d0ab8a273b4e4e17d412b390386eec0171cd3f7ba451bacd92c4f931051bba2049e040fdae84603ea0e07430afb05b49c6d8b6d429f9012d3c6b03eb69f54
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/type-utils@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.68.0"
|
||||
"@typescript-eslint/type-utils@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/type-utils@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.68.0"
|
||||
"@typescript-eslint/utils": "npm:8.68.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
"@typescript-eslint/utils": "npm:8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/ed6b6fe74eee017a79c9dbfa72b3a08748dac8de5702314494d8849feee516c20e2907ad730fd82e50be81c5984c575b29e35eb8b5c99091445b4bc5102ce836
|
||||
checksum: 10/a0b943e5a8f3c2e8490ec28599eb94561d7ad029fcf42f2e9a0e8d2685ce625898371cd0a7d3a906fe8b4077d72fee8329cad512a5b0521bc2167f7d88a984fb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/types@npm:8.68.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/types@npm:8.68.0"
|
||||
checksum: 10/f9337e0263d95e6edca6e364f9b1f70d5840b9648b5aa0d02065bcee5809a50c92d024ba17b458182ec4e7fffe5efd05584f1667b1e43ff3e7121b218283a207
|
||||
"@typescript-eslint/types@npm:8.67.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/types@npm:8.67.0"
|
||||
checksum: 10/8edc1a14a52c7566409c19b34fb72ba3a62a3b409c98b1d496de9fcd83179fb128c8df2fde391c199316a54cdc5d314ba70ce7b440161dd850b73a44f3e54e6d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/typescript-estree@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.68.0"
|
||||
"@typescript-eslint/typescript-estree@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/typescript-estree@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service": "npm:8.68.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.68.0"
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.68.0"
|
||||
"@typescript-eslint/project-service": "npm:8.67.0"
|
||||
"@typescript-eslint/tsconfig-utils": "npm:8.67.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/visitor-keys": "npm:8.67.0"
|
||||
debug: "npm:^4.4.3"
|
||||
minimatch: "npm:^10.2.2"
|
||||
semver: "npm:^7.7.3"
|
||||
@@ -6064,32 +6057,32 @@ __metadata:
|
||||
ts-api-utils: "npm:^2.5.0"
|
||||
peerDependencies:
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/47d75cbc9c10842aeb3c1cc8d0625bd578b3b43b75d3dc86d3543a739b2fc8d0b524fd86b5af1f1493a3f0ae1b9e65c3200bf01a6bde0162efe6414708eac553
|
||||
checksum: 10/31c5be812f67c997b9d1e9324c4289b6268a12e9948492403fdd9c9b5df12dcc00f845ae93ae37c78f717583519060175f2147f165b65112eef1e40acf44ad1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/utils@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.68.0"
|
||||
"@typescript-eslint/utils@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/utils@npm:8.67.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.9.1"
|
||||
"@typescript-eslint/scope-manager": "npm:8.68.0"
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.68.0"
|
||||
"@typescript-eslint/scope-manager": "npm:8.67.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/6581a1e7ced23cce82279df001d643844c97408bc84826da77ec77d52b77047004b202ff8cfcfbc0b0fef15bf2cafb5ae21bafbda24c9b58102ce634f66c2af9
|
||||
checksum: 10/8703468cdea3630275c6faacc031e7e4bd7fb8f8988e80ab987811ae3c63a9a9677496cee2b4811f49cb9bc9e6f6c0bdb91cb213353019ce31df0a2b351cea51
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@typescript-eslint/visitor-keys@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.68.0"
|
||||
"@typescript-eslint/visitor-keys@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "@typescript-eslint/visitor-keys@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/types": "npm:8.68.0"
|
||||
"@typescript-eslint/types": "npm:8.67.0"
|
||||
eslint-visitor-keys: "npm:^5.0.0"
|
||||
checksum: 10/d802bc2c4569495f3d83f9bcf169e3a432ac1c6672e243399886409dbc55d7fa30375d9d0b2cf0d9466b81fc2b01b991483eb56e8cdb8141721009978d3b078b
|
||||
checksum: 10/c92c9b202105a8f398ab2ad4be58c8c8fd9af6b5ddd28cdcd57d75f7f04f69561a56a05aac9a008668c64faa7222ebb3fefe19097923a18eb2b9f7a90809a5be
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -6794,7 +6787,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.18.0":
|
||||
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.17.0":
|
||||
version: 8.18.0
|
||||
resolution: "acorn@npm:8.18.0"
|
||||
bin:
|
||||
@@ -7757,20 +7750,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cli-spinners@npm:^2.5.0":
|
||||
"cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.6.0":
|
||||
version: 2.9.2
|
||||
resolution: "cli-spinners@npm:2.9.2"
|
||||
checksum: 10/a0a863f442df35ed7294424f5491fa1756bd8d2e4ff0c8736531d886cec0ece4d85e8663b77a5afaf1d296e3cbbebff92e2e99f52bbea89b667cbe789b994794
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"cli-spinners@npm:^3.4.0":
|
||||
version: 3.4.0
|
||||
resolution: "cli-spinners@npm:3.4.0"
|
||||
checksum: 10/6a4021c1999011fc34ae714f055dcdafb56309abc1f8fb021ea7d9370dfc524485fe8684226015e5fe6053dd30544e74270184ff7edc3fa4d37043b8efd0a054
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"clipboardy@npm:3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "clipboardy@npm:3.0.0"
|
||||
@@ -8702,7 +8688,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"enquirer@npm:^2.4.1":
|
||||
"enquirer@npm:^2.3.6":
|
||||
version: 2.4.1
|
||||
resolution: "enquirer@npm:2.4.1"
|
||||
dependencies:
|
||||
@@ -9111,9 +9097,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint@npm:10.9.1":
|
||||
version: 10.9.1
|
||||
resolution: "eslint@npm:10.9.1"
|
||||
"eslint@npm:10.9.0":
|
||||
version: 10.9.0
|
||||
resolution: "eslint@npm:10.9.0"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.8.0"
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
@@ -9152,7 +9138,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
eslint: bin/eslint.js
|
||||
checksum: 10/4cee237c7d2c445a05330e595b18521d7452fd3f419241bf1ccf81c769a6761c10ca5a06988197482d4842ed1a26b1dcb51b9526408990cee84559e6c08cb3ab
|
||||
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9689,23 +9675,24 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"generate-license-file@npm:4.2.3":
|
||||
version: 4.2.3
|
||||
resolution: "generate-license-file@npm:4.2.3"
|
||||
"generate-license-file@npm:4.2.1":
|
||||
version: 4.2.1
|
||||
resolution: "generate-license-file@npm:4.2.1"
|
||||
dependencies:
|
||||
"@commander-js/extra-typings": "npm:^14.0.0"
|
||||
"@npmcli/arborist": "npm:^9.0.0"
|
||||
cli-spinners: "npm:^3.4.0"
|
||||
cli-spinners: "npm:^2.6.0"
|
||||
commander: "npm:^14.0.2"
|
||||
cosmiconfig: "npm:^9.0.0"
|
||||
enquirer: "npm:^2.4.1"
|
||||
enquirer: "npm:^2.3.6"
|
||||
glob: "npm:^13.0.0"
|
||||
json5: "npm:^2.2.3"
|
||||
ora: "npm:^5.4.1"
|
||||
tslib: "npm:^2.3.0"
|
||||
zod: "npm:^4.0.0"
|
||||
bin:
|
||||
generate-license-file: bin/generate-license-file
|
||||
checksum: 10/bdfd899c04b247f75115d9710b53124b6f202d85d4cacf3e9d2d46260cc7d75e9f93ebf2204105ecb49b051c5acfac6c05d1a9711763b7daed073bffd986ac69
|
||||
checksum: 10/03876abeba28efea39a0fbd4d048e2c0fe1f59477c14e3a5e9f7022235f9197a3f451b7f26124dc09da5cc63140376f53b4ee9167f238eee58241ab954e62efd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10160,8 +10147,7 @@ __metadata:
|
||||
"@lit/context": "npm:1.1.6"
|
||||
"@lit/reactive-element": "npm:2.1.2"
|
||||
"@lit/task": "npm:1.0.3"
|
||||
"@lokalise/node-api": "npm:16.4.0"
|
||||
"@mapbox/mapbox-gl-rtl-text": "npm:0.4.0"
|
||||
"@lokalise/node-api": "npm:16.3.0"
|
||||
"@maplibre/maplibre-gl-leaflet": "npm:0.1.4"
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch"
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
@@ -10220,7 +10206,7 @@ __metadata:
|
||||
echarts: "npm:6.1.0"
|
||||
echarts-extension-chart2music: "npm:0.1.1"
|
||||
element-internals-polyfill: "npm:3.0.2"
|
||||
eslint: "npm:10.9.1"
|
||||
eslint: "npm:10.9.0"
|
||||
eslint-config-prettier: "npm:10.1.8"
|
||||
eslint-import-resolver-webpack: "npm:0.13.11"
|
||||
eslint-plugin-import-x: "npm:4.17.1"
|
||||
@@ -10231,7 +10217,7 @@ __metadata:
|
||||
fancy-log: "npm:2.0.0"
|
||||
fs-extra: "npm:11.4.0"
|
||||
fuse.js: "npm:7.5.0"
|
||||
generate-license-file: "npm:4.2.3"
|
||||
generate-license-file: "npm:4.2.1"
|
||||
glob: "npm:13.0.6"
|
||||
globals: "npm:17.11.0"
|
||||
gulp: "npm:5.0.1"
|
||||
@@ -10243,7 +10229,7 @@ __metadata:
|
||||
husky: "npm:9.1.7"
|
||||
idb-keyval: "npm:6.3.0"
|
||||
intl-messageformat: "npm:11.2.14"
|
||||
js-yaml: "npm:5.4.0"
|
||||
js-yaml: "npm:5.3.0"
|
||||
jsdom: "npm:30.0.1"
|
||||
jszip: "npm:3.10.1"
|
||||
leaflet: "npm:1.9.4"
|
||||
@@ -10260,9 +10246,9 @@ __metadata:
|
||||
luxon: "npm:3.7.2"
|
||||
map-stream: "npm:0.0.7"
|
||||
maplibre-gl: "npm:5.24.0"
|
||||
marked: "npm:18.0.11"
|
||||
marked: "npm:18.0.10"
|
||||
memoize-one: "npm:6.0.0"
|
||||
minify-literals: "npm:2.2.0"
|
||||
minify-literals: "npm:2.1.0"
|
||||
node-vibrant: "npm:4.0.4"
|
||||
object-hash: "npm:3.0.0"
|
||||
pinst: "npm:3.0.0"
|
||||
@@ -10283,7 +10269,7 @@ __metadata:
|
||||
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
|
||||
ts-lit-plugin: "npm:2.0.2"
|
||||
typescript: "npm:6.0.3"
|
||||
typescript-eslint: "npm:8.68.0"
|
||||
typescript-eslint: "npm:8.67.0"
|
||||
vite-tsconfig-paths: "npm:6.1.1"
|
||||
vitest: "npm:4.1.11"
|
||||
webpack-stats-plugin: "npm:1.1.3"
|
||||
@@ -10341,9 +10327,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"html-minifier-next@npm:^8.1.0":
|
||||
version: 8.1.0
|
||||
resolution: "html-minifier-next@npm:8.1.0"
|
||||
"html-minifier-next@npm:^7.0.0":
|
||||
version: 7.5.3
|
||||
resolution: "html-minifier-next@npm:7.5.3"
|
||||
dependencies:
|
||||
commander: "npm:^15.0.0"
|
||||
entities: "npm:^8.0.0"
|
||||
@@ -10358,7 +10344,7 @@ __metadata:
|
||||
bin:
|
||||
hmn: cli.js
|
||||
html-minifier-next: cli.js
|
||||
checksum: 10/5badf1abd2c6fb9ada2f7dd4929be74f43155510298f7af323b0e39a2c20e2a33ed8de507f105bcea389118f2813e7776b559cf4b2b6397537c5871aa27f2125
|
||||
checksum: 10/fe4ad84f5c577c2de936c7aad5c0a7619c5e7ce3763f3c2e15f1ecc9629d89ebc578a38c0e44883cc0388253de262962610f7dbc3101f8d16e363c477a6a9b07
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11191,14 +11177,14 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:5.4.0":
|
||||
version: 5.4.0
|
||||
resolution: "js-yaml@npm:5.4.0"
|
||||
"js-yaml@npm:5.3.0":
|
||||
version: 5.3.0
|
||||
resolution: "js-yaml@npm:5.3.0"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.mjs
|
||||
checksum: 10/d8216083212772b203a1ee16be2ea96a1638c6d7f0ff769ba4da274b5ad29e858bd26d96761feb8937fce1aa47e8acfa79b2cd21a8ced724cc5555c11b4d056c
|
||||
checksum: 10/6c7e8ea8dd5643d9df73f4aa796f3572537284e9b819b74ef919467452bfa4624f8f7477cfc775c91db7e2f32387133b1b6c4e95aef9702be6bb042ae554884a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11614,7 +11600,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lightningcss@npm:1.33.0, lightningcss@npm:^1.33.0":
|
||||
"lightningcss@npm:1.33.0, lightningcss@npm:^1.32.0, lightningcss@npm:^1.33.0":
|
||||
version: 1.33.0
|
||||
resolution: "lightningcss@npm:1.33.0"
|
||||
dependencies:
|
||||
@@ -11868,15 +11854,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magic-string@npm:^1.2.2":
|
||||
version: 1.2.2
|
||||
resolution: "magic-string@npm:1.2.2"
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec": "npm:^1.5.5"
|
||||
checksum: 10/23514d9fb744d30689037e41440125b6c4bc3fb5b8e6d992798e59f93d7452276f223d062ba9b1ce78fbee5f9cc365c1e0ae3c6babddb74cb3aa9074adb57b03
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magicast@npm:^0.5.2":
|
||||
version: 0.5.4
|
||||
resolution: "magicast@npm:0.5.4"
|
||||
@@ -11958,12 +11935,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"marked@npm:18.0.11":
|
||||
version: 18.0.11
|
||||
resolution: "marked@npm:18.0.11"
|
||||
"marked@npm:18.0.10":
|
||||
version: 18.0.10
|
||||
resolution: "marked@npm:18.0.10"
|
||||
bin:
|
||||
marked: bin/marked.js
|
||||
checksum: 10/6da69f17820c7442358d751e902d3e0e06fef957e1178d634db938cf339c4504399c9f11a953b0a116b1e5bebc1c7fb52726777cc6c351f2c9f39e4a8276e21a
|
||||
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -12081,16 +12058,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minify-literals@npm:2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "minify-literals@npm:2.2.0"
|
||||
"minify-literals@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "minify-literals@npm:2.1.0"
|
||||
dependencies:
|
||||
"@sveltejs/acorn-typescript": "npm:^1.0.13"
|
||||
acorn: "npm:^8.18.0"
|
||||
html-minifier-next: "npm:^8.1.0"
|
||||
lightningcss: "npm:^1.33.0"
|
||||
magic-string: "npm:^1.2.2"
|
||||
checksum: 10/73f17955820675f405b95810f3bf861a05baf426300e8ba46d174987ee45366c885ef1281c40842025529343a4ca71b6fd8294ce674bccbd58685e4a917b9448
|
||||
"@sveltejs/acorn-typescript": "npm:^1.0.10"
|
||||
acorn: "npm:^8.17.0"
|
||||
html-minifier-next: "npm:^7.0.0"
|
||||
lightningcss: "npm:^1.32.0"
|
||||
magic-string: "npm:^0.30.21"
|
||||
checksum: 10/95bcd25afcf638c00bcd8981c2e6409be8c2feb4fd8f954bc9a547b2ade917810cef17bb075ad95e5ceaac6fffc8e625c161ab77b2363c36de53da6d139de872
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -15246,18 +15223,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typescript-eslint@npm:8.68.0":
|
||||
version: 8.68.0
|
||||
resolution: "typescript-eslint@npm:8.68.0"
|
||||
"typescript-eslint@npm:8.67.0":
|
||||
version: 8.67.0
|
||||
resolution: "typescript-eslint@npm:8.67.0"
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.68.0"
|
||||
"@typescript-eslint/parser": "npm:8.68.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.68.0"
|
||||
"@typescript-eslint/utils": "npm:8.68.0"
|
||||
"@typescript-eslint/eslint-plugin": "npm:8.67.0"
|
||||
"@typescript-eslint/parser": "npm:8.67.0"
|
||||
"@typescript-eslint/typescript-estree": "npm:8.67.0"
|
||||
"@typescript-eslint/utils": "npm:8.67.0"
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
typescript: ">=4.8.4 <6.1.0"
|
||||
checksum: 10/451c64296ac5f48e4621a03324f5aaef4a26f1dd9d4df63bd3cb07f136e8379185a800731f83634eb5d909c696fad820ba74d31ab3d7cf263328504275d1305b
|
||||
checksum: 10/4efc2cea5b067f65d58e010fda3888ec94f2e67ebfa289be78c3a02458787b7125cf8f6095776b912005fb69e8748ad4018777b16436a0658957c163ef213bf9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user