Compare commits

..
65 changed files with 907 additions and 1863 deletions

No files matched your search

+2 -2
View File
@@ -2,13 +2,13 @@
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 627654,
"core": 57901,
"core": 55147,
"authorize": 593862,
"onboarding": 709255
},
"frontend-legacy": {
"app": 724225,
"core": 163115,
"core": 157026,
"authorize": 693913,
"onboarding": 812415
}
+8 -21
View File
@@ -117,16 +117,6 @@ module.exports.swcOptions = () => ({
},
});
const corejsPolyfills = (exclude = []) => [
"babel-plugin-polyfill-corejs3",
{
method: "usage-global",
version: dependencies["core-js"],
shippedProposals: true,
exclude,
},
];
module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
babelrc: false,
compact: false,
@@ -148,7 +138,14 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
// Inject Core-JS polyfills on demand. Babel 8 removed preset-env's
// `useBuiltIns`/`corejs` options, so the equivalent polyfill provider is
// configured directly here (`usage-global` matches the old `useBuiltIns: "usage"`).
corejsPolyfills(),
[
"babel-plugin-polyfill-corejs3",
{
method: "usage-global",
version: dependencies["core-js"],
shippedProposals: true,
},
],
[
path.join(BABEL_PLUGINS, "inline-constants-plugin.cjs"),
{
@@ -194,16 +191,6 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
].map((p) => new RegExp(`/node_modules/${p}/`)),
],
},
{
// It only constructs `Uint8Array`s, but any reference to one pulls in
// every typed array polyfill, and this lands in the `core` entrypoint.
test: /[\\/]node_modules[\\/]home-assistant-js-websocket[\\/]/,
plugins: [
corejsPolyfills([
/^(es|esnext)\.(array-buffer|typed-array|uint8-array)\./,
]),
],
},
{
// Use unambiguous for dependencies so that require() is correctly injected into CommonJS files
// Exclusions are needed in some cases where ES modules have no static imports or exports, such as polyfills
+2 -2
View File
@@ -101,7 +101,7 @@
"fullcalendar": "7.1.3",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.8.0",
"home-assistant-js-websocket": "9.7.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
"js-yaml": "5.5.0",
@@ -109,7 +109,7 @@
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.16.0",
"maplibre-gl": "6.15.0",
"marked": "18.1.1",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
-24
View File
@@ -13,27 +13,3 @@ index 95366713472f3ec5f3649547bb1fe202b806a2e6..e91f99229d554e79077486e1caf3602c
originX: e.pinchX,
originY: e.pinchY,
isAvailableBehavior: null
diff --git a/lib/label/LabelManager.js b/lib/label/LabelManager.js
index 3d233be98b14223168726ebb3d9cd9b768fa2554..d6abfe5484ea8c2f0cb3a9d7c7c2fe31e7516fc7 100644
--- a/lib/label/LabelManager.js
+++ b/lib/label/LabelManager.js
@@ -43,6 +43,7 @@
*/
// TODO: move labels out of viewport.
import { BoundingRect, updateProps, initProps, isElementRemoved } from '../util/graphic.js';
+import { getAnimationConfig } from '../animation/basicTransition.js';
import { getECData } from '../util/innerStore.js';
import { parsePercent } from '../util/number.js';
import Transformable from 'zrender/lib/core/Transformable.js';
@@ -395,6 +396,11 @@ var LabelManager = /** @class */function () {
textEl.attr(layoutStore.oldLayoutEmphasis);
}
}
+ // An instant update, e.g. on roam, stops all animations and would leave the fade in half done
+ var updateConfig = getAnimationConfig('update', seriesModel, dataIndex);
+ if (!(updateConfig && updateConfig.duration > 0)) {
+ textEl.stopAnimation('enter', true);
+ }
updateProps(textEl, newProps, seriesModel, dataIndex);
}
layoutStore.oldLayout = newProps;
+26 -26
View File
@@ -171,7 +171,7 @@ overrides:
patchedDependencies:
'@material/[email protected]': 9450e509a86887d8f68ca9cf3f81e6ed70e94e49de13c5f6f20f94519ad4a987
[email protected]: 4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5
[email protected]: b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0
[email protected]: f2ec2ae6877dfc201c542b4f9ded1d03831258bf328ae7bba876d14ce4508a06
[email protected]: cdc7ece96d53357d38ff3c124a2553f86e3a5dc333d641d223eb6f07e28275f0
[email protected]: 75e6346ad8d8af554c167f77ac7d9b9652f010398efdc6b399925a4795133099
@@ -278,7 +278,7 @@ importers:
version: 1.0.3
'@maplibre/maplibre-gl-leaflet':
specifier: 0.1.4
version: 0.1.4(@types/[email protected])([email protected])([email protected]6.0)
version: 0.1.4(@types/[email protected])([email protected])([email protected]5.0)
'@material/mwc-list':
specifier: 0.27.0
version: 0.27.0(patch_hash=9450e509a86887d8f68ca9cf3f81e6ed70e94e49de13c5f6f20f94519ad4a987)
@@ -302,7 +302,7 @@ importers:
version: 4.4.0
'@versatiles/style':
specifier: 7.0.1
version: 7.0.1([email protected]6.0)
version: 7.0.1([email protected]5.0)
'@vibrant/color':
specifier: 4.0.4
version: 4.0.4
@@ -347,10 +347,10 @@ importers:
version: 0.0.1
echarts:
specifier: 6.1.0
version: 6.1.0(patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5)
version: 6.1.0(patch_hash=b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0)
echarts-extension-chart2music:
specifier: 0.1.1
version: 0.1.1([email protected](patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5))([email protected])
version: 0.1.1([email protected](patch_hash=b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0))([email protected])
element-internals-polyfill:
specifier: 3.0.2
version: 3.0.2
@@ -364,8 +364,8 @@ importers:
specifier: 1.7.3
version: 1.7.3
home-assistant-js-websocket:
specifier: 9.8.0
version: 9.8.0
specifier: 9.7.1
version: 9.7.1
idb-keyval:
specifier: 6.3.0
version: 6.3.0
@@ -388,8 +388,8 @@ importers:
specifier: 3.3.3
version: 3.3.3
maplibre-gl:
specifier: 6.16.0
version: 6.16.0
specifier: 6.15.0
version: 6.15.0
marked:
specifier: 18.1.1
version: 18.1.1
@@ -2258,8 +2258,8 @@ packages:
'@maplibre/[email protected]':
resolution: {integrity: sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==}
'@maplibre/[email protected].3':
resolution: {integrity: sha512-dEe5XUd64RQbxNxy2OzIkJnwNDWjVmSXjCd0A7zLirm7QJ/WWbmtDFIkfsiM4yt1cplqzG6oSivRYeWx1SM1yg==}
'@maplibre/[email protected].2':
resolution: {integrity: sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==}
'@marijn/[email protected]':
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
@@ -5205,8 +5205,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==}
home-assistant-js-websocket@9.8.0:
resolution: {integrity: sha512-Nl5asoKQJwJ5a8N0U72jkSAUdO+hsY/T+Upp0VaZMUSt6A2gtdyvKfBIEYbpnYK3NP83Q/YGaC5XYkO5c08Img==}
home-assistant-js-websocket@9.7.1:
resolution: {integrity: sha512-zOAiyF+gUc/IsiswYdWKns6SCBbvWb8iJtMX9+Dk0wlbdfcp+I38T2Vx36krWeWMk/GPwoSiYwCs5hk/+Hsn7w==}
[email protected]:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
@@ -5898,8 +5898,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-C0X0KQmGm3N2ftbTGBhSyuydQ+vV1LC3f3zPvT3RXHXNZrvfPZcoXp/N5DOa8vedX/rTMm2CjTtivFg2STJMRQ==}
[email protected]6.0:
resolution: {integrity: sha512-gApO66EMtoDZuio680RUBvskR7ahFd/NNhLN4tBAs+Y7FeT4gQV8zVFgYVMRmFxQznOEzecW19w6xOd9XjZjiA==}
[email protected]5.0:
resolution: {integrity: sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==}
engines: {node: '>=16.14.0', npm: '>=8.1.0'}
[email protected]:
@@ -9784,11 +9784,11 @@ snapshots:
dependencies:
kdbush: 4.1.0
'@maplibre/[email protected](@types/[email protected])([email protected])([email protected]6.0)':
'@maplibre/[email protected](@types/[email protected])([email protected])([email protected]5.0)':
dependencies:
'@types/leaflet': 1.9.22
leaflet: 1.9.4
maplibre-gl: 6.16.0
maplibre-gl: 6.15.0
'@maplibre/[email protected]':
dependencies:
@@ -9803,7 +9803,7 @@ snapshots:
dependencies:
'@mapbox/point-geometry': 1.1.0
'@maplibre/[email protected].3':
'@maplibre/[email protected].2':
dependencies:
'@mapbox/point-geometry': 1.1.0
'@types/geojson': 7946.0.16
@@ -11279,11 +11279,11 @@ snapshots:
'@unrs/[email protected]':
optional: true
'@versatiles/[email protected]([email protected]6.0)':
'@versatiles/[email protected]([email protected]5.0)':
dependencies:
'@maplibre/maplibre-gl-style-spec': 26.4.4
optionalDependencies:
maplibre-gl: 6.16.0
maplibre-gl: 6.15.0
'@vibrant/[email protected]': {}
@@ -12190,14 +12190,14 @@ snapshots:
optionalDependencies:
wcwidth: 1.0.1
[email protected]([email protected](patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5))([email protected]):
[email protected]([email protected](patch_hash=b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0))([email protected]):
dependencies:
chart2music: 1.20.0([email protected])
echarts: 6.1.0(patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5)
echarts: 6.1.0(patch_hash=b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0)
transitivePeerDependencies:
- typescript
[email protected](patch_hash=4c014bd1952b4d1f21311ed2212dc87192e70b6e782597ecc7a485270247bbb5):
[email protected](patch_hash=b02e30d0fb3e2b865321c17ca1234593db0d05a1365cb6c932cd47c0e673fca0):
dependencies:
tslib: 2.8.1
zrender: 6.1.0
@@ -12962,7 +12962,7 @@ snapshots:
[email protected]: {}
home-assistant-js-websocket@9.8.0: {}
home-assistant-js-websocket@9.7.1: {}
[email protected]:
dependencies:
@@ -13666,7 +13666,7 @@ snapshots:
[email protected]: {}
[email protected]6.0:
[email protected]5.0:
dependencies:
'@mapbox/point-geometry': 1.1.0
'@mapbox/tiny-sdf': 2.3.0
@@ -13675,7 +13675,7 @@ snapshots:
'@maplibre/geojson-vt': 6.3.0
'@maplibre/maplibre-gl-style-spec': 26.4.4
'@maplibre/mlt': 1.3.0
'@maplibre/vt-pbf': 4.3.3
'@maplibre/vt-pbf': 4.3.2
'@types/geojson': 7946.0.16
bidi-js: 1.1.0
earcut: 3.2.4
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20261028.2"
version = "20260930.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
-34
View File
@@ -1,34 +0,0 @@
import type { VisibilityCondition } from "../../panels/lovelace/common/validate-condition";
import { ensureArray } from "../array/ensure-array";
// YAML dashboards can hold entries like `- ` (null) or a bare string.
const isConditionObject = (c: unknown): c is VisibilityCondition =>
typeof c === "object" && c !== null;
/**
* Drop entries that are not condition objects, at any depth. Returns the
* same array when every entry is valid.
*/
export const dropInvalidConditions = (
conditions: VisibilityCondition[]
): VisibilityCondition[] => {
let changed = false;
const valid: VisibilityCondition[] = [];
for (const c of conditions) {
if (!isConditionObject(c)) {
changed = true;
continue;
}
if ("conditions" in c && c.conditions != null) {
const children = ensureArray(c.conditions);
const validChildren = dropInvalidConditions(children);
if (validChildren !== children) {
changed = true;
valid.push({ ...c, conditions: validChildren } as VisibilityCondition);
continue;
}
}
valid.push(c);
}
return changed ? valid : conditions;
};
@@ -26,8 +26,10 @@ export interface QueryParamsControllerOptions {
* Called when the page of the host is navigated to while the host is
* connected, like a second link to an open page or coming back to it. Read
* the URL with `searchParams()` and apply it, then `write()` the state back.
* Gets the `popstate` or `location-changed` event, or nothing when the host
* is connected again.
*/
navigated: () => void;
navigated: (ev?: Event) => void;
/**
* The path of the host's page, for a page that can first be connected on
* a sub-path it sends on to itself, like an add link. Defaults to the path
@@ -159,7 +161,7 @@ export class QueryParamsController<
}
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
this._options.navigated(ev);
}
};
+2 -15
View File
@@ -1,7 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeCssColor } from "../color/compute-color";
import { computeDomain } from "./compute_domain";
import { stateColorCss } from "./state_color";
export const ALERT_DOMAINS = [
"alarm_control_panel",
@@ -10,6 +8,8 @@ export const ALERT_DOMAINS = [
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
@@ -25,16 +25,3 @@ export const computeAlertActiveStates = (entityId: string): string[] => {
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
export const computeAlertColor = (
stateObj: HassEntity,
color: string
): string | undefined => {
if (color === "none") {
return undefined;
}
if (color === "state") {
return stateColorCss(stateObj);
}
return computeCssColor(color);
};
+6 -18
View File
@@ -89,8 +89,6 @@ export const DEFAULT_CHART_WIDTH = 500;
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
export type ZoomSliderMode = "auto" | "always" | "never";
const DEFERRED_PROPS = [
"options",
"data",
@@ -165,10 +163,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "small-controls", type: Boolean })
public smallControls?: boolean;
// "auto" shows the zoom slider once zoomed. "never" hides every zoom
// control, for charts zoomed from outside like the synced History charts.
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
// For charts zoomed from outside, like the synced History charts
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
// For charts sized to their content, like timelines, which grow to fit the
// zoom slider instead of giving up plot height
@@ -620,7 +617,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
>
${
(this._isZoomed || this._isGraphRoamed) &&
this.zoomSlider !== "never" &&
!this.hideZoomControls &&
!showZoomSlider
? html`<ha-icon-button
class="zoom-reset"
@@ -972,7 +969,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (zoomStart !== 0 || zoomEnd !== 100) {
this.zoom(zoomStart, zoomEnd, true);
}
fireEvent(this, "chart-setup");
} finally {
this._loading = false;
this._applyDeferredWork();
@@ -1067,12 +1063,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _showZoomSlider() {
const [start, end] = this._zoomRange;
return (
this.zoomSlider !== "never" &&
!this.hideZoomControls &&
this._hasZoomableXAxis() &&
(this.zoomSlider === "always" ||
this._zoomSliderHeld ||
start !== 0 ||
end !== 100)
(this._zoomSliderHeld || start !== 0 || end !== 100)
);
}
@@ -1303,10 +1296,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
},
bar: { itemStyle: { barBorderWidth: 1.5 } },
graph: {
itemStyle: {
borderColor: style.getPropertyValue("--primary-text-color"),
borderWidth: 0,
},
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor,
@@ -2329,6 +2318,5 @@ declare global {
end: number;
};
"chart-sankeyroam": { zoom: number };
"chart-setup": undefined;
}
}
+21 -141
View File
@@ -69,13 +69,6 @@ export interface NetworkData {
const PHYSICS_DISABLE_THRESHOLD = 512;
const SELECTED_NODE_MAX_ZOOM = 2;
// Keeps neighbors clear of the search field, buttons and legend
const SELECTED_NODE_FIT_MARGIN = 72;
const FOLLOW_ROAM = "follow-selected-node";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let GraphChart: typeof import("echarts/lib/chart/graph/install");
@@ -99,12 +92,6 @@ export class HaNetworkGraph extends LitElement {
@property({ attribute: false }) public searchFilter = "";
/**
* Id of a node to highlight. The view centers on it, zoomed so its neighbors
* stay visible, and follows it until the user moves the view.
*/
@property({ attribute: false }) public selectedNode?: string;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state() private _highlightedNodes?: Set<string>;
@@ -119,10 +106,6 @@ export class HaNetworkGraph extends LitElement {
private _nodePositions: Record<string, { x: number; y: number }> = {};
private _followSelectedNode = false;
private _followedChart?: EChartsType;
@query("ha-chart-base") private _baseChart?: HaChartBase;
constructor() {
@@ -162,13 +145,11 @@ export class HaNetworkGraph extends LitElement {
this._reducedMotion,
this._showLabels,
isMobile,
hasHighlightedNodes,
this.selectedNode
hasHighlightedNodes
)}
.options=${this._createOptions(this.data?.categories)}
height="100%"
.extraComponents=${[GraphChart]}
@chart-setup=${this._updateFollowing}
>
<slot name="search" slot="search"></slot>
<slot name="button" slot="button"></slot>
@@ -231,10 +212,6 @@ export class HaNetworkGraph extends LitElement {
this._applyHighlighting();
this._updateMouseoverHandler();
}
if (changedProperties.has("selectedNode")) {
this._followSelectedNode = !!this.selectedNode;
this._updateFollowing();
}
}
private _nodeMatchesFilter(node: NetworkNode, lowerFilter: string): boolean {
@@ -265,8 +242,7 @@ export class HaNetworkGraph extends LitElement {
reducedMotion: boolean,
showLabels: boolean,
isMobile: boolean,
hasHighlightedNodes?: boolean,
selectedNode?: string
hasHighlightedNodes?: boolean
) => ({
id: "network",
type: "graph",
@@ -309,12 +285,7 @@ export class HaNetworkGraph extends LitElement {
},
edgeSymbol: ["none", "arrow"],
edgeSymbolSize: 10,
data: this._getSeriesData(
data.nodes,
data.links,
this._nodePositions,
selectedNode
),
data: this._getSeriesData(data.nodes, data.links, this._nodePositions),
links: data.links.map((link) => ({
...link,
value: link.reverseValue
@@ -332,28 +303,25 @@ export class HaNetworkGraph extends LitElement {
(
nodes: NetworkNode[],
links: NetworkLink[],
nodePositions: Record<string, { x: number; y: number }>,
selectedNode?: string
nodePositions: Record<string, { x: number; y: number }>
) =>
this._getPositionedNodes(nodes, links, nodePositions).map((node) => {
const selected = node.id === selectedNode;
return {
id: node.id,
name: node.name,
context: node.context,
category: node.category,
value: node.value,
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: selected
? { ...node.itemStyle, borderWidth: 3 }
: node.itemStyle || {},
label: selected ? { ...node.label, fontWeight: "bold" } : node.label,
fixed: node.fixed,
x: node.x,
y: node.y,
} as NonNullable<GraphSeriesOption["data"]>[number];
}),
this._getPositionedNodes(nodes, links, nodePositions).map(
(node) =>
({
id: node.id,
name: node.name,
context: node.context,
category: node.category,
value: node.value,
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: node.itemStyle || {},
label: node.label,
fixed: node.fixed,
x: node.x,
y: node.y,
}) as NonNullable<GraphSeriesOption["data"]>[number]
),
deepEqual
);
@@ -509,94 +477,6 @@ export class HaNetworkGraph extends LitElement {
}
}
private _updateFollowing() {
const chart = this._followSelectedNode ? this._baseChart?.chart : undefined;
if (chart !== this._followedChart) {
const previous = this._followedChart;
if (previous && !previous.isDisposed()) {
previous.off("rendered", this._centerOnSelectedNode);
previous.off("graphroam", this._handleGraphRoam);
previous.getZr().off("drag", this._stopFollowing);
}
chart?.on("rendered", this._centerOnSelectedNode);
chart?.on("graphroam", this._handleGraphRoam);
chart?.getZr().on("drag", this._stopFollowing);
this._followedChart = chart;
}
this._centerOnSelectedNode();
}
private _stopFollowing = () => {
this._followSelectedNode = false;
this._updateFollowing();
};
// A roam dispatched while ECharts renders is queued, so a flag set around
// dispatchAction would be cleared before the event fires
private _handleGraphRoam = (ev: unknown) => {
if ((ev as { from?: string }).from !== FOLLOW_ROAM) {
this._stopFollowing();
}
};
private _centerOnSelectedNode = () => {
const chart = this._followedChart;
if (!chart) {
return;
}
const series: any = chart
// @ts-ignore private method but no other way to get the graph positions
.getModel()
.getSeriesByIndex(0);
const node = series?.getGraph().getNodeById(this.selectedNode);
const position: number[] | undefined = node?.getLayout();
if (!position) {
return;
}
const toPixel = (point: number[]) =>
chart.convertToPixel({ seriesId: "network" }, point);
const [x, y] = toPixel(position);
const zoom = series.coordinateSystem.getRoamTransform()[0];
const centerX = chart.getWidth() / 2;
const centerY = chart.getHeight() / 2;
// Zoom so the neighbors stay in view
const fitX = Math.max(centerX - SELECTED_NODE_FIT_MARGIN, centerX / 2);
const fitY = Math.max(centerY - SELECTED_NODE_FIT_MARGIN, centerY / 2);
let targetZoom = SELECTED_NODE_MAX_ZOOM;
for (const edge of node.edges) {
const neighbor: number[] | undefined = (
edge.node1 === node ? edge.node2 : edge.node1
).getLayout();
if (neighbor) {
const [neighborX, neighborY] = toPixel(neighbor);
targetZoom = Math.min(
targetZoom,
(zoom * fitX) / Math.abs(neighborX - x),
(zoom * fitY) / Math.abs(neighborY - y)
);
}
}
const zoomChanged = Math.abs(zoom - targetZoom) >= 1e-3;
if (
!zoomChanged &&
Math.abs(centerX - x) < 0.5 &&
Math.abs(centerY - y) < 0.5
) {
return;
}
chart.dispatchAction({
type: "graphRoam",
from: FOLLOW_ROAM,
seriesId: "network",
dx: centerX - x,
dy: centerY - y,
// Any zoom, even 1, rescales every node and edge
zoom: zoomChanged ? targetZoom / zoom : undefined,
originX: centerX,
originY: centerY,
});
};
public disconnectedCallback(): void {
super.disconnectedCallback();
if (this._emphasisGuardHandler) {
@@ -24,7 +24,6 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import {
@@ -106,8 +105,8 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@state() private _chartData: LineSeriesOption[] = [];
@@ -160,7 +159,7 @@ export class StateHistoryChartLine extends LitElement {
@dataset-unhidden=${this._datasetUnhidden}
@chart-zoom=${this._handleDataZoom}
.expandLegend=${this.expandLegend}
.zoomSlider=${this.zoomSlider}
.hideZoomControls=${this.hideZoomControls}
.clickLabelForMoreInfo=${this.clickForMoreInfo}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
@@ -22,7 +22,6 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -69,8 +68,8 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public chartIndex?;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@state() private _chartData: CustomSeriesOption[] = [];
@@ -106,7 +105,7 @@ export class StateHistoryChartTimeline extends LitElement {
grow-for-zoom-slider
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
.zoomSlider=${this.zoomSlider}
.hideZoomControls=${this.hideZoomControls}
></ha-chart-base>
`;
}
+2 -2
View File
@@ -218,7 +218,7 @@ export class StateHistoryCharts extends LitElement {
@chart-zoom-with-index=${this._handleTimelineSync}
.height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
?hide-zoom-controls=${this.syncCharts}
></state-history-chart-line>
</div> `;
}
@@ -237,7 +237,7 @@ export class StateHistoryCharts extends LitElement {
.clickForMoreInfo=${this.clickForMoreInfo}
@y-width-changed=${this._yWidthChanged}
@chart-zoom-with-index=${this._handleTimelineSync}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
?hide-zoom-controls=${this.syncCharts}
></state-history-chart-timeline>
</div> `;
};
+21 -24
View File
@@ -29,9 +29,9 @@ import type {
} from "../../common/translations/localize";
import { formattersContext, registriesContext } from "../../data/context";
import type { HomeAssistantFormatters, ValueChangedEvent } from "../../types";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-button";
import "../ha-button-toggle-group";
import "../ha-combo-box-item";
import "../ha-generic-picker";
@@ -121,8 +121,6 @@ export class HaEntityNamePicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-button.add") private _addButton?: HTMLElement;
private _editIndex?: number;
connectedCallback(): void {
@@ -161,7 +159,7 @@ export class HaEntityNamePicker extends LitElement {
return html`
<div class="container">
<div class="header">
${this.label ? html`<label id="label">${this.label}</label>` : nothing}
${this.label ? html`<label>${this.label}</label>` : nothing}
${
this.allowCustom
? html`
@@ -266,9 +264,7 @@ export class HaEntityNamePicker extends LitElement {
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
<ha-chip-set>
${repeat(
this._items,
(item) => item,
@@ -294,16 +290,22 @@ export class HaEntityNamePicker extends LitElement {
`;
}
)}
<ha-button
class="add"
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._addItem}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this._localize("ui.common.add")}
</ha-button>
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._localize(
"ui.components.entity.entity-name-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`
}
</ha-chip-set>
</ha-sortable>
${this._renderRestoreButton()}
@@ -371,10 +373,6 @@ export class HaEntityNamePicker extends LitElement {
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
value.splice(idx, 1);
this._setValue(value);
if (!value.length) {
await this.updateComplete;
this._addButton?.focus();
}
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
@@ -631,7 +629,7 @@ export class HaEntityNamePicker extends LitElement {
label {
display: block;
font-weight: var(--ha-font-weight-medium);
font-weight: 500;
}
.content {
@@ -724,8 +722,7 @@ export class HaEntityNamePicker extends LitElement {
ha-input-helper-text {
display: block;
padding: 0;
margin: var(--ha-space-1) 0 0;
margin: var(--ha-space-2) 0 0;
}
`;
}
@@ -245,15 +245,15 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
const newOrder: string[] = [];
for (const floorId of floorIds) {
const isOnFloor = (areaId: string) =>
(this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR) === floorId;
if ((currentFloorId ?? UNASSIGNED_FLOOR) === floorId) {
// Reordering returns the hidden areas of every floor, not just this one
newHidden.push(...(value.hidden ?? []).filter(isOnFloor));
newHidden.push(...(value.hidden ?? []));
newOrder.push(...(value.order ?? []));
continue;
}
const hidden = oldHidden.filter(isOnFloor);
const hidden = oldHidden.filter((areaId) => {
const id = this._areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
return id === floorId;
});
if (hidden?.length) {
newHidden.push(...hidden);
}
+33 -65
View File
@@ -1,9 +1,7 @@
import type { ContextType } from "@lit/context";
import { mdiPlus } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -28,10 +26,6 @@ import "./chips/ha-input-chip";
import "./ha-combo-box-item";
import "./ha-domain-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-button";
import "./ha-svg-icon";
import "./ha-input-helper-text";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
@@ -88,8 +82,6 @@ export class HaEntityTypePicker extends LitElement {
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _titlesLoaded = false;
protected willUpdate() {
@@ -245,13 +237,40 @@ export class HaEntityTypePicker extends LitElement {
const localize = this._i18n?.localize;
const value = this.value ?? [];
const chipLabels = localize ? this._chipLabels(localize, value) : undefined;
const addLabel = localize?.("ui.common.add");
return html`
${this.label ? html`<label id="label">${this.label}</label>` : nothing}
${this.label ? html`<label>${this.label}</label>` : nothing}
${
value.length && chipLabels
? html`
<ha-chip-set>
${repeat(
value,
(key) => key,
(key) => {
const label = chipLabels.get(key)!;
return html`
<ha-input-chip
data-key=${key}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${this._renderIcon(key, "icon")} ${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
`
: nothing
}
<ha-generic-picker
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.value=${""}
.addButtonLabel=${localize?.("ui.components.entity-type-picker.add")}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.searchFn=${this._searchFn}
@@ -259,53 +278,10 @@ export class HaEntityTypePicker extends LitElement {
.emptyLabel=${localize?.("ui.components.entity-type-picker.no_types")}
no-sort
@value-changed=${this._itemAdded}
>
<ha-chip-set
slot="field"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
value,
(key) => key,
(key) => {
const label = chipLabels?.get(key) ?? key;
return html`
<ha-input-chip
data-key=${key}
.label=${label}
.disabled=${this.disabled}
@remove=${this._removeItem}
selected
>
${this._renderIcon(key, "icon")} ${label}
</ha-input-chip>
`;
}
)}
<ha-button
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${addLabel}
</ha-button>
</ha-chip-set>
</ha-generic-picker>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing
}
></ha-generic-picker>
`;
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this._picker?.open();
}
private _itemAdded(ev: CustomEvent<{ value?: string }>) {
ev.stopPropagation();
const key = ev.detail.value;
@@ -344,15 +320,7 @@ export class HaEntityTypePicker extends LitElement {
width: 100%;
}
ha-chip-set {
background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm);
border-top-left-radius: var(--ha-border-radius-sm);
padding: var(--ha-space-3);
}
ha-input-helper-text {
padding: 0;
margin-top: var(--ha-space-1);
padding: var(--ha-space-2) 0;
}
`;
}
+54
View File
@@ -6,6 +6,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { consume } from "../common/decorators/consume";
import {
@@ -23,10 +24,21 @@ import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
import "./ha-badge";
import "./ha-list";
import "./ha-svg-icon";
import "./ha-tooltip";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
export interface IntegrationFilterCount {
key: string;
path: string;
count: number;
label: string;
color?: string;
}
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@property({ attribute: false }) public value?: string[];
@@ -35,6 +47,12 @@ export class HaFilterIntegrations extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
/** Counts to show beside each integration, by domain. */
@property({ attribute: false }) public counts?: Record<
string,
IntegrationFilterCount[]
>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -120,6 +138,7 @@ export class HaFilterIntegrations extends LitElement {
brand-fallback
></ha-domain-icon>
${integration.name}
${this._renderCounts(integration.domain)}
</ha-check-list-item>`
)}
</ha-list>`
@@ -131,6 +150,32 @@ export class HaFilterIntegrations extends LitElement {
`;
}
private _renderCounts(domain: string) {
const counts = this.counts?.[domain];
if (!counts?.length) {
return nothing;
}
return html`<span class="counts">
${counts.map(
(item) =>
html`<ha-badge
id="count-${domain}-${item.key}"
role="img"
aria-label=${item.label}
style=${styleMap({ "--badge-color": item.color })}
>
<ha-svg-icon slot="icon" .path=${item.path}></ha-svg-icon>
${item.count}
</ha-badge>
<ha-tooltip for="count-${domain}-${item.key}">
${item.label}
</ha-tooltip>`
)}
</span>`;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -246,6 +291,15 @@ export class HaFilterIntegrations extends LitElement {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
.counts {
display: inline-flex;
gap: var(--ha-space-1);
margin-inline-start: var(--ha-space-2);
vertical-align: middle;
--ha-badge-size: 22px;
--ha-badge-icon-size: 14px;
--ha-badge-font-size: var(--ha-font-size-xs);
}
`,
];
}
+65 -105
View File
@@ -1,28 +1,26 @@
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { mdiDragHorizontalVariant } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
import { ensureArray } from "../../common/array/ensure-array";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { internationalizationContext } from "../../data/context";
import type { SelectOption, SelectSelector } from "../../data/selector";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistantInternationalization } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-checkbox";
import "../ha-dropdown-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-button";
import "../ha-input-helper-text";
import "../ha-select";
import "../ha-select-box";
import "../ha-sortable";
import "../ha-svg-icon";
import "../radio/ha-radio-group";
import "../radio/ha-radio-option";
@@ -30,7 +28,10 @@ import "../radio/ha-radio-option";
export class HaSelectSelector extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@property({ attribute: false }) public selector!: SelectSelector;
@@ -47,8 +48,6 @@ export class HaSelectSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
@@ -84,11 +83,7 @@ export class HaSelectSelector extends LitElement {
if (this.selector.select?.sort) {
options.sort((a, b) =>
caseInsensitiveStringCompare(
a.label,
b.label,
this._i18n.locale.language
)
caseInsensitiveStringCompare(a.label, b.label, this._locale.language)
);
}
@@ -141,8 +136,8 @@ export class HaSelectSelector extends LitElement {
const value =
!this.value || this.value === "" ? [] : ensureArray(this.value);
return html`
<div class="checkbox-list">
${this.label} ${this._renderHelper()}
<div>
${this.label}
${options.map(
(item: SelectOption) => html`
<ha-checkbox
@@ -156,6 +151,7 @@ export class HaSelectSelector extends LitElement {
`
)}
</div>
${this._renderHelper()}
`;
}
@@ -163,80 +159,67 @@ export class HaSelectSelector extends LitElement {
const value =
!this.value || this.value === "" ? [] : ensureArray(this.value);
const addLabel = this._i18n.localize("ui.common.add");
return html`
${
this.label
? html`<span id="label" class="label chips-label"
>${this.label}</span
>`
value?.length
? html`
<ha-sortable
no-style
.disabled=${!this.selector.select.reorder}
@item-moved=${this._itemMoved}
handle-selector="button.primary.action"
>
<ha-chip-set>
${repeat(
value,
(item) => item,
(item, idx) => {
const label =
options.find((option) => option.value === item)
?.label || item;
return html`
<ha-input-chip
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
this.selector.select?.reorder
? html`
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing
}
${
options.find((option) => option.value === item)
?.label || item
}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
</ha-sortable>
`
: nothing
}
<ha-generic-picker
no-sort
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.addButtonLabel=${this.label}
.getItems=${this._getItems(options, value, true)}
.allowCustomValue=${this.selector.select.custom_value ?? false}
@value-changed=${this._comboBoxValueChanged}
>
<ha-sortable
slot="field"
no-style
.disabled=${!this.selector.select.reorder}
@item-moved=${this._itemMoved}
draggable-selector="ha-input-chip"
handle-selector="button.primary.action"
>
<ha-chip-set
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${repeat(
value,
(item) => item,
(item, idx) => {
const label =
options.find((option) => option.value === item)?.label ||
item;
return html`
<ha-input-chip
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
this.selector.select?.reorder
? html`
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing
}
${label}
</ha-input-chip>
`;
}
)}
<ha-button
size="s"
appearance="filled"
.disabled=${this.disabled}
@click=${this._openPicker}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${addLabel}
</ha-button>
</ha-chip-set>
</ha-sortable>
</ha-generic-picker>
${this._renderHelper()}
></ha-generic-picker>
`;
}
@@ -281,11 +264,6 @@ export class HaSelectSelector extends LitElement {
: "";
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this._picker?.open();
}
private _getOptions = memoizeOne(
(selector: SelectSelector) =>
selector.select?.options?.map((option) =>
@@ -426,37 +404,19 @@ export class HaSelectSelector extends LitElement {
ha-dropdown-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
ha-chip-set {
padding: 8px 0;
}
.label {
display: block;
margin: 0 0 8px;
}
.chips-label {
font-weight: var(--ha-font-weight-medium);
}
ha-sortable ha-chip-set {
background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm);
border-top-left-radius: var(--ha-border-radius-sm);
padding: var(--ha-space-3);
}
ha-select-box + ha-input-helper-text {
margin-top: 4px;
}
ha-generic-picker + ha-input-helper-text {
padding: 0;
margin-top: 4px;
}
.checkbox-list ha-input-helper-text {
padding: 0;
margin-top: 4px;
}
.sortable-fallback {
display: none;
opacity: 0;
@@ -98,9 +98,8 @@ export class HaUnitOfMeasurementSelector extends LitElement {
return;
}
if (units.length === 1 && units[0] !== null) {
// Preselect the only allowed unit; "no unit" is not allowed either, so
// this also applies to an optional field
if (this.required && units.length === 1 && units[0] !== null) {
// Preselect the only allowed unit
fireEvent(this, "value-changed", { value: units[0] });
}
}
+2 -15
View File
@@ -14,7 +14,6 @@ import {
query,
state,
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { loadVirtualizer } from "../../resources/virtualizer";
import { HaListItemBase } from "../item/ha-list-item-base";
@@ -129,7 +128,7 @@ export class HaListVirtualized extends HaListBase {
tabindex="-1"
scroller
.items=${this.rows}
.renderItem=${this._getRenderItem(this.rowRenderer)}
.renderItem=${this.rowRenderer}
style="min-height: 36px; height: 100%;"
.layout=${
!this._unpinned && this.pinIndex !== undefined
@@ -364,19 +363,7 @@ export class HaListVirtualized extends HaListBase {
return Math.max(1, this.rangeEnd - this.rangeStart + 1);
}
// The virtualizer renders its last range again when it re-renders before it
// lays out shorter rows, so rows past the end arrive as undefined.
private _keyFunction = (
item: HaListVirtualizedItem | undefined,
index: number
) => item?.id ?? index;
private _getRenderItem = memoizeOne(
(rowRenderer?: RenderItemFunction<HaListVirtualizedItem>) =>
rowRenderer &&
((item: HaListVirtualizedItem | undefined, index: number) =>
item ? rowRenderer(item, index) : nothing)
);
private _keyFunction = (item: HaListVirtualizedItem) => item.id;
@eventOptions({ passive: true })
private _handleUnpinned() {
-1
View File
@@ -1,5 +1,4 @@
import "@webcomponents/scoped-custom-element-registry/scoped-custom-element-registry.min";
import "../resources/scoped-registry-ready";
import "../layouts/home-assistant";
import("../resources/append-ha-style");
+13 -17
View File
@@ -152,6 +152,16 @@
window.latestJS = true;
}
</script>
<% if (obj.hassUrl === "") { %>
<script>
{%- for extra_module in extra_modules -%}
// Caught, or the boot recovery guard reads it as a stale build.
import("{{ extra_module }}").catch(function (err) {
console.error("Failed to load extra module {{ extra_module }}", err);
});
{%- endfor -%}
</script>
<% } %>
<script>
if (!window.latestJS) {
// Below the [legacy] browserslist floor the entry bundles cannot be
@@ -176,24 +186,10 @@
<% if (obj.hassUrl === "") { %>
<script>
// The app entry calls this right after it installs the scoped custom
// element registry polyfill, so extra modules define their elements in
// the registry that the app uses.
window.__haLoadExtraModules = function () {
{%- for extra_module in extra_modules -%}
// Caught, or the boot recovery guard reads it as a stale build.
import("{{ extra_module }}").catch(function (err) {
console.error("Failed to load extra module {{ extra_module }}", err);
});
if (!window.latestJS) {
{%- for extra_script in extra_js_es5 -%}
_ls("{{ extra_script }}");
{%- endfor -%}
if (!window.latestJS) {
{%- for extra_script in extra_js_es5 -%}
_ls("{{ extra_script }}");
{%- endfor -%}
}
};
if (window.__haScopedRegistryReady) {
window.__haLoadExtraModules();
}
</script>
<% } %>
@@ -1064,9 +1064,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
align-items: center;
color: var(--secondary-text-color);
}
.search-toolbar ha-input-search {
min-width: 0;
}
.filters {
--mdc-text-field-fill-color: var(--input-fill-color);
--mdc-text-field-idle-line-color: var(--input-idle-line-color);
+1 -7
View File
@@ -180,13 +180,6 @@ export class HomeAssistantMain extends LitElement {
color: var(--primary-text-color);
/* remove the grey tap highlights in iOS on the fullscreen touch targets */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
/* Keep the negative z-index panel backgrounds (the resolver's ::before
and the Lovelace view background) in this host's stacking context.
Isolating the conditionally rendered partial-panel-resolver instead
can intermittently leave the Android WebView (Chromium 109-124)
accessibility tree with an empty panel subtree while the page paints
normally, which breaks UI automation such as the companion app E2E. */
isolation: isolate;
--ha-sidebar-width: calc(56px + var(--safe-area-inset-left, 0px));
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
--safe-area-content-inset-left: 0px;
@@ -208,6 +201,7 @@ export class HomeAssistantMain extends LitElement {
partial-panel-resolver {
display: block;
height: 100%;
isolation: isolate;
}
partial-panel-resolver::before {
content: "";
+7 -9
View File
@@ -5,7 +5,6 @@ import type { ConditionEvaluation } from "../common/controllers/condition-evalua
import { ConditionEvaluatorController } from "../common/controllers/condition-evaluator-controller";
import { maxColumnsContext } from "../panels/lovelace/common/context";
import { evaluateConditionsLocally } from "../common/condition/evaluate-locally";
import { dropInvalidConditions } from "../common/condition/sanitize";
import type {
ConditionContext,
VisibilityCondition,
@@ -154,28 +153,27 @@ export const ConditionalListenerMixin = <
const finalConditions = conditions ?? config?.visibility;
const entityId = this._conditionContext.entity_id;
this.__conditions = finalConditions;
// Fold in the host entity and keep a stable array across hass updates.
if (
finalConditions !== this.__observedSource ||
entityId !== this.__observedEntityId
) {
const validConditions =
finalConditions && dropInvalidConditions(finalConditions);
// Tree content changed; don't keep the previous result for a frame.
const signature = validConditions
? JSON.stringify(validConditions)
const signature = finalConditions
? JSON.stringify(finalConditions)
: undefined;
if (signature !== this.__conditionsSignature) {
this.__conditionsSignature = signature;
this.__conditionResult = "unknown";
}
this.__conditions = validConditions;
this.__observedSource = finalConditions;
this.__observedEntityId = entityId;
this.__observed =
validConditions && entityId
? validConditions.map((c) => addEntityToCondition(c, entityId))
: validConditions;
finalConditions && entityId
? finalConditions.map((c) => addEntityToCondition(c, entityId))
: finalConditions;
}
this.__conditionEvaluator.observe(
@@ -942,7 +942,6 @@ class HaConfigAreaPage extends LitElement {
:host {
display: block;
height: 100%;
}
.container {
display: flex;
@@ -27,7 +27,6 @@ import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeEntityNameParts } from "../../../../common/entity/compute_entity_name_display";
import { getDeviceAreaId } from "../../../../common/entity/context/get_device_context";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-button";
import "../../../../components/ha-floor-icon";
import "../../../../components/ha-icon";
@@ -1068,16 +1067,10 @@ export default class HaAutomationAddFromTarget extends LitElement {
private _renderEntityIcon =
(stateObj: HassEntity) => (slot: string | undefined) =>
stateObj.attributes.entity_picture ||
stateObj.attributes.entity_picture_local
? html`<state-badge
slot=${ifDefined(slot)}
.stateObj=${stateObj}
></state-badge>`
: html`<ha-state-icon
slot=${ifDefined(slot)}
.stateObj=${stateObj}
></ha-state-icon>`;
html`<ha-state-icon
slot=${ifDefined(slot)}
.stateObj=${stateObj}
></ha-state-icon>`;
private _renderItem(
label: string,
@@ -1902,8 +1895,6 @@ export default class HaAutomationAddFromTarget extends LitElement {
state-badge {
width: 24px;
height: 24px;
flex-shrink: 0;
margin: var(--ha-space-1);
}
wa-tree-item[selected]::part(item):hover {
@@ -8,7 +8,6 @@ import {
mdiPlaylistCheck,
mdiRobotOutline,
mdiScriptTextOutline,
mdiViewDashboardOutline,
} from "@mdi/js";
import { consume } from "../../../../common/decorators/consume";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
@@ -35,8 +34,7 @@ type DeviceAddToAction =
kind: "add-to";
key: AddToAutomationScriptActionKey;
})
| (AddToActionListItem & { kind: "scene" })
| (AddToActionListItem & { kind: "dashboard" });
| (AddToActionListItem & { kind: "scene" });
@customElement("dialog-device-add-to")
export class DialogDeviceAddTo extends LitElement {
@@ -96,20 +94,6 @@ export class DialogDeviceAddTo extends LitElement {
}
const sections: AddToActionListSection<DeviceAddToAction>[] = [
{
title: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.dashboards_heading"
),
actions: [
{
kind: "dashboard",
iconPath: mdiViewDashboardOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.dashboard"
),
},
],
},
{
title: this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
@@ -202,11 +186,6 @@ export class DialogDeviceAddTo extends LitElement {
this._handleCreateScene();
return;
}
if (action.kind === "dashboard") {
this.closeDialog();
this._params.addToDashboard();
return;
}
this._handleAddToAction(action.key);
}
@@ -4,6 +4,7 @@ import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import { until } from "lit/directives/until";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-icon";
import "../../../../components/ha-list";
@@ -13,6 +14,11 @@ import type { EntityRegistryEntry } from "../../../../data/entity/entity_registr
import { entryIcon } from "../../../../data/icons";
import { showMoreInfoDialog } from "../../../../dialogs/more-info/show-ha-more-info-dialog";
import type { HomeAssistant } from "../../../../types";
import {
computeCards,
computeSection,
} from "../../../lovelace/common/generate-lovelace-config";
import { addEntitiesToLovelaceView } from "../../../lovelace/editor/add-entities-to-view";
import type { EntityRegistryStateEntry } from "../ha-config-device-page";
import { entityRowElement } from "../../../lovelace/entity-rows/entity-row-element-directive";
@@ -120,6 +126,13 @@ export class HaDeviceEntitiesCard extends LitElement {
</div>`
: nothing
}
<div class="card-actions">
<ha-button appearance="plain" @click=${this._addToLovelaceView}>
${this.hass.localize(
"ui.panel.config.devices.entities.add_entities_lovelace"
)}
</ha-button>
</div>
</ha-card>
`;
}
@@ -176,6 +189,27 @@ export class HaDeviceEntitiesCard extends LitElement {
showMoreInfoDialog(this, { entityId: entry.entity_id });
}
private _addToLovelaceView(): void {
const entities = this.entities
.filter((entity) => !entity.disabled_by)
.map((entity) => entity.entity_id);
addEntitiesToLovelaceView(
this,
this.hass,
computeCards(
this.hass,
entities,
{ title: this.deviceName },
{ entityName: { type: "entity" } }
),
computeSection(entities, {
title: this.deviceName,
}),
entities
);
}
static styles = css`
:host {
display: block;
@@ -249,6 +283,9 @@ export class HaDeviceEntitiesCard extends LitElement {
height: 40px;
--mdc-ripple-color: transparent;
}
.card-actions {
padding: 4px 16px 4px 4px;
}
`;
}
@@ -5,7 +5,6 @@ export interface DeviceAddToDialogParams {
device: DeviceRegistryEntry;
entityIds: string[];
canCreateScene: boolean;
addToDashboard: () => void;
}
export const loadDeviceAddToDialog = () => import("./ha-device-add-to-dialog");
+196 -190
View File
@@ -117,8 +117,6 @@ import type { ESPHomeSetupController } from "./device-detail/integration-element
import "./device-detail/ha-device-linked-devices-card";
import "./device-detail/ha-device-via-devices-card";
import { showDeviceAddToDialog } from "./device-detail/show-dialog-device-add-to";
import { addEntitiesToLovelaceView } from "../../lovelace/editor/add-entities-to-view";
import { showAddDeviceCardDialog } from "../../lovelace/editor/card-editor/show-add-device-card-dialog";
import {
loadDeviceRegistryDetailDialog,
showDeviceRegistryDetailDialog,
@@ -517,186 +515,205 @@ export class HaConfigDevicePage extends LitElement {
const hasSceneSupport =
isComponentLoaded(this.hass.config, "scene") && sceneEntityIds.length;
const relatedCard = html`
<ha-card outlined>
<h1 class="card-header">
${this.hass.localize(
"ui.panel.config.devices.automation.related_heading"
)}
<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
.disabled=${device.disabled_by}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.dialogs.more_info_control.add_to.item")}
</ha-button>
</h1>
${
!this._related
? html`
<div class="card-content loading">
<ha-spinner></ha-spinner>
</div>
`
: this._related.automation?.length ||
this._related.script?.length ||
this._related.scene?.length
? html`
${
isComponentLoaded(this.hass.config, "automation") &&
this._related.automation?.length
? html`
<h3 class="section-header">
${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
>
${this._getRelated(this._related).automation.map(
(automation) =>
automation
? html`<ha-list-item-button
.headline=${this.hass.formatEntityName(automation)}
.href=${
automation.attributes.id
? `/config/automation/edit/${encodeURIComponent(automation.attributes.id)}`
: `/config/automation/show/${automation.entity_id}`
const relatedCard =
isComponentLoaded(this.hass.config, "automation") ||
isComponentLoaded(this.hass.config, "script") ||
hasSceneSupport
? html`
<ha-card outlined>
<h1 class="card-header">
${this.hass.localize(
"ui.panel.config.devices.automation.related_heading"
)}
<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
.disabled=${device.disabled_by}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>
</h1>
${
!this._related
? html`
<div class="card-content loading">
<ha-spinner></ha-spinner>
</div>
`
: this._related.automation?.length ||
this._related.script?.length ||
this._related.scene?.length
? html`
${
isComponentLoaded(this.hass.config, "automation") &&
this._related.automation?.length
? html`
<h3 class="section-header">
${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
>
${this._getRelated(
this._related
).automation.map((automation) =>
automation
? html`<ha-list-item-button
.headline=${this.hass.formatEntityName(automation)}
.href=${
automation.attributes.id
? `/config/automation/edit/${encodeURIComponent(automation.attributes.id)}`
: `/config/automation/show/${automation.entity_id}`
}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>`
: nothing
)}
</ha-list-nav>
`
: nothing
}
${
isComponentLoaded(this.hass.config, "script") &&
this._related.script?.length
? html`
<h3 class="section-header">
${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
</h3>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${this._getRelated(this._related).script.map(
(script) => {
if (!script) {
return nothing;
}
const entry = this._entityReg.find(
(e) => e.entity_id === script.entity_id
);
const url = entry
? `/config/script/edit/${entry.unique_id}`
: `/config/script/show/${script.entity_id}`;
return html`
<ha-list-item-button
.headline=${this.hass.formatEntityName(script)}
.href=${url}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>
`;
}
)}
</ha-list-nav>
`
: nothing
}
${
hasSceneSupport && this._related.scene?.length
? html`
<h3 class="section-header">
${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
</h3>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${this._getRelated(this._related).scene.map(
(scene) => {
if (!scene) {
return nothing;
}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
: nothing
)}
</ha-list-nav>
`
: nothing
}
${
isComponentLoaded(this.hass.config, "script") &&
this._related.script?.length
? html`
<h3 class="section-header">
${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
</h3>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.script.scripts_heading"
)}
>
${this._getRelated(this._related).script.map(
(script) => {
if (!script) {
return nothing;
}
const entry = this._entityReg.find(
(e) => e.entity_id === script.entity_id
);
const url = entry
? `/config/script/edit/${entry.unique_id}`
: `/config/script/show/${script.entity_id}`;
return html`
<ha-list-item-button
.headline=${this.hass.formatEntityName(script)}
.href=${url}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
}
)}
</ha-list-nav>
`
: nothing
}
${
hasSceneSupport && this._related.scene?.length
? html`
<h3 class="section-header">
${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
</h3>
<ha-list-nav
.ariaLabel=${this.hass.localize(
"ui.panel.config.devices.scene.scenes_heading"
)}
>
${this._getRelated(this._related).scene.map(
(scene) => {
if (!scene) {
return nothing;
}
const sceneId = `scene-${slugify(
scene.entity_id
)}`;
const sceneId = `scene-${slugify(
scene.entity_id
)}`;
return scene.attributes.id
? html`
<ha-list-item-button
.headline=${this.hass.formatEntityName(scene)}
.href=${`/config/scene/edit/${scene.attributes.id}`}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
: html`
<ha-list-item-base
id=${sceneId}
.headline=${this.hass.formatEntityName(scene)}
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-base>
<ha-tooltip
.for=${sceneId}
placement=${
computeRTL(
this.hass.language,
this.hass.translationMetadata
.translations
)
? "left"
: "right"
}
>
${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</ha-tooltip>
`;
}
)}
</ha-list-nav>
`
: nothing
}
`
: html`
<div class="card-content">
${this.hass.localize("ui.panel.config.devices.add_prompt", {
name: this.hass.localize(
"ui.panel.config.devices.automation.automations_scripts_or_scenes"
),
type: this.hass.localize(
`ui.panel.config.devices.type.${
device.entry_type || "device"
}`
),
})}
${add_prompt}
</div>
`
}
</ha-card>
`;
return scene.attributes.id
? html`
<ha-list-item-button
.headline=${this.hass.formatEntityName(scene)}
.href=${`/config/scene/edit/${scene.attributes.id}`}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-button>
`
: html`
<ha-list-item-base
id=${sceneId}
.headline=${this.hass.formatEntityName(scene)}
>
<ha-icon-next
slot="end"
></ha-icon-next>
</ha-list-item-base>
<ha-tooltip
.for=${sceneId}
placement=${
computeRTL(
this.hass.language,
this.hass.translationMetadata
.translations
)
? "left"
: "right"
}
>
${this.hass.localize(
"ui.panel.config.devices.cant_edit"
)}
</ha-tooltip>
`;
}
)}
</ha-list-nav>
`
: nothing
}
`
: html`
<div class="card-content">
${this.hass.localize(
"ui.panel.config.devices.add_prompt",
{
name: this.hass.localize(
"ui.panel.config.devices.automation.automations_scripts_or_scenes"
),
type: this.hass.localize(
`ui.panel.config.devices.type.${
device.entry_type || "device"
}`
),
}
)}
${add_prompt}
</div>
`
}
</ha-card>
`
: "";
const infoColumn = html`
${
@@ -1429,16 +1446,6 @@ export class HaConfigDevicePage extends LitElement {
canCreateScene:
isComponentLoaded(this.hass.config, "scene") &&
sceneEntityIds.length > 0,
addToDashboard: () => this._addToDashboard(),
});
}
private _addToDashboard() {
const deviceId = this.deviceId;
addEntitiesToLovelaceView(this, this.hass, {
sectionStrategy: { type: "device", device_id: deviceId },
showCardDialog: (params) =>
showAddDeviceCardDialog(this, { ...params, deviceId }),
});
}
@@ -1645,7 +1652,6 @@ export class HaConfigDevicePage extends LitElement {
css`
:host {
display: block;
height: 100%;
}
.container {
display: flex;
@@ -101,8 +101,6 @@ export class DialogYamlIntegration extends LitElement {
ha-dialog {
/* Place above other dialogs */
--dialog-z-index: 104;
--ha-dialog-header-white-space: normal;
--ha-dialog-header-title-height: auto;
}
`;
}
@@ -68,7 +68,7 @@ class ZHAConfigDashboardRouter extends HassRouterPage {
} else if (this._currentPage === "device") {
el.ieee = this.routeTail.path.split("/")[1];
} else if (this._currentPage === "visualization") {
el.deviceId = this.routeTail.path.substr(1);
el.zoomedDeviceIdFromURL = this.routeTail.path.substr(1);
} else if (this._currentPage === "section") {
el.sectionId = this.routeTail.path.substr(1);
}
@@ -39,8 +39,6 @@ export class ZHANetworkVisualizationPage extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public deviceId?: string;
@state()
private _networkData: NetworkData = {
nodes: [],
@@ -80,11 +78,6 @@ export class ZHANetworkVisualizationPage extends LitElement {
.searchFilter=${this._searchFilter}
.data=${this._networkData}
.searchableAttributes=${this._getSearchableAttributes}
.selectedNode=${
this._devices.find(
(device) => device.device_reg_id === this.deviceId
)?.ieee
}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
>
+225 -32
View File
@@ -1,9 +1,14 @@
import type { ContextType } from "@lit/context";
import {
mdiAlert,
mdiAlertCircle,
mdiAlertOctagon,
mdiBugOutline,
mdiChevronDown,
mdiChip,
mdiDns,
mdiFilterVariant,
mdiInformationOutline,
mdiPackageVariant,
mdiPuzzle,
mdiRadar,
@@ -17,16 +22,14 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { atLeastVersion } from "../../../common/config/version";
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
import { consume } from "../../../common/decorators/consume";
import { storage } from "../../../common/decorators/storage";
import type {
HASSDomEvent,
HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import {
getHistoryState,
navigate,
updateHistoryState,
} from "../../../common/navigate";
import { navigate } from "../../../common/navigate";
import { stringCompare } from "../../../common/string/compare";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { currentPath } from "../../../common/url/current-path";
import { deepEqual } from "../../../common/util/deep-equal";
import {
@@ -35,12 +38,14 @@ import {
logsLinkFromUrl,
logsQueryParamConfig,
logsQueryParams,
type LogsState,
} from "../../../common/url/logs-query-params";
import { createQueryString } from "../../../common/url/query-params";
import "../../../components/ha-app-icon";
import "../../../components/ha-button";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-filter-integrations";
import type { IntegrationFilterCount } from "../../../components/ha-filter-integrations";
import "../../../components/ha-filter-pane";
import "../../../components/ha-filter-pane-chip";
import "../../../components/ha-generic-picker";
@@ -55,6 +60,7 @@ import {
fetchHassioAddonsInfo,
type HassioAddonInfo,
} from "../../../data/hassio/addon";
import type { LoggedError, SystemLogLevel } from "../../../data/system_log";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { mdiHomeAssistant } from "../../../resources/home-assistant-logo-svg";
@@ -66,6 +72,18 @@ import type { SystemLogCard } from "./system-log-card";
const NO_INTEGRATIONS: string[] = [];
const LOG_LEVELS: {
level: SystemLogLevel;
path: string;
color?: string;
}[] = [
{ level: "critical", path: mdiAlertOctagon, color: "var(--error-color)" },
{ level: "error", path: mdiAlertCircle, color: "var(--error-color)" },
{ level: "warning", path: mdiAlert, color: "var(--warning-color)" },
{ level: "info", path: mdiInformationOutline },
{ level: "debug", path: mdiBugOutline },
];
const logProviders: LogProvider[] = [
{
key: "core",
@@ -93,8 +111,6 @@ const logProviders: LogProvider[] = [
},
];
const LOGS_HISTORY_STATE = "logsPage";
interface LogProviderPickerItem extends PickerComboBoxItem {
addon?: HassioAddonInfo;
hasAppIcon?: boolean;
@@ -128,6 +144,29 @@ export class HaConfigLogs extends LitElement {
@state() private _integrationsExpanded = true;
@state() private _logItems?: LoggedError[];
// What the user picked, shown again when the page is opened without params,
// like from the sidebar. What a link shows isn't stored.
@storage({
storage: "sessionStorage",
key: "logs-search",
state: false,
subscribe: false,
})
private _storedSearch = "";
@storage({
storage: "sessionStorage",
key: "logs-integrations",
state: false,
subscribe: false,
})
private _storedIntegrations: string[] = [];
@storage({ key: "logs-raw", state: false, subscribe: false })
private _storedRaw = false;
@state()
@consume({ context: manifestsContext, subscribe: true })
private _manifests?: ContextType<typeof manifestsContext>;
@@ -138,7 +177,7 @@ export class HaConfigLogs extends LitElement {
private _queryParams = new QueryParamsController(this, logsQueryParamConfig, {
enabled: () => true,
navigated: () => this._applyUrl(),
navigated: (ev) => this._navigated(ev),
});
public connectedCallback() {
@@ -164,6 +203,10 @@ export class HaConfigLogs extends LitElement {
private _filterChanged(ev: HASSDomTargetEvent<HaInputSearch>) {
this._filter = ev.target.value ?? "";
if (this._currentProvider() === DEFAULT_LOG_PROVIDER) {
this._storedSearch = this._filter;
}
this._writeUrl(true);
}
@@ -232,6 +275,15 @@ export class HaConfigLogs extends LitElement {
.narrow=${this.narrow}
.value=${this._integrations}
.expanded=${this._integrationsExpanded}
.counts=${
this._detail
? undefined
: this._getIntegrationCounts(
this._logItems,
this._manifests,
this.hass.localize
)
}
@data-table-filter-changed=${this._integrationsChanged}
@expanded-changed=${this._integrationsExpandedChanged}
></ha-filter-integrations>
@@ -277,6 +329,7 @@ export class HaConfigLogs extends LitElement {
this._manifests
)}
@switch-log-view=${this._showDetail}
@system-log-items-changed=${this._logItemsChanged}
></system-log-card>
`
: html`<error-log-card
@@ -301,6 +354,10 @@ export class HaConfigLogs extends LitElement {
private _showDetail() {
this._detail = !this._detail;
if (this._currentProvider() === DEFAULT_LOG_PROVIDER) {
this._storedRaw = this._detail;
}
this._writeUrl();
}
@@ -317,12 +374,12 @@ export class HaConfigLogs extends LitElement {
this._selectedLogProvider = provider;
this._filter = "";
this._integrations = [];
this._storedSearch = "";
this._storedIntegrations = [];
// Keep the search typed so far on the entry Back returns to
this._queryParams.flush();
// Picking another provider is a navigation of its own, so Back returns
// to the previous one. Both entries are marked, so going back to one
// without params shows the default view instead of keeping the provider.
updateHistoryState({ [LOGS_HISTORY_STATE]: true });
// to the previous one
const search = createQueryString(
logsQueryParams({
provider,
@@ -332,10 +389,9 @@ export class HaConfigLogs extends LitElement {
}),
logsQueryParamConfig
);
// From the current path, so a trailing slash still matches the controller
navigate(`${currentPath()}${search ? `?${search}` : ""}`, {
data: { [LOGS_HISTORY_STATE]: true },
});
navigate(`${currentPath()}${search ? `?${search}` : ""}`);
}
private _toggleFilters() {
@@ -351,12 +407,27 @@ export class HaConfigLogs extends LitElement {
) {
ev.stopPropagation();
const value = ev.detail.value;
this._integrations = Array.isArray(value) ? value : [];
const integrations = Array.isArray(value) ? value : [];
// The filter also sends the value it was given once it has its items, in
// its own order, so only a change is the user's
if (
this._currentProvider() === DEFAULT_LOG_PROVIDER &&
!deepEqual([...integrations].sort(), [...this._integrations].sort())
) {
this._storedIntegrations = integrations;
}
this._integrations = integrations;
this._writeUrl();
}
private _clearIntegrations() {
this._integrations = [];
if (this._currentProvider() === DEFAULT_LOG_PROVIDER) {
this._storedIntegrations = [];
}
this._writeUrl();
}
@@ -366,20 +437,49 @@ export class HaConfigLogs extends LitElement {
this._integrationsExpanded = ev.detail.expanded;
}
// Until the apps are loaded, the provider is the one the URL asks for
private _currentProvider(): string {
return this._providersLoaded
? this._selectedLogProvider
: (logsLinkFromUrl(this._queryParams.searchParams() ?? "")?.provider ??
DEFAULT_LOG_PROVIDER);
}
private _writeUrl(debounced = false) {
this._queryParams.write(
logsQueryParams({
// Until the apps are loaded, keep the provider the URL asks for
provider: this._providersLoaded
? this._selectedLogProvider
: (logsLinkFromUrl(this._queryParams.searchParams() ?? "")
?.provider ?? DEFAULT_LOG_PROVIDER),
search: this._filter,
integrations: this._integrations,
raw: this._detail,
}),
debounced
);
this._queryParams.write(logsQueryParams(this._urlState()), debounced);
}
private _urlState(): LogsState {
return {
provider: this._currentProvider(),
search: this._filter,
integrations: this._integrations,
raw: this._detail,
};
}
// Closing a dialog, like an entry's details, goes back to the URL the page
// is already showing, so there's nothing to apply. A link is always applied.
private _navigated(ev?: Event) {
const searchParams = this._queryParams.searchParams();
if (ev?.type === "popstate" && searchParams) {
const shown = createQueryString(
logsQueryParams(this._urlState()),
logsQueryParamConfig
);
const back = createQueryString(
logsQueryParams(logsLinkFromUrl(searchParams) ?? DEFAULT_LOGS_STATE),
logsQueryParamConfig
);
if (back === shown) {
return;
}
}
this._applyUrl();
}
private async _init() {
@@ -389,18 +489,30 @@ export class HaConfigLogs extends LitElement {
// Keep a search typed while the apps were loading
this._queryParams.flush();
this._providersLoaded = true;
this._applyUrl();
// What was stored is already applied, and an empty URL now is a link like
// `?provider=core` that was tidied up
this._applyUrl(false);
}
private _applyUrl() {
private _applyUrl(restoreStored = true) {
const searchParams = this._queryParams.searchParams();
if (!searchParams) {
return;
}
// A link is shown, but not stored. Opened without params, like from the
// sidebar or Back to before another provider was picked, the page shows
// what the user picked.
const link =
logsLinkFromUrl(searchParams) ??
(getHistoryState()?.[LOGS_HISTORY_STATE]
? DEFAULT_LOGS_STATE
(searchParams.toString() === ""
? restoreStored
? {
...DEFAULT_LOGS_STATE,
search: this._storedSearch,
integrations: this._storedIntegrations,
raw: this._storedRaw,
}
: DEFAULT_LOGS_STATE
: undefined);
let missingProvider: string | undefined;
if (link) {
@@ -485,6 +597,87 @@ export class HaConfigLogs extends LitElement {
: undefined
);
// Matches entries to integrations by logger name, the same way
// `_getIntegrationLoggers` filters them
private _getIntegrationCounts = memoizeOne(
(
items: LoggedError[] | undefined,
manifests: ContextType<typeof manifestsContext> | undefined,
localize: LocalizeFunc
): Record<string, IntegrationFilterCount[]> | undefined => {
if (!items?.length || !manifests) {
return undefined;
}
const loggerDomains = new Map<string, string[]>();
for (const { domain, loggers } of Object.values(manifests)) {
for (const logger of [
`homeassistant.components.${domain}`,
`custom_components.${domain}`,
...(loggers ?? []),
]) {
loggerDomains.set(logger, [
...(loggerDomains.get(logger) ?? []),
domain,
]);
}
}
const levelCounts: Record<
string,
Partial<Record<SystemLogLevel, number>>
> = {};
for (const item of items) {
const parts = item.name.split(".");
const domains = new Set(
parts.flatMap(
(_part, index) =>
loggerDomains.get(parts.slice(0, index + 1).join(".")) ?? []
)
);
for (const domain of domains) {
const counts = (levelCounts[domain] ??= {});
counts[item.level] = (counts[item.level] ?? 0) + 1;
}
}
return Object.fromEntries(
Object.entries(levelCounts).map(([domain, counts]) => [
domain,
LOG_LEVELS.flatMap(({ level, path, color }) => {
const count = counts[level];
return count
? [
{
key: level,
path,
color,
count,
label: localize(
`ui.panel.config.logs.level_count.${level}`,
{ count }
),
},
]
: [];
}),
])
);
}
);
private _logItemsChanged(
ev: HASSDomEvent<HASSDomEvents["system-log-items-changed"]>
) {
this._logItems = ev.detail.items;
}
private _getLogProviderItems = (): LogProviderPickerItem[] =>
this._logProviders.map((provider) => ({
id: provider.key,
+12
View File
@@ -294,6 +294,14 @@ export class SystemLogCard extends LitElement {
);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_items")) {
fireEvent(this, "system-log-items-changed", { items: this._items });
}
}
protected serviceCalled(ev): void {
// Check if this is for us
if (ev.detail.success && ev.detail.domain === "system_log") {
@@ -404,4 +412,8 @@ declare global {
interface HTMLElementTagNameMap {
"system-log-card": SystemLogCard;
}
interface HASSDomEvents {
"system-log-items-changed": { items: LoggedError[] | undefined };
}
}
@@ -106,10 +106,6 @@ class HaConfigSectionNetwork extends LitElement {
margin-bottom: 24px;
max-width: 600px;
}
.discovery-card ha-list-item-button::part(headline),
.discovery-card ha-list-item-button::part(supporting-text) {
white-space: normal;
}
`;
}
@@ -73,10 +73,6 @@ const mountPartitionLabel = ({
return drive ? `${drive} — ${identity}, ${size}` : `${identity}, ${size}`;
};
// Supervisor accepts only ASCII letters, digits and underscores.
const mountNameFromLabel = (label?: string): string =>
(label ?? "").replace(/^\W+|\W+$/g, "").replace(/\W+/g, "_");
const requiredFieldsFilled = (
schema: readonly HaFormSchema[],
data?: MountFormData
@@ -309,8 +305,6 @@ class ViewMountDialog extends DirtyStateProviderMixin<
private _originalType?: SupervisorMountType;
private _suggestedName?: string;
@state() private _diskIdentity?: string;
public connectedCallback(): void {
@@ -535,9 +529,7 @@ class ViewMountDialog extends DirtyStateProviderMixin<
private _valueChanged(ev: CustomEvent) {
this._validationError = {};
this._validationWarning = {};
const previous = this._data;
this._data = ev.detail.value;
this._suggestName(previous);
if (this._data?.name && !/^\w+$/.test(this._data.name)) {
this._validationError.name = "invalid_name";
}
@@ -564,32 +556,6 @@ class ViewMountDialog extends DirtyStateProviderMixin<
);
}
// Name a new disk mount after the partition's volume label, unless the user
// typed a name of their own.
private _suggestName(previous?: MountFormData) {
const data = this._data;
if (data?.name !== previous?.name) {
this._suggestedName = undefined;
}
if (
this._existing ||
data?.type !== SupervisorMountType.DISK ||
(data.device === previous?.device &&
previous?.type === SupervisorMountType.DISK) ||
(data.name && data.name !== this._suggestedName)
) {
return;
}
const name = mountNameFromLabel(
this._partitions?.find((entry) => entry.partition.device === data.device)
?.partition.label
);
if (name !== (data.name ?? "")) {
data.name = name;
}
this._suggestedName = name;
}
private async _connectMount(ev) {
const progressButton = ev.target as HaProgressButton;
this._error = undefined;
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../components/item/ha-list-item-base";
import "../../../../components/ha-switch";
@@ -43,13 +43,6 @@ class HaDebugConnectionRow extends LitElement {
setDebugConnection(debugConnection);
storeState(this.hass);
}
static styles = css`
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
`;
}
declare global {
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { storage } from "../../../../common/decorators/storage";
import { setViewTransitionDisabled } from "../../../../common/util/view-transition";
@@ -41,13 +41,6 @@ class HaDebugDisableViewTransitionRow extends LitElement {
this._disabled = (ev.target as HaSwitch).checked;
setViewTransitionDisabled(this._disabled);
}
static styles = css`
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
`;
}
declare global {
@@ -1,4 +1,4 @@
import { css, html, LitElement } from "lit";
import { html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { storage } from "../../../../common/decorators/storage";
@@ -42,13 +42,6 @@ class HaDebugSafeAreaToolRow extends LitElement {
private _checkedChanged(ev: Event) {
this._enabled = (ev.target as HaSwitch).checked;
}
static styles = css`
ha-list-item-base::part(headline),
ha-list-item-base::part(supporting-text) {
white-space: normal;
}
`;
}
declare global {
+7 -5
View File
@@ -901,14 +901,16 @@ class HaPanelHistory extends LitElement {
entities,
},
];
addEntitiesToLovelaceView(this, this.hass, {
cardConfig: cards,
sectionConfig: {
addEntitiesToLovelaceView(
this,
this.hass,
cards,
{
title: this.hass.localize("panel.history"),
cards,
},
entities,
});
entities
);
}
static get styles() {
+13 -10
View File
@@ -5,6 +5,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { consume } from "../../../common/decorators/consume";
import {
consumeEntityState,
@@ -13,7 +14,7 @@ import {
import { fireEvent } from "../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
computeAlertColor,
DEFAULT_ALERT_COLOR,
isAlertActive,
} from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -120,14 +121,12 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
const active = isAlertActive(stateObj);
const color = computeAlertColor(
stateObj,
active
? (this._config.alert_color ?? "state")
: (this._config.clear_color ?? "none")
);
const color = active
? (this._config.alert_color ?? DEFAULT_ALERT_COLOR)
: this._config.clear_color;
const pulse = active && this._config.pulse === true;
const hasColor = color !== undefined && color !== "none";
const name = this._formatters.formatEntityName(stateObj, this._config.name);
@@ -144,9 +143,10 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
.type=${hasTileCardAction(this._config) ? "button" : "badge"}
.label=${showState && showName ? name : undefined}
.iconOnly=${!content}
class=${classMap({ alert: true, pulse })}
class=${classMap({ alert: true, pulse, "no-color": !hasColor })}
style=${styleMap({
"--ha-alert-color": color,
"--ha-alert-color":
color && hasColor ? computeCssColor(color) : undefined,
})}
@action=${this._handleAction}
.actionHandler=${actionHandler({
@@ -205,7 +205,10 @@ export class HuiAlertBadge extends LitElement implements LovelaceBadge {
);
background: var(--ha-alert-badge-background);
--ha-card-background: transparent;
--badge-color: var(--ha-alert-color, var(--secondary-text-color));
--badge-color: var(--ha-alert-color);
}
ha-badge.alert.no-color {
--badge-color: var(--secondary-text-color);
}
ha-badge.alert::before {
position: absolute;
+15 -10
View File
@@ -5,11 +5,12 @@ import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../../common/decorators/consume";
import { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
computeAlertColor,
DEFAULT_ALERT_COLOR,
isAlertActive,
} from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -119,14 +120,12 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
const active = isAlertActive(stateObj);
const color = computeAlertColor(
stateObj,
active
? (this._config.alert_color ?? "state")
: (this._config.clear_color ?? "none")
);
const color = active
? (this._config.alert_color ?? DEFAULT_ALERT_COLOR)
: this._config.clear_color;
const pulse = active && this._config.pulse === true;
const hasColor = color !== undefined && color !== "none";
const areaName = this._formatters.formatEntityName(stateObj, {
type: "area",
@@ -134,9 +133,10 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
return html`
<ha-card
class=${classMap({ pulse })}
class=${classMap({ pulse, "no-color": !hasColor })}
style=${styleMap({
"--ha-alert-color": color,
"--ha-alert-color":
color && hasColor ? computeCssColor(color) : undefined,
"--ha-alert-static-opacity": pulse
? undefined
: "var(--ha-alert-pulse-opacity)",
@@ -211,7 +211,12 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
position: relative;
overflow: hidden;
height: 100%;
--tile-color: var(--ha-alert-color, var(--secondary-text-color));
}
ha-card:not(.no-color) {
--tile-color: var(--ha-alert-color);
}
ha-card.no-color {
--tile-color: var(--secondary-text-color);
}
ha-card::before {
position: absolute;
@@ -310,11 +310,11 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
position: relative;
}
#states:first-child > div:first-child > hui-section-row::part(divider) {
#states > div:first-child > hui-section-row::part(divider) {
display: none;
}
#states:first-child > div:first-child > hui-section-row::part(label) {
#states > div:first-child > hui-section-row::part(label) {
margin-top: 0;
}
+15 -57
View File
@@ -1,7 +1,7 @@
import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
@@ -38,11 +38,7 @@ import { actionHandler } from "../common/directives/action-handler-directive";
import { findEntities } from "../common/find-entities";
import { hasAction, hasAnyAction } from "../common/has-action";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import type {
LovelaceCard,
LovelaceCardEditor,
LovelaceGridOptions,
} from "../types";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import type { GaugeCardConfig } from "./types";
export const DEFAULT_MIN = 0;
@@ -84,8 +80,6 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
return { type: "gauge", entity: foundEntities[0] || "" };
}
@property({ attribute: false }) public layout?: string;
@state() private _config?: GaugeCardConfig;
@state()
@@ -193,17 +187,10 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
const name = this._formatters.formatEntityName(stateObj, this._config.name);
// In a sections view with a set number of rows the card height is fixed,
// so the gauge also has to fit in the height, not only in the width.
const fixedHeight =
this.layout === "grid" &&
typeof this._config.grid_options?.rows === "number";
return html`
<ha-card
class=${classMap({
action: hasAnyAction(this._config),
"fixed-height": fixedHeight,
})}
@action=${this._handleAction}
.actionHandler=${actionHandler({
@@ -216,34 +203,24 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
: undefined
)}
>
<div class="gauge-container">
<ha-gauge
.min=${this._config.min!}
.max=${this._config.max!}
.value=${value}
.valueText=${valueToDisplay}
.locale=${this._i18n.locale}
.label=${customUnit ?? ""}
style=${styleMap({
"--gauge-color": this._computeSeverity(Number(value)),
})}
.needle=${this._config!.needle}
.levels=${this._config!.needle ? this._severityLevels() : undefined}
></ha-gauge>
</div>
<ha-gauge
.min=${this._config.min!}
.max=${this._config.max!}
.value=${value}
.valueText=${valueToDisplay}
.locale=${this._i18n.locale}
.label=${customUnit ?? ""}
style=${styleMap({
"--gauge-color": this._computeSeverity(Number(value)),
})}
.needle=${this._config!.needle}
.levels=${this._config!.needle ? this._severityLevels() : undefined}
></ha-gauge>
<p class="title" .title=${name}>${name}</p>
</ha-card>
`;
}
public getGridOptions(): LovelaceGridOptions {
// With one row there is almost no room left for the gauge after the
// padding and the name, so the card needs at least 2 rows.
return {
min_rows: 2,
};
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._config || !this._themes) {
@@ -389,25 +366,6 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
width: 100%;
max-width: 250px;
}
.gauge-container {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* With a fixed card height the container takes the space left after
the name, and the gauge is sized to fit inside it. */
ha-card.fixed-height .gauge-container {
flex: 1;
min-height: 0;
container-type: size;
}
ha-card.fixed-height ha-gauge {
width: min(250px, 100cqw, 100cqh * 100 / 55);
}
`;
}
@@ -1,8 +1,6 @@
import { navigate } from "../../../common/navigate";
import type { LovelacePanelConfig } from "../../../data/lovelace";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceStrategyConfig } from "../../../data/lovelace/config/strategy";
import type {
LovelaceConfig,
LovelaceRawConfig,
@@ -12,48 +10,20 @@ import {
isStrategyDashboard,
saveConfig,
} from "../../../data/lovelace/config/types";
import { isStrategyView } from "../../../data/lovelace/config/view";
import { fetchDashboards } from "../../../data/lovelace/dashboard";
import { getPanelTitleFromUrlPath, LOVELACE_PANEL } from "../../../data/panel";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import { SECTIONS_VIEW_LAYOUT } from "../views/const";
import { getViewType } from "../views/get-view-type";
import type { SuggestCardDialogParams } from "./card-editor/show-suggest-card-dialog";
import { showSuggestCardDialog } from "./card-editor/show-suggest-card-dialog";
import { showEditSectionDialog } from "./section-editor/show-edit-section-dialog";
import { showSelectViewDialog } from "./select-view/show-select-view-dialog";
export type AddToViewCardDialogParams = Pick<
SuggestCardDialogParams,
"lovelaceConfig" | "saveConfig" | "path" | "yaml"
>;
export interface AddEntitiesToViewOptions {
cardConfig?: LovelaceCardConfig[];
sectionConfig?: LovelaceSectionConfig;
entities?: string[];
sectionStrategy?: LovelaceStrategyConfig;
showCardDialog?: (params: AddToViewCardDialogParams) => void;
}
export const addEntitiesToLovelaceView = async (
element: HTMLElement,
hass: HomeAssistant,
options: AddEntitiesToViewOptions
cardConfig: LovelaceCardConfig[],
sectionConfig?: LovelaceSectionConfig,
entities?: string[]
) => {
const { sectionStrategy } = options;
const showCardDialog =
options.showCardDialog ??
((params: AddToViewCardDialogParams) =>
showSuggestCardDialog(element, {
...params,
cardConfig: options.cardConfig ?? [],
sectionConfig: options.sectionConfig,
entities: options.entities,
}));
await hass.loadFragmentTranslation("lovelace");
hass.loadFragmentTranslation("lovelace");
const dashboards = await fetchDashboards(hass);
const storageDashs = dashboards.filter(
@@ -66,7 +36,11 @@ export const addEntitiesToLovelaceView = async (
if (mainLovelaceMode !== "storage" && !storageDashs.length) {
// no storage dashboards, just show the YAML config
showCardDialog({ yaml: true });
showSuggestCardDialog(element, {
cardConfig,
entities,
yaml: true,
});
return;
}
@@ -120,7 +94,12 @@ export const addEntitiesToLovelaceView = async (
if (!hasViewList(lovelaceConfig)) {
if (dashboards.length > storageDashs.length) {
// all storage dashboards are generated, but we have YAML dashboards just show the YAML config
showCardDialog({ yaml: true });
showSuggestCardDialog(element, {
cardConfig,
sectionConfig,
entities,
yaml: true,
});
} else {
// all storage dashboards are generated
showAlertDialog(element, {
@@ -137,59 +116,21 @@ export const addEntitiesToLovelaceView = async (
return;
}
const showAddDialog = (
viewUrlPath: string | null,
config: LovelaceConfig,
viewIndex: number
) => {
const save = async (newConfig: LovelaceConfig): Promise<void> => {
try {
await saveConfig(hass!, viewUrlPath, newConfig);
} catch (_err: any) {
alert(hass.localize("ui.panel.lovelace.add_entities.saving_failed"));
return;
}
const dashboardPath = viewUrlPath ?? LOVELACE_PANEL;
const viewPath = newConfig.views[viewIndex].path || viewIndex;
const dashboardTitle = getPanelTitleFromUrlPath(hass, dashboardPath);
showToast(element, {
message: dashboardTitle
? {
translationKey: "ui.panel.lovelace.add_entities.added",
args: { dashboard: dashboardTitle },
}
: { translationKey: "ui.panel.lovelace.add_entities.added_unnamed" },
action: {
text: { translationKey: "ui.panel.lovelace.add_entities.open" },
action: () => navigate(`/${dashboardPath}/${viewPath}`),
},
});
};
const view = config.views[viewIndex];
if (
sectionStrategy &&
!isStrategyView(view) &&
getViewType(view) === SECTIONS_VIEW_LAYOUT
) {
showEditSectionDialog(element, {
lovelaceConfig: config,
saveConfig: save,
path: ["views", viewIndex, "sections", view.sections?.length ?? 0],
newSection: { strategy: sectionStrategy },
});
return;
}
showCardDialog({
lovelaceConfig: config,
saveConfig: save,
path: ["views", viewIndex],
});
};
if (!storageDashs.length && lovelaceConfig.views.length === 1) {
showAddDialog(null, lovelaceConfig, 0);
showSuggestCardDialog(element, {
cardConfig,
sectionConfig,
lovelaceConfig,
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
try {
await saveConfig(hass!, null, newConfig);
} catch (_err: any) {
alert(hass.localize("ui.panel.lovelace.add_entities.saving_failed"));
}
},
path: ["views", 0],
entities,
});
return;
}
@@ -199,6 +140,23 @@ export const addEntitiesToLovelaceView = async (
allowDashboardChange: true,
actionLabel: hass.localize("ui.common.next"),
dashboards,
viewSelectedCallback: showAddDialog,
viewSelectedCallback: (newUrlPath, selectedDashConfig, viewIndex) => {
showSuggestCardDialog(element, {
cardConfig,
sectionConfig,
lovelaceConfig: selectedDashConfig,
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
try {
await saveConfig(hass!, newUrlPath, newConfig);
} catch {
alert(
hass.localize("ui.panel.lovelace.add_entities.saving_failed")
);
}
},
path: ["views", viewIndex],
entities,
});
},
});
};
@@ -1,299 +0,0 @@
import memoizeOne from "memoize-one";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import "../../../../components/chips/ha-chip-set";
import "../../../../components/chips/ha-filter-chip";
import type { HaFilterChip } from "../../../../components/chips/ha-filter-chip";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { computeDeviceEntityCategory } from "../../../../data/device/device_entity_category";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../cards/hui-card";
import type { DeviceSectionCategory } from "../../strategies/device/device-section-strategy";
import {
DEFAULT_DEVICE_SECTION_CATEGORIES,
DEVICE_SECTION_CATEGORIES,
DeviceSectionStrategy,
} from "../../strategies/device/device-section-strategy";
import { addCards } from "../config-util";
import type { AddDeviceCardDialogParams } from "./show-add-device-card-dialog";
@customElement("hui-dialog-add-device-card")
export class HuiDialogAddDeviceCard
extends LitElement
implements HassDialog<AddDeviceCardDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: AddDeviceCardDialogParams;
@state() private _open = false;
@state() private _categories: DeviceSectionCategory[] =
DEFAULT_DEVICE_SECTION_CATEGORIES;
@state() private _cardConfig?: LovelaceCardConfig[];
@state() private _saving = false;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
public showDialog(params: AddDeviceCardDialogParams): void {
this._params = params;
this._open = true;
const available = this._availableCategories(
params.deviceId,
this.hass.entities
);
const categories = DEFAULT_DEVICE_SECTION_CATEGORIES.filter((category) =>
available.includes(category)
);
this._updateCategories(categories.length ? categories : available);
}
private _availableCategories = memoizeOne(
(
deviceId: string,
entities: HomeAssistant["entities"]
): DeviceSectionCategory[] => {
const categories = new Set(
Object.values(entities)
.filter((entry) => entry.device_id === deviceId && !entry.hidden)
.map(computeDeviceEntityCategory)
);
return DEVICE_SECTION_CATEGORIES.filter((category) =>
categories.has(category)
);
}
);
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._cardConfig = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
const device = this.hass.devices[this._params.deviceId];
const deviceName = device
? computeDeviceNameDisplay(device, this.hass.localize)
: this._params.deviceId;
const availableCategories = this._availableCategories(
this._params.deviceId,
this.hass.entities
);
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.lovelace.editor.add_device_card.header",
{ device: deviceName }
)}
@closed=${this._dialogClosed}
>
${
availableCategories.length > 1
? html`
<p class="label">
${this.hass.localize(
"ui.panel.lovelace.editor.strategy.device.categories"
)}
</p>
<ha-chip-set>
${availableCategories.map(
(category) => html`
<ha-filter-chip
.value=${category}
.selected=${this._categories.includes(category)}
.label=${this.hass.localize(
`ui.panel.lovelace.strategy.device.categories.${category}`
)}
@click=${this._categoryToggled}
></ha-filter-chip>
`
)}
</ha-chip-set>
`
: nothing
}
<div class="preview">
${this._cardConfig?.map(
(cardConfig) => html`
<hui-card
.hass=${this.hass}
.config=${cardConfig}
preview
></hui-card>
`
)}
</div>
${
this._params.yaml && this._cardConfig
? html`
<div class="editor">
<ha-yaml-editor
.defaultValue=${this._cardConfig}
in-dialog
></ha-yaml-editor>
</div>
`
: nothing
}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@click=${this.closeDialog}
appearance="plain"
>
${this.hass.localize(
this._params.yaml ? "ui.common.close" : "ui.common.cancel"
)}
</ha-button>
${
this._params.yaml
? nothing
: html`
<ha-button
slot="primaryAction"
.disabled=${this._saving || !this._cardConfig?.length}
.loading=${this._saving}
@click=${this._save}
>
${this.hass.localize(
"ui.panel.lovelace.editor.add_device_card.add"
)}
</ha-button>
`
}
</ha-dialog-footer>
</ha-dialog>
`;
}
private _categoryToggled(ev: MouseEvent): void {
const chip = ev.currentTarget as HaFilterChip;
const category = chip.value as DeviceSectionCategory;
this._updateCategories(
this._availableCategories(
this._params!.deviceId,
this.hass.entities
).filter((item) =>
item === category ? chip.selected : this._categories.includes(item)
)
);
}
private async _updateCategories(
categories: DeviceSectionCategory[]
): Promise<void> {
this._categories = categories;
const deviceId = this._params!.deviceId;
const section = await DeviceSectionStrategy.generate(
{
type: "device",
device_id: deviceId,
categories,
display: "list",
heading: "none",
group_by_category: true,
},
this.hass
);
if (
this._categories !== categories ||
this._params?.deviceId !== deviceId
) {
return;
}
const device = this.hass.devices[deviceId];
const title = device
? computeDeviceNameDisplay(device, this.hass.localize)
: undefined;
this._cardConfig = (section.cards ?? []).map((card) => ({
...card,
title,
}));
this._yamlEditor?.setValue(this._cardConfig);
}
private async _save(): Promise<void> {
if (
!this._params?.lovelaceConfig ||
!this._params.path ||
!this._params.saveConfig ||
!this._cardConfig?.length
) {
return;
}
this._saving = true;
await this._params.saveConfig(
addCards(this._params.lovelaceConfig, this._params.path, this._cardConfig)
);
this._saving = false;
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-z-index: 6;
}
.label {
margin: 0 0 var(--ha-space-2);
color: var(--secondary-text-color);
}
ha-chip-set {
margin-bottom: var(--ha-space-4);
}
.preview {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
padding: var(--ha-space-4);
border-radius: var(--ha-border-radius-lg);
background-color: var(--secondary-background-color);
}
hui-card {
display: block;
width: 100%;
max-width: 390px;
margin: 0 auto;
}
.editor {
padding-top: var(--ha-space-4);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-add-device-card": HuiDialogAddDeviceCard;
}
}
@@ -17,6 +17,7 @@ import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/vie
import { isStrategyView } from "../../../../data/lovelace/config/view";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card";
import "../../sections/hui-section";
import { getViewType } from "../../views/get-view-type";
@@ -281,6 +282,7 @@ export class HuiDialogSuggestCard extends LitElement {
);
await this._params!.saveConfig(newConfig);
this._saving = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
}
}
@@ -1,19 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { AddToViewCardDialogParams } from "../add-entities-to-view";
export interface AddDeviceCardDialogParams extends AddToViewCardDialogParams {
deviceId: string;
}
const importAddDeviceCardDialog = () => import("./hui-dialog-add-device-card");
export const showAddDeviceCardDialog = (
element: HTMLElement,
addDeviceCardDialogParams: AddDeviceCardDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-add-device-card",
dialogImport: importAddDeviceCardDialog,
dialogParams: addDeviceCardDialogParams,
});
};
@@ -1,4 +1,5 @@
import { mdiAlertOutline, mdiTextShort } from "@mdi/js";
import { DEFAULT_ALERT_COLOR } from "../../../../common/entity/alert_active";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { HaFormSchema } from "../../../../components/ha-form/types";
import { contentLayoutSchema, displayedElementsSchema } from "./content-schema";
@@ -17,11 +18,7 @@ export const alertSchema = () =>
{
name: "clear_color",
selector: {
ui_color: {
include_none: true,
include_state: true,
default_color: "none",
},
ui_color: { include_none: true, default_color: "none" },
},
},
{
@@ -29,8 +26,7 @@ export const alertSchema = () =>
selector: {
ui_color: {
include_none: true,
include_state: true,
default_color: "state",
default_color: DEFAULT_ALERT_COLOR,
},
},
},
@@ -5,7 +5,7 @@ import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
export interface EditSectionDialogParams {
lovelace?: Lovelace;
lovelace: Lovelace;
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelacePath;
@@ -7,7 +7,6 @@ import "../../../../components/ha-button";
import "../../../../components/ha-svg-icon";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import { computeUnusedEntities } from "../../common/compute-unused-entities";
import {
computeCards,
@@ -112,11 +111,6 @@ export class HuiUnusedEntities extends LitElement {
this._selectedEntities = ev.detail.selectedEntities;
}
private _saveConfig = async (config: LovelaceConfig): Promise<void> => {
await this.lovelace.saveConfig(config);
showSaveSuccessToast(this, this.hass);
};
private _addToLovelaceView(): void {
const cardConfig = computeCards(this.hass, this._selectedEntities, {});
const sectionConfig = computeSection(this._selectedEntities, {});
@@ -124,7 +118,7 @@ export class HuiUnusedEntities extends LitElement {
if (this.lovelace.config.views.length === 1) {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this._saveConfig,
saveConfig: this.lovelace.saveConfig,
path: ["views", 0],
entities: this._selectedEntities,
cardConfig,
@@ -138,7 +132,7 @@ export class HuiUnusedEntities extends LitElement {
viewSelectedCallback: (_urlPath, _selectedDashConfig, viewIndex) => {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this._saveConfig,
saveConfig: this.lovelace.saveConfig,
path: ["views", viewIndex],
entities: this._selectedEntities,
cardConfig,
+2 -2
View File
@@ -319,7 +319,7 @@ export class LovelacePanel extends LitElement {
// Refreshing a YAML config can trigger an update event. We will ignore
// all update events while fetching the config and for 2 seconds after the config is back.
// We ignore because we already have the latest config.
if (confMode === "yaml") {
if (this.lovelace && this.lovelace.mode === "yaml") {
this._ignoreNextUpdateEvent = true;
}
@@ -367,7 +367,7 @@ export class LovelacePanel extends LitElement {
} finally {
this._loading = false;
// Ignore updates for another 2 seconds.
if (confMode === "yaml") {
if (this.lovelace && this.lovelace.mode === "yaml") {
setTimeout(() => {
this._ignoreNextUpdateEvent = false;
}, 2000);
@@ -92,22 +92,16 @@ export const filterUnavailableBatteryEntities = (
return states[entityId]?.state === "unavailable";
});
const computeMaintenanceTileCard = (
states: HomeAssistant["states"],
const computeBatteryTileCard = (
entities: HomeAssistant["entities"],
entityId: string
): TileCardConfig => {
const deviceId = entities[entityId]?.device_id;
if (!deviceId) {
return { type: "tile", entity: entityId, name: { type: "entity" } };
}
const isProblem = states[entityId]?.attributes?.device_class === "problem";
const entity = entities[entityId];
const deviceId = entity?.device_id;
return {
type: "tile",
entity: entityId,
name: isProblem
? [{ type: "device" }, { type: "entity" }]
: { type: "device" },
name: { type: deviceId ? "device" : "entity" },
};
};
@@ -136,9 +130,7 @@ const processAreasForBattery = (
const areaCards: LovelaceCardConfig[] = [];
for (const entityId of areaBatteryEntities) {
areaCards.push(
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
);
areaCards.push(computeBatteryTileCard(hass.entities, entityId));
}
if (areaCards.length > 0) {
@@ -178,9 +170,7 @@ const processUnassignedEntities = (
const cards: LovelaceCardConfig[] = [];
for (const entityId of unassignedEntities) {
cards.push(
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
);
cards.push(computeBatteryTileCard(hass.entities, entityId));
}
return cards;
@@ -1,10 +0,0 @@
// Install the Intl.PluralRules polyfill together with its locale data.
// FormatJS replaces Intl.PluralRules as soon as polyfill-force runs, and a
// data-less polyfill throws from select(). Keeping both imports in one module
// closes that gap for every caller, including the should-polyfill probes
// that other entry bundles run.
import "@formatjs/intl-pluralrules/polyfill-force";
// Locale data for plural rules breaks current JSON conversions as it includes functions,
// so only import English to avoid huge bundles
// TODO: Setup JS imports instead of JSON fetches
import "@formatjs/intl-pluralrules/locale-data/en";
+15 -10
View File
@@ -14,6 +14,8 @@ import {
polyfillTimeZoneData,
} from "./locale-data-polyfill";
let polyfilled = false;
const _polyfillTimeZoneData = polyfillTimeZoneData;
// The DateTimeFormat and DurationFormat polyfills call BigInt() when formatting
@@ -45,6 +47,10 @@ const setDefaultTimeZone = () => {
};
const polyfillIntl = async () => {
if (polyfilled) {
return;
}
polyfilled = true;
const locale = getLocalLanguage();
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillGetCanonicalLocales()) {
@@ -73,10 +79,14 @@ const polyfillIntl = async () => {
polyfills.push(import("@formatjs/intl-numberformat/polyfill-force"));
}
if (shouldPolyfillPluralRules(locale)) {
// The polyfill and its English data are one chunk, so Intl.PluralRules
// is never installed without data. Its should-polyfill probe calls
// select(), which throws on a data-less polyfill.
polyfills.push(import("./intl-pluralrules"));
polyfills.push(
import("@formatjs/intl-pluralrules/polyfill-force").then(
// Locale data for plural rules breaks current JSON conversions as it includes functions,
// so only import English to avoid huge bundles
// TODO: Setup JS imports instead of JSON fetches
() => import("@formatjs/intl-pluralrules/locale-data/en")
)
);
}
if (shouldPolyfillRelativeTimeFormat(locale)) {
polyfills.push(import("@formatjs/intl-relativetimeformat/polyfill-force"));
@@ -90,9 +100,4 @@ const polyfillIntl = async () => {
);
};
// Each legacy entry bundle (core, app, authorize, onboarding) carries its own
// copy of this module, and the bundles load in parallel. A module-level flag
// only stops re-runs within one bundle, so a second bundle could probe
// Intl.* while the first was still loading locale data. Share one promise
// per page instead, so every bundle waits for the same run.
await (globalThis.__haIntlPolyfill ??= polyfillIntl());
await polyfillIntl();
-5
View File
@@ -1,5 +0,0 @@
// Imported right after the scoped custom element registry polyfill. The index
// page starts the extra modules from Core only after this has run, so their
// elements are defined in the registry that the app uses.
window.__haScopedRegistryReady = true;
window.__haLoadExtraModules?.();
+14 -14
View File
@@ -17,7 +17,6 @@
"climate": "Climate",
"home": "Overview",
"maintenance": "Maintenance",
"marketplace": "Marketplace",
"notfound": "Page not found"
},
"state": {
@@ -858,6 +857,7 @@
},
"mode_composed": "Composed",
"mode_custom": "Custom",
"add": "Add",
"search": "Search name",
"custom_name": "Custom name",
"no_more_parts": "Nothing left to add",
@@ -944,6 +944,7 @@
"collapse": "Collapse"
},
"entity-type-picker": {
"add": "Add type",
"no_types": "No types available"
},
"subpage-data-table": {
@@ -1910,12 +1911,10 @@
"automation_condition": "Create as a new condition",
"automation_action": "Create as a new action",
"script_action": "Create as a new action",
"scene": "Create as a new scene",
"dashboard": "Add to a dashboard"
"scene": "Create as a new scene"
},
"automations_heading": "Automations",
"scripts_heading": "Scripts",
"dashboards_heading": "Dashboards",
"app_actions": "App actions",
"no_actions": "No actions available",
"action_failed": "Failed to perform the action {error}"
@@ -3207,8 +3206,8 @@
"disable": "[%key:ui::common::disable%]",
"disable_confirm_title": "Disable the Model Context Protocol server?",
"disable_confirm_text": "AI agents connected to the server will lose access to Home Assistant.",
"your_api_header": "Main URL",
"apis_header": "Individual URLs",
"your_api_header": "Your MCP API",
"apis_header": "Individual MCP APIs",
"copy_url": "Copy URL",
"configure": "Configure",
"error_load": "Failed to load the Model Context Protocol configuration",
@@ -5083,6 +5082,13 @@
"info": "INFO",
"debug": "DEBUG"
},
"level_count": {
"critical": "{count} critical {count, plural,\n one {entry}\n other {entries}\n}",
"error": "{count} {count, plural,\n one {error}\n other {errors}\n}",
"warning": "{count} {count, plural,\n one {warning}\n other {warnings}\n}",
"info": "{count} info {count, plural,\n one {entry}\n other {entries}\n}",
"debug": "{count} debug {count, plural,\n one {entry}\n other {entries}\n}"
},
"custom_integration": "custom integration",
"error_from_custom_integration": "This log entry is from a custom integration.",
"show_full_logs": "Show raw logs",
@@ -7475,6 +7481,7 @@
"notify": "Notifiers",
"assist": "[%key:ui::panel::lovelace::menu::assist%]",
"config": "Configuration",
"add_entities_lovelace": "Add to dashboard",
"none": "This device has no entities",
"show_less": "Show less",
"disabled_entities": "+{count} disabled {count, plural,\n one {entity}\n other {entities}\n}",
@@ -10224,10 +10231,7 @@
"add_entities": {
"yaml_unsupported": "You cannot use this function when using your dashboard in YAML mode.",
"generated_unsupported": "You can only use this function when you have taken control of your dashboard.",
"saving_failed": "Saving dashboard configuration failed.",
"added": "Added to {dashboard}",
"added_unnamed": "Added to the dashboard",
"open": "Open"
"saving_failed": "Saving dashboard configuration failed."
},
"views": {
"unnamed_view": "Unnamed view",
@@ -10638,10 +10642,6 @@
"theme_helper": "Apply a specific theme to this section, overriding the view theme"
}
},
"add_device_card": {
"header": "Add {device} to dashboard",
"add": "[%key:ui::panel::lovelace::editor::suggest_card::add%]"
},
"suggest_card": {
"header": "We created a suggestion for you",
"create_own": "Pick different card",
-5
View File
@@ -41,11 +41,6 @@ declare global {
var __HASS_URL__: string;
// Read by intl-polyfill before a polyfill replaces Intl.DateTimeFormat
var __haNativeTimeZone: string | undefined;
// Shared by every legacy entry bundle so the Intl polyfills load once
var __haIntlPolyfill: Promise<void> | undefined;
// Handshake between the app entry and the extra modules in index.html
var __haScopedRegistryReady: boolean | undefined;
var __haLoadExtraModules: (() => void) | undefined;
/* eslint-enable @typescript-eslint/naming-convention */
interface Window {
-43
View File
@@ -1,43 +0,0 @@
import { describe, expect, it } from "vitest";
import { dropInvalidConditions } from "../../../src/common/condition/sanitize";
import type { VisibilityCondition } from "../../../src/panels/lovelace/common/validate-condition";
const conds = (c: unknown[]): VisibilityCondition[] =>
c as VisibilityCondition[];
const state = { condition: "state", entity: "light.kitchen", state: "on" };
const screen = { condition: "screen", media_query: "(max-width: 600px)" };
describe("dropInvalidConditions", () => {
it("returns the same array when every entry is valid", () => {
const conditions = conds([
state,
{ condition: "or", conditions: [screen] },
{ condition: "not", conditions: screen },
]);
expect(dropInvalidConditions(conditions)).toBe(conditions);
});
it("drops invalid entries at any depth without changing the input", () => {
const nested = { condition: "and", conditions: [screen, undefined, 5] };
const conditions = conds([
null,
"screen",
state,
{ condition: "or", conditions: [null, nested] },
{ condition: "not", conditions: "screen" },
{ condition: "and", conditions: "" },
]);
expect(dropInvalidConditions(conditions)).toEqual([
state,
{
condition: "or",
conditions: [{ condition: "and", conditions: [screen] }],
},
{ condition: "not", conditions: [] },
{ condition: "and", conditions: [] },
]);
expect(nested.conditions).toEqual([screen, undefined, 5]);
});
});
@@ -1,184 +0,0 @@
import { ContextProvider } from "@lit/context";
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import "../../src/components/ha-areas-floors-display-editor";
import type {
AreasFloorsDisplayValue,
HaAreasFloorsDisplayEditor,
} from "../../src/components/ha-areas-floors-display-editor";
import type { HaItemDisplayEditor } from "../../src/components/ha-items-display-editor";
import {
areasContext,
floorsContext,
internationalizationContext,
} from "../../src/data/context";
import type {
HomeAssistant,
HomeAssistantInternationalization,
} from "../../src/types";
beforeAll(() => {
// jsdom's ElementInternals lacks the validity API used by the webawesome
// buttons that render inside the items editor.
const internalsProto = window.ElementInternals.prototype as any;
internalsProto.setValidity = vi.fn();
internalsProto.setFormValue = vi.fn();
Object.defineProperty(internalsProto, "validity", {
get: () => ({ valid: true }),
configurable: true,
});
});
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
const UNASSIGNED_FLOOR = "__unassigned__";
const FLOORS = {
floor_a: { floor_id: "floor_a", name: "A", level: 0 },
floor_b: { floor_id: "floor_b", name: "B", level: 1 },
} as unknown as HomeAssistant["floors"];
const area = (areaId: string, floorId: string | null) => ({
area_id: areaId,
name: areaId,
floor_id: floorId,
icon: null,
});
const AREAS = Object.fromEntries(
[
area("a1", "floor_a"),
area("a2", "floor_a"),
area("a3", "floor_a"),
area("b1", "floor_b"),
area("b2", "floor_b"),
area("b3", "floor_b"),
area("u1", null),
area("u2", null),
].map((entry) => [entry.area_id, entry])
) as unknown as HomeAssistant["areas"];
describe("ha-areas-floors-display-editor", () => {
let host: HTMLDivElement | undefined;
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
});
afterEach(() => {
host?.remove();
host = undefined;
vi.unstubAllGlobals();
});
const mount = async (
value: AreasFloorsDisplayValue
): Promise<HaAreasFloorsDisplayEditor> => {
host = document.createElement("div");
document.body.appendChild(host);
new ContextProvider(host, {
context: internationalizationContext,
initialValue: {
localize: (key: string) => key,
} as unknown as HomeAssistantInternationalization,
});
new ContextProvider(host, { context: areasContext, initialValue: AREAS });
new ContextProvider(host, { context: floorsContext, initialValue: FLOORS });
const el = document.createElement(
"ha-areas-floors-display-editor"
) as HaAreasFloorsDisplayEditor;
el.value = value;
host.appendChild(el);
await el.updateComplete;
return el;
};
// Drags the first visible area of a floor below the second one and returns
// the configuration the editor emits.
const reorderFirstTwo = async (
el: HaAreasFloorsDisplayEditor,
floorId: string
): Promise<AreasFloorsDisplayValue> => {
const editor = Array.from(
el.shadowRoot!.querySelectorAll("ha-items-display-editor")
).find(
(child) =>
(child as HaItemDisplayEditor & { floorId: string }).floorId === floorId
)!;
await editor.updateComplete;
const emitted: AreasFloorsDisplayValue[] = [];
el.addEventListener(
"value-changed",
(ev) => emitted.push((ev as CustomEvent).detail.value),
{ once: true }
);
editor.shadowRoot!.querySelector("ha-sortable")!.dispatchEvent(
new CustomEvent("item-moved", {
detail: { oldIndex: 0, newIndex: 1 },
bubbles: true,
composed: true,
})
);
expect(emitted).toHaveLength(1);
return emitted[0];
};
it.each([
{
name: "hidden area on another floor",
floorId: "floor_a",
value: {
areas_display: { hidden: ["b1"], order: ["b3", "b2"] },
},
expected: {
hidden: ["b1"],
order: ["a2", "a1", "a3", "b3", "b2"],
},
},
{
name: "hidden areas on the reordered and another floor",
floorId: "floor_a",
value: { areas_display: { hidden: ["a3", "b1"] } },
expected: { hidden: ["a3", "b1"], order: ["a2", "a1"] },
},
{
name: "reordering areas without a floor",
floorId: UNASSIGNED_FLOOR,
value: { areas_display: { hidden: ["b1"] } },
expected: { hidden: ["b1"], order: ["u2", "u1"] },
},
{
name: "no hidden areas",
floorId: "floor_b",
value: {},
expected: { order: ["b2", "b1", "b3"] },
},
])(
"keeps hidden areas unchanged when reordering: $name",
async ({ floorId, value, expected }) => {
const el = await mount(value);
const first = await reorderFirstTwo(el, floorId);
expect(first.areas_display).toEqual(expected);
// Saving and reordering again must not grow the hidden list
el.value = first;
await el.updateComplete;
const second = await reorderFirstTwo(el, floorId);
expect(second.areas_display?.hidden).toEqual(expected.hidden);
}
);
});
-113
View File
@@ -1,113 +0,0 @@
/**
* @vitest-environment node
*/
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import template from "lodash.template";
import { afterEach, describe, expect, it, vi } from "vitest";
import patterns from "../../src/util/stale-build-patterns.json";
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../.."
);
// Renders a page template as build-scripts/gulp/entry-html.js does.
const renderTemplate = (templateFile, data) =>
template(readFileSync(templateFile, "utf-8"))({
...data,
renderTemplate: (childTemplate) =>
renderTemplate(
path.resolve(path.dirname(templateFile), childTemplate),
data
),
});
// The index script that starts the extra modules, with Core's Jinja loops
// rendered for one extra module and one legacy extra script.
const extraModulesScript = () => {
const html = renderTemplate(
path.join(repoRoot, "src/html/index.html.template"),
{
modernRegex: "/modern/",
hassUrl: "",
staleBuildPatterns: patterns,
useCacheRecovery: true,
latestEntryJS: ["/frontend_latest/app.a1b2c3d4e5.js"],
es5EntryJS: ["/frontend_es5/app.d1e2f3a4b5.js"],
latestCustomPanelJS: "/frontend_latest/custom-panel.e1f2a3b4c5.js",
es5CustomPanelJS: "/frontend_es5/custom-panel.f1a2b3c4d5.js",
}
);
const script = html
.split("<script>")
.map((part) => part.slice(0, part.indexOf("</script>")))
.find((part) => part.includes("__haLoadExtraModules"));
return script
.replace(
/\{%-? for extra_module in extra_modules -?%\}([\s\S]*?)\{%-? endfor -?%\}/,
(_match, body) => body.replaceAll("{{ extra_module }}", "/local/card.js")
)
.replace(
/\{%-? for extra_script in extra_js_es5 -?%\}([\s\S]*?)\{%-? endfor -?%\}/,
(_match, body) =>
body.replaceAll("{{ extra_script }}", "/local/legacy.js")
)
.replace(/\bimport\(/g, "loadModule(");
};
describe("index.html extra modules", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
const setup = (latestJS) => {
const win = { latestJS };
const started = [];
vi.stubGlobal("window", win);
vi.resetModules();
return {
started,
runIndexScript: () => {
// eslint-disable-next-line no-new-func
new Function("window", "_ls", "loadModule", extraModulesScript())(
win,
(src) => started.push(src),
(src) => {
started.push(src);
return Promise.resolve();
}
);
},
// What app.ts runs right after the polyfill import.
runAppEntry: () => import("../../src/resources/scoped-registry-ready"),
};
};
it.each([
["modern", true, ["/local/card.js"]],
["legacy", false, ["/local/card.js", "/local/legacy.js"]],
])("waits for the app entry on the %s build", async (_, latestJS, all) => {
const { started, runIndexScript, runAppEntry } = setup(latestJS);
runIndexScript();
expect(started).toEqual([]);
await runAppEntry();
expect(started).toEqual(all);
});
it.each([
["modern", true, ["/local/card.js"]],
["legacy", false, ["/local/card.js", "/local/legacy.js"]],
])(
"starts right away when the app entry ran first on the %s build",
async (_, latestJS, all) => {
const { started, runIndexScript, runAppEntry } = setup(latestJS);
await runAppEntry();
expect(started).toEqual([]);
runIndexScript();
expect(started).toEqual(all);
}
);
});
@@ -2,13 +2,8 @@ import { describe, expect, it } from "vitest";
import {
filterLowBatteryEntities,
filterProblemEntities,
MaintenanceViewStrategy,
} from "../../../../src/panels/maintenance/strategies/maintenance-view-strategy";
import {
mockDevice,
mockEntity,
} from "../../../common/entity/context/context-mock";
import type { LovelaceSectionConfig } from "../../../../src/data/lovelace/config/section";
import { mockEntity } from "../../../common/entity/context/context-mock";
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
describe("filterLowBatteryEntities", () => {
@@ -214,70 +209,3 @@ describe("filterProblemEntities", () => {
).toEqual(["binary_sensor.has_problem"]);
});
});
describe("MaintenanceViewStrategy.generate", () => {
it("names problem tiles after the device and the entity", async () => {
const hass = createMockHass(
{
"sensor.microwave_battery": createMockEntityState(
"sensor.microwave_battery",
"80",
{ device_class: "battery" }
),
"binary_sensor.microwave_overheating": createMockEntityState(
"binary_sensor.microwave_overheating",
"off",
{ device_class: "problem" }
),
"binary_sensor.standalone_problem": createMockEntityState(
"binary_sensor.standalone_problem",
"off",
{ device_class: "problem" }
),
},
{
devices: {
microwave: mockDevice({ id: "microwave", name: "Microwave" }),
},
entities: {
"sensor.microwave_battery": mockEntity({
entity_id: "sensor.microwave_battery",
device_id: "microwave",
}),
"binary_sensor.microwave_overheating": mockEntity({
entity_id: "binary_sensor.microwave_overheating",
device_id: "microwave",
}),
"binary_sensor.standalone_problem": mockEntity({
entity_id: "binary_sensor.standalone_problem",
}),
},
}
);
const view = await MaintenanceViewStrategy.generate(
{ type: "maintenance" },
hass
);
const section = view.sections?.[0] as LovelaceSectionConfig;
expect(section.cards?.slice(1)).toEqual([
{
type: "tile",
entity: "sensor.microwave_battery",
name: { type: "device" },
},
{
type: "tile",
entity: "binary_sensor.microwave_overheating",
name: [{ type: "device" }, { type: "entity" }],
},
{
type: "tile",
entity: "binary_sensor.standalone_problem",
name: { type: "entity" },
},
]);
});
});