Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Bottein 3fbc8ae758 Rename stored key 2026-07-27 17:31:01 +02:00
Paul Bottein e18cc7ec3a Add favorite cards to the card picker 2026-07-27 17:17:10 +02:00
23 changed files with 930 additions and 607 deletions
@@ -52,23 +52,6 @@ gulp.task("fetch-nightly-translations", async function () {
currentArtifact = null;
}
try {
await fetchTranslations(currentArtifact);
} catch (err) {
// Local builds should work offline or without valid GitHub credentials,
// so fall back to English only. CI must fail instead of silently
// building without translations.
if (process.env.CI) {
throw err;
}
console.warn(
"Failed to fetch nightly translations, continuing with English only:",
err?.message || err
);
}
});
async function fetchTranslations(currentArtifact) {
// To store file writing promises
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
const writings = [];
@@ -147,6 +130,11 @@ async function fetchTranslations(currentArtifact) {
if (!latestArtifact) {
throw Error("Latest nightly workflow run has no translations artifact");
}
writings.push(
createExtractDir.then(
writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2))
)
);
// Remove the current translations
const deleteCurrent = Promise.all(writings).then(
@@ -172,12 +160,7 @@ async function fetchTranslations(currentArtifact) {
await new Promise((resolve, reject) => {
extractStream.on("close", resolve).on("error", reject);
});
// Record the artifact only after successful extraction, so a failed fetch
// is retried by the next build instead of being considered current.
await createExtractDir;
await writeFile(ARTIFACT_FILE, JSON.stringify(latestArtifact, null, 2));
}
});
gulp.task(
"setup-and-fetch-nightly-translations",
+1 -2
View File
@@ -106,8 +106,7 @@
{
"description": "Group Playwright package and CI container updates",
"groupName": "Playwright",
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
"minimumGroupSize": 5
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"]
}
]
}
@@ -0,0 +1,120 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { LitElement } from "lit";
import type { Ref } from "lit/directives/ref";
const scrollParent = (element: Element): HTMLElement | undefined => {
let node = element.parentElement;
while (node) {
const { overflowY } = getComputedStyle(node);
if (overflowY === "auto" || overflowY === "scroll") {
return node;
}
node = node.parentElement;
}
return undefined;
};
/**
* Does what CSS scroll anchoring does in Chrome and Firefox but not in Safari.
* Point the ref at the element that grows, not at the scroller. Turns native
* anchoring off on that scroller, so growth elsewhere in it is no longer
* compensated either.
*/
export class PreserveScrollPositionController implements ReactiveController {
private _target: Ref<HTMLElement>;
private _element?: HTMLElement;
private _scroller?: HTMLElement;
private _observer?: ResizeObserver;
private _height = 0;
constructor(
host: ReactiveControllerHost & LitElement,
target: Ref<HTMLElement>
) {
this._target = target;
host.addController(this);
}
hostConnected() {
this._sync();
}
hostUpdated() {
this._sync();
}
hostDisconnected() {
this._detach();
}
private _sync() {
const element = this._target.value;
if (element === this._element) {
return;
}
this._detach();
this._element = element;
if (element) {
this._height = element.getBoundingClientRect().height;
this._observer = new ResizeObserver((entries) =>
this._compensate(entries)
);
this._observer.observe(element);
}
}
private _detach() {
this._observer?.disconnect();
this._observer = undefined;
this._element = undefined;
if (this._scroller) {
this._scroller.style.removeProperty("overflow-anchor");
this._scroller = undefined;
}
}
private _resolveScroller(): HTMLElement | undefined {
if (!this._scroller && this._element) {
this._scroller = scrollParent(this._element);
if (this._scroller) {
// Chrome and Firefox would otherwise anchor on top of this controller
// and correct twice.
this._scroller.style.overflowAnchor = "none";
}
}
return this._scroller;
}
private _compensate(entries: ResizeObserverEntry[]) {
const element = this._element;
if (!element) {
return;
}
const height =
entries[0]?.borderBoxSize?.[0]?.blockSize ?? element.offsetHeight;
const delta = height - this._height;
this._height = height;
if (!delta) {
return;
}
const scroller = this._resolveScroller();
if (
scroller &&
element.getBoundingClientRect().top < scroller.getBoundingClientRect().top
) {
scroller.scrollTop += delta;
}
}
}
+14 -16
View File
@@ -542,14 +542,20 @@ export class HaServiceControl extends LitElement {
}
${
serviceData && "target" in serviceData
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector>`
? html`<ha-settings-row
.narrow=${this.narrow || isFullWidthSelector(targetSelector)}
>
<span slot="heading"
>${this.hass.localize("ui.components.service-control.target")}</span
>
<ha-selector
.hass=${this.hass}
.selector=${targetSelector}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector
></ha-settings-row>`
: entityId
? html`<ha-entity-picker
.disabled=${this.disabled}
@@ -1053,14 +1059,6 @@ export class HaServiceControl extends LitElement {
display: block;
margin: var(--service-control-padding, 0 var(--ha-space-4));
}
ha-selector.target-selector {
display: block;
padding: var(--ha-space-2) var(--ha-space-4);
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
+1 -1
View File
@@ -1342,7 +1342,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
.item-groups {
overflow: hidden;
border: var(--ha-border-width-sm) solid var(--divider-color);
border: 2px solid var(--divider-color);
border-radius: var(--ha-border-radius-lg);
}
`;
@@ -8,13 +8,6 @@ import "../ha-expansion-panel";
import "../list/ha-list-base";
import "./ha-target-picker-item-row";
const TYPE_PLURAL = {
entity: "entities",
device: "devices",
area: "areas",
label: "labels",
} as const satisfies Record<TargetTypeFloorless, string>;
@customElement("ha-target-picker-item-group")
export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -67,11 +60,11 @@ export class HaTargetPickerItemGroup extends LitElement {
>
<div slot="header" class="heading">
${this.hass.localize(
`ui.components.target-picker.type.${TYPE_PLURAL[this.type]}`
`ui.components.target-picker.selected.${this.type}`,
{
count,
}
)}
${
this.collapsed ? html`<span class="count">(${count})</span>` : nothing
}
</div>
<ha-list-base>
${Object.entries(this.items).map(([type, items]) =>
@@ -113,10 +106,6 @@ export class HaTargetPickerItemGroup extends LitElement {
justify-content: space-between;
min-height: unset;
}
.count {
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-normal);
}
`;
}
+1
View File
@@ -10,6 +10,7 @@ export interface CoreFrontendUserData {
showEntityIdPicker?: boolean;
default_panel?: string;
apps_info_dismissed?: boolean;
dashboard_favorite_card_types?: string[];
}
export interface SidebarFrontendUserData {
+4 -7
View File
@@ -135,14 +135,11 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
this.update = super.update;
// Apps with a native splash screen keep covering the frontend until
// frontend/loaded, so the launch screen stays up (invisibly) until the
// first panel has rendered and partial-panel-resolver removes it and
// fires frontend/loaded.
if (
!this.hass.auth.external?.config.hasSplashscreen &&
removeLaunchScreen()
) {
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
// first panel has rendered and partial-panel-resolver removes it.
if (!this.hass.auth.external?.config.hasSplashscreen) {
removeLaunchScreen();
}
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
super.update(changedProps);
}
+2 -7
View File
@@ -220,13 +220,8 @@ class PartialPanelResolver extends HassRouterPage {
) {
await this.rebuild();
await this.pageRendered;
// Only fire frontend/loaded when this call actually removed the launch
// screen, so later panel updates do not fire it again.
if (
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen)
) {
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
removeLaunchScreen(!!this.hass.auth.external?.config.hasSplashscreen);
this.hass.auth.external?.fireMessage({ type: "frontend/loaded" });
}
}
}
@@ -1,7 +1,7 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { consume } from "@lit/context";
import "@material/mwc-list/mwc-list";
import { mdiPlus, mdiTextureBox, mdiUnfoldMoreHorizontal } from "@mdi/js";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import {
@@ -98,16 +98,8 @@ export const ITEM_SEARCH_KEYS: FuseWeightedKey[] = [
},
];
const MAX_SEARCH_ITEMS_PER_SECTION = 5;
type SearchSection = "item" | "block" | "entity" | "device" | "area" | "label";
interface SearchMoreComboBoxItem extends PickerComboBoxItem {
type: "more";
section: "entity" | "device" | "item";
label: string;
}
@customElement("ha-automation-add-search")
export class HaAutomationAddSearch extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -296,8 +288,7 @@ export class HaAutomationAddSearch extends LitElement {
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem
| AutomationItemComboBoxItem
| SearchMoreComboBoxItem,
| AutomationItemComboBoxItem,
index: number
) => {
if (!item) {
@@ -308,24 +299,6 @@ export class HaAutomationAddSearch extends LitElement {
return html`<ha-section-title>${item}</ha-section-title>`;
}
if ("type" in item && item.type === "more") {
return html`<ha-combo-box-item
id=${`search-list-item-${index}`}
type="button"
tabindex="-1"
.section-id=${item.section}
.value=${`more-${item.section}`}
@click=${this._toggleSection}
>
<ha-svg-icon
slot="start"
.path=${mdiUnfoldMoreHorizontal}
></ha-svg-icon>
<span slot="headline"></span>
<span slot="supporting-text">${item.label}</span>
</ha-combo-box-item>`;
}
const type = ["trigger", "condition", "action", "block"].includes(
(item as AutomationItemComboBoxItem).type
)
@@ -539,17 +512,10 @@ export class HaAutomationAddSearch extends LitElement {
selectedSection?: SearchSection,
relatedIdSets?: RelatedIdSets
) => {
type ResultItem =
| string
| FloorComboBoxItem
| EntityComboBoxItem
| PickerComboBoxItem
| SearchMoreComboBoxItem;
const resultSections: {
title: string;
type: string;
items: ResultItem[];
}[] = [];
const resultItems: (
string | FloorComboBoxItem | EntityComboBoxItem | PickerComboBoxItem
)[] = [];
if (!selectedSection || selectedSection === "item") {
let items = this._convertItemsToComboBoxItems(automationItems, type);
if (searchTerm) {
@@ -560,13 +526,15 @@ export class HaAutomationAddSearch extends LitElement {
ITEM_SEARCH_KEYS
) as AutomationItemComboBoxItem[];
}
if (items.length) {
resultSections.push({
title: localize(`ui.panel.config.automation.editor.${type}s.name`),
type: "item",
items: items,
});
if (!selectedSection && items.length) {
// show group title
resultItems.push(
localize(`ui.panel.config.automation.editor.${type}s.name`)
);
}
resultItems.push(...items);
}
if (
@@ -596,13 +564,13 @@ export class HaAutomationAddSearch extends LitElement {
) as AutomationItemComboBoxItem[];
}
if (blocks.length) {
resultSections.push({
title: localize("ui.panel.config.automation.editor.blocks"),
type: "block",
items: blocks,
});
if (!selectedSection && blocks.length) {
// show group title
resultItems.push(
localize("ui.panel.config.automation.editor.blocks")
);
}
resultItems.push(...blocks);
}
if (!selectedSection || selectedSection === "entity") {
@@ -633,13 +601,14 @@ export class HaAutomationAddSearch extends LitElement {
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
}
if (entityItems.length) {
resultSections.push({
title: localize("ui.components.target-picker.type.entities"),
type: "entity",
items: entityItems,
});
if (!selectedSection && entityItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.entities")
);
}
resultItems.push(...entityItems);
}
if (!selectedSection || selectedSection === "device") {
@@ -671,13 +640,14 @@ export class HaAutomationAddSearch extends LitElement {
deviceItems = sortRelatedFirst(deviceItems);
}
if (deviceItems.length) {
resultSections.push({
title: localize("ui.components.target-picker.type.devices"),
type: "device",
items: deviceItems,
});
if (!selectedSection && deviceItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.devices")
);
}
resultItems.push(...deviceItems);
}
if (!selectedSection || selectedSection === "area") {
@@ -727,9 +697,15 @@ export class HaAutomationAddSearch extends LitElement {
) as FloorComboBoxItem[];
}
if (areasAndFloors.length) {
const areaItems = areasAndFloors.map((item, index) => {
if (!selectedSection && areasAndFloors.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.areas"));
}
resultItems.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
@@ -739,15 +715,10 @@ export class HaAutomationAddSearch extends LitElement {
last: true,
};
}
return item;
});
resultSections.push({
title: localize("ui.components.target-picker.type.areas"),
type: "area",
items: areaItems,
});
}
return item;
})
);
}
if (!selectedSection || selectedSection === "label") {
@@ -775,47 +746,13 @@ export class HaAutomationAddSearch extends LitElement {
);
}
if (labels.length) {
resultSections.push({
title: localize("ui.components.target-picker.type.labels"),
type: "label",
items: labels,
});
if (!selectedSection && labels.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.labels"));
}
}
const resultItems: ResultItem[] = [];
resultSections.forEach((section, index) => {
if (selectedSection) {
resultItems.push(...section.items);
return;
}
resultItems.push(section.title);
if (
index !== resultSections.length - 1 &&
(section.type === "item" ||
section.type === "entity" ||
section.type === "device") &&
section.items.length > MAX_SEARCH_ITEMS_PER_SECTION + 1
) {
resultItems.push(
...section.items.slice(0, MAX_SEARCH_ITEMS_PER_SECTION)
);
const typeKey = section.type === "item" ? type : section.type;
resultItems.push({
primary: "",
id: `search-more-${section.type}`,
type: "more",
section: section.type,
label: localize(
`ui.panel.config.automation.editor.show_more_search.${typeKey}`,
{ count: section.items.length - MAX_SEARCH_ITEMS_PER_SECTION }
),
});
} else {
resultItems.push(...section.items);
}
});
resultItems.push(...labels);
}
return resultItems;
}
@@ -853,22 +790,18 @@ export class HaAutomationAddSearch extends LitElement {
);
}
private _toggleSection = (ev: Event) => {
private _toggleSection(ev: Event) {
ev.stopPropagation();
// this._resetSelectedItem();
this._searchSectionTitle = undefined;
const section = (ev.currentTarget as HTMLElement)["section-id"] as string;
const section = (ev.target as HTMLElement)["section-id"] as string;
if (!section) {
return;
}
this._toggleSectionType(section);
};
private _toggleSectionType(type: string) {
if (this._selectedSearchSection === type) {
if (this._selectedSearchSection === section) {
this._selectedSearchSection = undefined;
} else {
this._selectedSearchSection = type as SearchSection;
this._selectedSearchSection = section as SearchSection;
}
// Reset scroll position when filter changes
@@ -1086,13 +1019,9 @@ export class HaAutomationAddSearch extends LitElement {
const item = this._virtualizerElement?.items[
this._selectedSearchItemIndex
] as PickerComboBoxItem | SearchMoreComboBoxItem;
] as PickerComboBoxItem;
if (item) {
if ("type" in item && item.type === "more") {
this._toggleSectionType((item as SearchMoreComboBoxItem).section);
} else {
this._selectSearchItem(item as PickerComboBoxItem);
}
this._selectSearchItem(item);
}
};
@@ -218,14 +218,20 @@ export class HaPlatformCondition extends LitElement {
</div>
${
conditionDesc && "target" in conditionDesc
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${this._targetSelector(conditionDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.condition?.target}
></ha-selector>`
? html`<ha-settings-row narrow>
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(conditionDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.condition?.target}
></ha-selector
></ha-settings-row>`
: nothing
}
${
@@ -664,14 +670,6 @@ export class HaPlatformCondition extends LitElement {
display: block;
margin: 0 var(--ha-space-4);
}
ha-selector.target-selector {
display: block;
padding: var(--ha-space-2) var(--ha-space-4);
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -213,14 +213,19 @@ export class HaPlatformTrigger extends LitElement {
</div>
${
triggerDesc && "target" in triggerDesc
? html`<ha-selector
class="target-selector"
.hass=${this.hass}
.selector=${this._targetSelector(triggerDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.trigger?.target}
></ha-selector>`
? html`<ha-settings-row narrow>
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${this._targetSelector(triggerDesc.target)}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this.trigger?.target}
></ha-selector
></ha-settings-row>`
: nothing
}
${
@@ -543,14 +548,6 @@ export class HaPlatformTrigger extends LitElement {
display: block;
margin: 0 var(--ha-space-4);
}
ha-selector.target-selector {
display: block;
padding: var(--ha-space-2) var(--ha-space-4);
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-yaml-editor {
padding: var(--ha-space-4) 0;
}
@@ -54,6 +54,9 @@ export interface BuildSankeyDeviceNodesOptions {
getValue: (id: string) => number;
getLabel: (id: string, name: string | undefined) => string;
getEntityId: (id: string) => string | undefined;
findEffectiveParent: (
includedInStat: string | undefined
) => string | undefined;
}
/**
@@ -83,6 +86,7 @@ export const buildSankeyDeviceNodes = (
getValue,
getLabel,
getEntityId,
findEffectiveParent,
} = options;
const unavailableColor = computedStyle
@@ -96,62 +100,19 @@ export const buildSankeyDeviceNodes = (
const smallConsumerStats = new Set<string>();
let untrackedConsumption = initialUntracked;
// Resolve every device's node id and value once. `included_in_stat` always
// names a stat_consumption, so the hierarchy is keyed by that, while the
// rendered set holds node ids (stat_consumption or stat_rate, per `getId`).
const deviceByStat = new Map<string, DeviceConsumptionEnergyPreference>();
const deviceValues = new Map<string, number>();
const renderedIds = new Set<string>();
// Parent chain in stat_consumption space, used to detect nested small consumers
const deviceParents = new Map<string, string | undefined>();
devices.forEach((device) => {
deviceByStat.set(device.stat_consumption, device);
deviceParents.set(device.stat_consumption, device.included_in_stat);
});
devices.forEach((device, idx) => {
const id = getId(device);
// Falsy check (not `=== undefined`) mirrors the cards' original `!stat_rate` guard.
if (!id) {
return;
}
const value = getValue(id);
deviceValues.set(id, value);
if (value >= minThreshold) {
renderedIds.add(id);
}
});
/** Node id of a device rendered as its own node, else undefined. */
const renderedId = (statConsumption: string): string | undefined => {
const device = deviceByStat.get(statConsumption);
if (!device) {
return undefined;
}
const id = getId(device);
return id && renderedIds.has(id) ? id : undefined;
};
// Walk up the included_in_stat chain to the first ancestor that is rendered.
// Bounded because a hand-edited config can make included_in_stat cyclic.
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let current = includedInStat;
for (let hops = 0; current && hops < devices.length; hops++) {
const rendered = renderedId(current);
if (rendered) {
return rendered;
}
const device = deviceByStat.get(current);
if (!device) {
return undefined;
}
current = device.included_in_stat;
}
return undefined;
};
devices.forEach((device, idx) => {
const id = getId(device);
if (!id) {
return;
}
const value = deviceValues.get(id)!;
const effectiveParent = findEffectiveParent(device.included_in_stat);
if (value < minThreshold) {
@@ -194,19 +155,14 @@ export const buildSankeyDeviceNodes = (
smallConsumersByParent.forEach((allConsumers, parentKey) => {
// A small consumer whose included_in_stat chain reaches another small
// consumer is already counted inside that ancestor's value - drop it so
// totals don't double-count nested devices. A rendered ancestor ends the
// walk: the consumer links to it and never touches untracked, so it can't
// be double-counted through anything further up.
// totals don't double-count nested devices.
const consumers = allConsumers.filter((consumer) => {
let ancestor = consumer.includedInStat;
for (let hops = 0; ancestor && hops < devices.length; hops++) {
if (renderedId(ancestor)) {
return true;
}
if (smallConsumerStats.has(ancestor)) {
return false;
}
ancestor = deviceByStat.get(ancestor)?.included_in_stat;
ancestor = deviceParents.get(ancestor);
}
return true;
});
@@ -272,6 +272,35 @@ class HuiEnergySankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
// Set of device stats that will be rendered as their own node
const renderedStats = new Set<string>();
prefs.device_consumption.forEach((device) => {
if (deviceValue(device.stat_consumption) >= minEnergyThreshold) {
renderedStats.add(device.stat_consumption);
}
});
// Walk up the included_in_stat chain to the first ancestor that is rendered
const deviceMap = new Map<string, string | undefined>();
prefs.device_consumption.forEach((device) => {
deviceMap.set(device.stat_consumption, device.included_in_stat);
});
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
if (renderedStats.has(currentParent)) {
return currentParent;
}
if (!deviceMap.has(currentParent)) {
return undefined;
}
currentParent = deviceMap.get(currentParent);
}
return undefined;
};
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
getStatisticLabel(
@@ -298,6 +327,7 @@ class HuiEnergySankeyCard
getValue: deviceValue,
getLabel: deviceLabel,
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -278,6 +278,54 @@ class HuiPowerSankeyCard
}
}
// Build a map of device relationships for hierarchy resolution
// Key: stat_consumption (energy), Value: { stat_rate, included_in_stat }
const deviceMap = new Map<
string,
{ stat_rate?: string; included_in_stat?: string }
>();
prefs.device_consumption.forEach((device) => {
deviceMap.set(device.stat_consumption, {
stat_rate: device.stat_rate,
included_in_stat: device.included_in_stat,
});
});
// Set of stat_rate entities that will be rendered as nodes
const renderedStatRates = new Set<string>();
prefs.device_consumption.forEach((device) => {
if (device.stat_rate) {
const value = this._getCurrentPower(device.stat_rate);
if (value >= minPowerThreshold) {
renderedStatRates.add(device.stat_rate);
}
}
});
// Find the effective parent for power hierarchy
// Walks up the chain to find an ancestor with stat_rate that will be rendered
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
const parentDevice = deviceMap.get(currentParent);
if (!parentDevice) {
return undefined;
}
// If this parent has a stat_rate and will be rendered, use it
if (
parentDevice.stat_rate &&
renderedStatRates.has(parentDevice.stat_rate)
) {
return parentDevice.stat_rate;
}
// Otherwise, continue up the chain
currentParent = parentDevice.included_in_stat;
}
return undefined;
};
const {
deviceNodes,
parentLinks,
@@ -296,6 +344,7 @@ class HuiPowerSankeyCard
getValue: (id) => this._getCurrentPower(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getEntityId: (id) => id,
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -241,6 +241,48 @@ class HuiWaterFlowSankeyCard
}
}
// Build a map of device relationships for hierarchy resolution
const deviceMap = new Map<
string,
{ stat_rate?: string; included_in_stat?: string }
>();
prefs.device_consumption_water.forEach((device) => {
deviceMap.set(device.stat_consumption, {
stat_rate: device.stat_rate,
included_in_stat: device.included_in_stat,
});
});
// Set of stat_rate entities that will be rendered as nodes
const renderedStatRates = new Set<string>();
prefs.device_consumption_water.forEach((device) => {
if (device.stat_rate) {
const value = this._getCurrentFlowRate(device.stat_rate);
if (value >= minFlowThreshold) {
renderedStatRates.add(device.stat_rate);
}
}
});
// Find the effective parent for hierarchy
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
const parentDevice = deviceMap.get(currentParent);
if (!parentDevice) return undefined;
if (
parentDevice.stat_rate &&
renderedStatRates.has(parentDevice.stat_rate)
) {
return parentDevice.stat_rate;
}
currentParent = parentDevice.included_in_stat;
}
return undefined;
};
const {
deviceNodes,
parentLinks,
@@ -259,6 +301,7 @@ class HuiWaterFlowSankeyCard
getValue: (id) => this._getCurrentFlowRate(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getEntityId: (id) => id,
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -215,6 +215,35 @@ class HuiWaterSankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
// Set of device stats that will be rendered as their own node
const renderedStats = new Set<string>();
prefs.device_consumption_water.forEach((device) => {
if (deviceValue(device.stat_consumption) >= minWaterThreshold) {
renderedStats.add(device.stat_consumption);
}
});
// Walk up the included_in_stat chain to the first ancestor that is rendered
const deviceMap = new Map<string, string | undefined>();
prefs.device_consumption_water.forEach((device) => {
deviceMap.set(device.stat_consumption, device.included_in_stat);
});
const findEffectiveParent = (
includedInStat: string | undefined
): string | undefined => {
let currentParent = includedInStat;
while (currentParent) {
if (renderedStats.has(currentParent)) {
return currentParent;
}
if (!deviceMap.has(currentParent)) {
return undefined;
}
currentParent = deviceMap.get(currentParent);
}
return undefined;
};
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
getStatisticLabel(
@@ -241,6 +270,7 @@ class HuiWaterSankeyCard
getValue: deviceValue,
getLabel: deviceLabel,
getEntityId: (id) => (isExternalStatistic(id) ? undefined : id),
findEffectiveParent,
});
links.push(...deviceLinks);
@@ -1,19 +1,27 @@
import { mdiSort, mdiStar, mdiStarOutline } from "@mdi/js";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { PreserveScrollPositionController } from "../../../../common/controllers/preserve-scroll-position-controller";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import { stringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-ripple";
import "../../../../components/ha-spinner";
import "../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../components/input/ha-input-search";
import { UNAVAILABLE, UNKNOWN } from "../../../../data/entity/entity";
import { saveFrontendUserData } from "../../../../data/frontend";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { CustomCardEntry } from "../../../../data/lovelace_custom_cards";
@@ -24,6 +32,7 @@ import {
} from "../../../../data/lovelace_custom_cards";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import {
calcUnusedEntities,
computeUsedEntities,
@@ -33,12 +42,18 @@ import type { LovelaceCard } from "../../types";
import { getCardStubConfig } from "../get-card-stub-config";
import { coreCards, energyCards } from "../lovelace-cards";
import type { Card, CardPickTarget } from "../types";
import { showReorderFavoriteCardsDialog } from "./show-reorder-favorite-cards-dialog";
interface CardElement {
card: Card;
element: TemplateResult;
}
const cardKey = (card: Card): string =>
card.isCustom ? `${CUSTOM_TYPE_PREFIX}${card.type}` : card.type;
const SPINNER = html`<div class="spinner"><ha-spinner></ha-spinner></div>`;
@customElement("hui-card-picker")
export class HuiCardPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -56,6 +71,8 @@ export class HuiCardPicker extends LitElement {
@state() private _cards: CardElement[] = [];
@state() private _favorites: string[] = [];
public lovelace?: LovelaceConfig;
public cardPicked?: (cardConf: LovelaceCardConfig) => void;
@@ -64,10 +81,23 @@ export class HuiCardPicker extends LitElement {
@query("ha-input-search") private _searchInput?: HaInputSearch;
@query("#content") private _content?: HTMLElement;
private _unusedEntities?: string[];
private _usedEntities?: string[];
private _suggestedCards: CardElement[] = [];
private _favoriteElements = new Map<string, CardElement>();
private _topSection = createRef<HTMLElement>();
public preserveScrollPosition = new PreserveScrollPositionController(
this,
this._topSection
);
public async focus(): Promise<void> {
await this.updateComplete;
// Wait for the input's inner wa-input to render so focus delegation works.
@@ -99,18 +129,10 @@ export class HuiCardPicker extends LitElement {
}
);
private _suggestedCards = memoizeOne(
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) => cardElement.card.isSuggested
)
);
private _customCards = memoizeOne(
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) =>
cardElement.card.isCustom && !cardElement.card.isSuggested
(cardElement: CardElement) => cardElement.card.isCustom
)
);
@@ -118,9 +140,7 @@ export class HuiCardPicker extends LitElement {
(cardElements: CardElement[]): CardElement[] =>
cardElements.filter(
(cardElement: CardElement) =>
!cardElement.card.isSuggested &&
!cardElement.card.isCustom &&
!cardElement.card.isEnergy
!cardElement.card.isCustom && !cardElement.card.isEnergy
)
);
@@ -131,6 +151,34 @@ export class HuiCardPicker extends LitElement {
)
);
private _favoriteCards(): CardElement[] {
return this._favorites
.map((key) => this._favoriteElement(key))
.filter((cardElement): cardElement is CardElement => !!cardElement);
}
// A preview is a live DOM node, so every section a card shows up in needs
// its own element.
private _toCardElement(card: Card): CardElement {
return {
card,
element: html`${until(this._renderCardElement(card), SPINNER)}`,
};
}
private _favoriteElement(key: string): CardElement | undefined {
let cardElement = this._favoriteElements.get(key);
if (!cardElement) {
const card = this._cards.find((item) => cardKey(item.card) === key)?.card;
if (!card) {
return undefined;
}
cardElement = this._toCardElement(card);
this._favoriteElements.set(key, cardElement);
}
return cardElement;
}
protected render() {
if (
!this.hass ||
@@ -141,7 +189,8 @@ export class HuiCardPicker extends LitElement {
return nothing;
}
const suggestedCards = this._suggestedCards(this._cards);
const favoriteCards = this._favoriteCards();
const suggestedCards = favoriteCards.length > 0 ? [] : this._suggestedCards;
const othersCards = this._otherCards(this._cards);
const energyCardsItems = this._energyCards(this._cards);
const customCardsItems = this._customCards(this._cards);
@@ -160,53 +209,106 @@ export class HuiCardPicker extends LitElement {
this._filter
? html`<div class="cards-container">
${this._filterCards(this._cards, this._filter).map(
(cardElement: CardElement) => cardElement.element
(cardElement: CardElement) => this._renderCard(cardElement)
)}
</div>`
: html`
${
suggestedCards.length > 0
? html` <ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.suggested_cards`
)}
</div>
<div class="cards-container">
${this._renderClipboardCard()}
${suggestedCards.map(
(cardElement: CardElement) => cardElement.element
)}
</div>
</ha-expansion-panel>`
: nothing
}
<div ${ref(this._topSection)}>
${
favoriteCards.length > 0
? html`<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.favorite_cards`
)}</span
>
${
favoriteCards.length > 1
? html`<ha-icon-button
class="reorder-favorites"
.path=${mdiSort}
.label=${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.reorder_favorites`
)}
@click=${this._reorderFavorites}
></ha-icon-button>`
: nothing
}
</div>
<div class="cards-container">
${this._renderClipboardCard(this._clipboard, this.hass!.locale)}
${repeat(
favoriteCards,
(cardElement: CardElement) =>
cardKey(cardElement.card),
(cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>`
: nothing
}
${
suggestedCards.length > 0
? html`<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.suggested_cards`
)}</span
>
</div>
<div class="cards-container">
${
favoriteCards.length === 0
? this._renderClipboardCard(
this._clipboard,
this.hass!.locale
)
: nothing
}
${suggestedCards.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>`
: nothing
}
</div>
<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.core_cards`
)}
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.core_cards`
)}</span
>
</div>
<div class="cards-container">
${
suggestedCards.length === 0
? this._renderClipboardCard()
favoriteCards.length === 0 && suggestedCards.length === 0
? this._renderClipboardCard(
this._clipboard,
this.hass!.locale
)
: nothing
}
${othersCards.map(
(cardElement: CardElement) => cardElement.element
${othersCards.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>
<ha-expansion-panel>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.energy_cards`
)}
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.energy_cards`
)}</span
>
</div>
<div class="cards-container">
${energyCardsItems.map(
(cardElement: CardElement) => cardElement.element
${energyCardsItems.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>
@@ -215,13 +317,15 @@ export class HuiCardPicker extends LitElement {
? html`
<ha-expansion-panel expanded>
<div slot="header" class="cards-container-header">
${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.custom_cards`
)}
<span class="title"
>${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.custom_cards`
)}</span
>
</div>
<div class="cards-container">
${customCardsItems.map(
(cardElement: CardElement) => cardElement.element
${customCardsItems.map((cardElement: CardElement) =>
this._renderCard(cardElement)
)}
</div>
</ha-expansion-panel>
@@ -254,16 +358,12 @@ export class HuiCardPicker extends LitElement {
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
if (changedProps.size > 1 || !changedProps.has("hass")) {
return true;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass) {
return true;
}
if (oldHass.locale !== this.hass!.locale) {
return true;
}
return false;
return !oldHass || oldHass.locale !== this.hass!.locale;
}
protected firstUpdated(): void {
@@ -283,16 +383,15 @@ export class HuiCardPicker extends LitElement {
this._usedEntities = [...usedEntities].filter(isAvailable);
this._unusedEntities = [...unusedEntities].filter(isAvailable);
this._favorites = this.hass.userData?.dashboard_favorite_card_types ?? [];
this._loadCards();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_filter")) {
const div = this.shadowRoot!.getElementById("content");
if (div) {
div.scrollTo({ behavior: "auto", top: 0 });
}
this._content?.scrollTo({ behavior: "auto", top: 0 });
}
}
@@ -308,19 +407,9 @@ export class HuiCardPicker extends LitElement {
...card,
}));
cards = cards.sort((a, b) => {
if (a.isSuggested && !b.isSuggested) {
return -1;
}
if (!a.isSuggested && b.isSuggested) {
return 1;
}
return stringCompare(
a.name || a.type,
b.name || b.type,
this.hass?.language
);
});
cards = cards.sort((a, b) =>
stringCompare(a.name || a.type, b.name || b.type, this.hass?.language)
);
cards = cards.concat(
energyCards.map((card: Card) => ({
@@ -354,48 +443,126 @@ export class HuiCardPicker extends LitElement {
)
);
}
this._cards = cards.map((card: Card) => ({
card: card,
element: html`${until(
this._renderCardElement(card),
html`
<div class="card spinner">
<ha-spinner></ha-spinner>
</div>
`
)}`,
}));
this._cards = cards.map((card: Card) => this._toCardElement(card));
this._suggestedCards = cards
.filter((card: Card) => card.isSuggested)
.map((card: Card) => this._toCardElement(card));
}
private _renderClipboardCard() {
if (!this._clipboard) {
return nothing;
}
private _renderClipboardCard = memoizeOne(
(clipboard: LovelaceCardConfig | undefined, _locale: unknown) => {
if (!clipboard) {
return nothing;
}
return html` ${until(
this._renderCardElement(
{
type: this._clipboard.type,
showElement: true,
isCustom: false,
name: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste"
),
description: `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste_description",
return html`<div class="card" tabindex="0">
${until(
this._renderCardElement(
{
type: this._clipboard.type,
}
)}`,
},
this._clipboard
type: clipboard.type,
showElement: true,
isCustom: false,
name: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste"
),
description: `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.paste_description",
{
type: clipboard.type,
}
)}`,
},
clipboard
),
SPINNER
)}
<ha-ripple></ha-ripple>
</div>`;
}
);
private _renderCard(cardElement: CardElement): TemplateResult {
const key = cardKey(cardElement.card);
const favorite = this._favorites.includes(key);
return html`
<div class="card" tabindex="0">
${cardElement.element}
<ha-icon-button
class="favorite ${classMap({ selected: favorite })}"
.path=${favorite ? mdiStar : mdiStarOutline}
.label=${this.hass!.localize(
favorite
? "ui.panel.lovelace.editor.card.generic.remove_favorite"
: "ui.panel.lovelace.editor.card.generic.add_favorite"
)}
data-card=${key}
@click=${this._toggleFavorite}
@pointerdown=${stopPropagation}
></ha-icon-button>
<ha-ripple></ha-ripple>
</div>
`;
}
private _setFavorite(key: string, value: boolean): void {
const favorites = this._favorites.filter((favorite) => favorite !== key);
this._favorites = value ? [...favorites, key] : favorites;
}
private async _toggleFavorite(ev: Event): Promise<void> {
ev.stopPropagation();
const key = (ev.currentTarget as HTMLElement).dataset.card!;
const adding = !this._favorites.includes(key);
this._setFavorite(key, adding);
try {
await this._persistFavorites();
} catch (_err: any) {
this._setFavorite(key, !adding);
this._showSaveError();
}
}
private _reorderFavorites(ev: Event): void {
ev.preventDefault();
ev.stopPropagation();
showReorderFavoriteCardsDialog(this, {
favorites: this._favoriteCards().map((cardElement) => ({
key: cardKey(cardElement.card),
name: cardElement.card.name || cardElement.card.type,
})),
saveFavorites: (favorites) => this._saveFavorites(favorites),
});
}
private async _saveFavorites(favorites: string[]): Promise<void> {
const previous = this._favorites;
this._favorites = favorites;
try {
await this._persistFavorites();
} catch (_err: any) {
this._favorites = previous;
this._showSaveError();
}
}
private _persistFavorites(): Promise<void> {
return saveFrontendUserData(this.hass!.connection, "core", {
...this.hass!.userData,
dashboard_favorite_card_types: this._favorites,
});
}
private _showSaveError(): void {
showToast(this, {
message: this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.favorite_save_failed"
),
html`
<div class="card spinner">
<ha-spinner></ha-spinner>
</div>
`
)}`;
});
}
private _handleSearchChange(ev: Event) {
@@ -477,32 +644,29 @@ export class HuiCardPicker extends LitElement {
}
return html`
<div class="card" tabindex="0">
<div
class="overlay"
@click=${this._cardPicked}
.config=${cardConfig}
></div>
<div class="card-header">
${customCard ? customCard.name || customCard.type : name}
</div>
<div
class="preview ${classMap({
description: !element || element.tagName === "HUI-ERROR-CARD",
})}"
>
${
element && element.tagName !== "HUI-ERROR-CARD"
? element
: customCard
? customCard.description ||
this.hass!.localize(
`ui.panel.lovelace.editor.cardpicker.no_description`
)
: description
}
</div>
<ha-ripple></ha-ripple>
<div
class="overlay"
@click=${this._cardPicked}
.config=${cardConfig}
></div>
<div class="card-header">
${customCard ? customCard.name || customCard.type : name}
</div>
<div
class="preview ${classMap({
description: !element || element.tagName === "HUI-ERROR-CARD",
})}"
>
${
element && element.tagName !== "HUI-ERROR-CARD"
? element
: customCard
? customCard.description ||
this.hass!.localize(
`ui.panel.lovelace.editor.cardpicker.no_description`
)
: description
}
</div>
`;
}
@@ -531,6 +695,10 @@ export class HuiCardPicker extends LitElement {
}
.cards-container-header {
display: flex;
align-items: center;
gap: var(--ha-space-2);
min-width: 0;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
padding: var(--ha-space-3) var(--ha-space-2);
@@ -578,11 +746,55 @@ export class HuiCardPicker extends LitElement {
font-weight: var(--ha-font-weight-bold);
letter-spacing: -0.012em;
line-height: var(--ha-line-height-condensed);
padding: var(--ha-space-3) var(--ha-space-4);
padding: var(--ha-space-3) var(--ha-space-11);
display: block;
text-align: center;
}
.cards-container-header .title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reorder-favorites {
flex: none;
margin-inline-start: auto;
/* Keeps the hit area without growing the header row */
margin-block: calc(-1 * var(--ha-space-1));
--ha-icon-button-size: var(--ha-space-8);
--mdc-icon-size: var(--ha-space-5);
color: var(--secondary-text-color);
}
.favorite {
position: absolute;
top: var(--ha-space-1);
inset-inline-end: var(--ha-space-1);
z-index: 2;
color: var(--secondary-text-color);
opacity: 0;
transition: opacity var(--ha-animation-duration-fast) ease-in-out;
--ha-icon-button-size: var(--ha-space-10);
}
.card:hover .favorite,
.card:focus-within .favorite,
.favorite.selected {
opacity: 1;
}
.favorite.selected {
color: var(--primary-color);
}
@media (hover: none) {
.favorite {
opacity: 1;
}
}
.preview {
pointer-events: none;
margin: var(--ha-space-5);
@@ -602,6 +814,8 @@ export class HuiCardPicker extends LitElement {
}
.spinner {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
@@ -622,23 +836,6 @@ export class HuiCardPicker extends LitElement {
max-width: none;
grid-column: 1 / -1;
}
.icon {
position: absolute;
top: var(--ha-space-2);
right: var(--ha-space-2);
inset-inline-start: var(--ha-space-2);
inset-inline-end: var(--ha-space-2);
border-radius: var(--ha-border-radius-circle);
--mdc-icon-size: var(--ha-space-4);
line-height: var(--ha-space-4);
box-sizing: border-box;
color: var(--text-primary-color);
padding: var(--ha-space-1);
}
.icon.custom {
background: var(--warning-color);
}
`,
];
}
@@ -0,0 +1,136 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-sortable";
import "../../../../components/ha-svg-icon";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type {
FavoriteCardItem,
ReorderFavoriteCardsDialogParams,
} from "./show-reorder-favorite-cards-dialog";
@customElement("hui-dialog-reorder-favorite-cards")
export class HuiDialogReorderFavoriteCards extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: ReorderFavoriteCardsDialogParams;
@state() private _open = false;
@state() private _favorites: FavoriteCardItem[] = [];
public async showDialog(
params: ReorderFavoriteCardsDialogParams
): Promise<void> {
this._params = params;
this._favorites = params.favorites;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._open = false;
this._params = undefined;
this._favorites = [];
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.reorder_favorites"
)}
@closed=${this._dialogClosed}
>
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="favorites">
${repeat(
this._favorites,
(favorite) => favorite.key,
(favorite) => html`
<div class="favorite">
<div class="handle">
<ha-svg-icon
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="name">${favorite.name}</span>
</div>
`
)}
</div>
</ha-sortable>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const favorites = [...this._favorites];
const [moved] = favorites.splice(oldIndex, 1);
favorites.splice(newIndex, 0, moved);
this._favorites = favorites;
this._params!.saveFavorites(favorites.map((favorite) => favorite.key));
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.favorites {
display: flex;
flex-direction: column;
}
.favorite {
display: flex;
align-items: center;
gap: var(--ha-space-2);
min-height: var(--ha-space-12);
}
.handle {
display: flex;
align-items: center;
cursor: grab;
padding: var(--ha-space-2);
color: var(--secondary-text-color);
}
.name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-dialog-reorder-favorite-cards": HuiDialogReorderFavoriteCards;
}
}
@@ -0,0 +1,25 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface FavoriteCardItem {
key: string;
name: string;
}
export interface ReorderFavoriteCardsDialogParams {
favorites: FavoriteCardItem[];
saveFavorites: (favorites: string[]) => void;
}
export const importReorderFavoriteCardsDialog = () =>
import("./hui-dialog-reorder-favorite-cards");
export const showReorderFavoriteCardsDialog = (
element: HTMLElement,
reorderFavoriteCardsDialogParams: ReorderFavoriteCardsDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "hui-dialog-reorder-favorite-cards",
dialogImport: importReorderFavoriteCardsDialog,
dialogParams: reorderFavoriteCardsDialogParams,
});
};
+12 -7
View File
@@ -797,6 +797,13 @@
"target_details": "Target details",
"no_targets": "No targets",
"no_target_found": "No target found for {term}",
"selected": {
"entity": "Entities: {count}",
"device": "Devices: {count}",
"area": "Areas: {count}",
"label": "Labels: {count}",
"floor": "Floors: {count}"
},
"type": {
"area": "Area",
"areas": "Areas",
@@ -5177,13 +5184,6 @@
"area": "No areas or floors found for {term}",
"label": "No labels found for {term}"
},
"show_more_search": {
"trigger": "{count} more triggers...",
"condition": "{count} more conditions...",
"action": "{count} more actions...",
"entity": "{count} more entities...",
"device": "{count} more devices..."
},
"load_target_items_failed": "Failed to load target items for",
"other_areas": "Other areas",
"services": "Services",
@@ -10063,6 +10063,11 @@
"core_cards": "Core cards",
"energy_cards": "Energy cards",
"custom_cards": "Community cards",
"favorite_cards": "Favorite cards",
"add_favorite": "Add to favorites",
"remove_favorite": "Remove from favorites",
"reorder_favorites": "Reorder favorites",
"favorite_save_failed": "Failed to save favorite cards",
"round_temperature": "Round temperature",
"features": "Features",
"actions": "Actions",
+1 -53
View File
@@ -10,8 +10,6 @@
import { readFileSync } from "fs";
const REPORT_PATH = "test/e2e/reports/combined/results.json";
const COMMENT_MARKER = "<!-- playwright-e2e-report -->";
const COMMENT_HEADING = "## Playwright E2E tests failed";
// GitHub comment bodies cap at 65536 chars; leave headroom.
const MAX_BODY = 60000;
@@ -78,19 +76,6 @@ export default async function postReportComment({ github, context, core }) {
const { owner, repo } = context.repo;
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: context.issue.number,
per_page: 100,
});
const previousComments = comments.filter(
(comment) =>
comment.user?.login === "github-actions[bot]" &&
(comment.body?.includes(COMMENT_MARKER) ||
comment.body?.startsWith(COMMENT_HEADING))
);
let stats = { expected: 0, unexpected: 0, flaky: 0, skipped: 0 };
let failures = [];
@@ -112,8 +97,7 @@ export default async function postReportComment({ github, context, core }) {
: "_No failing tests were captured in the report._";
let body = [
COMMENT_MARKER,
COMMENT_HEADING,
"## Playwright E2E tests failed",
"",
summaryLine,
"",
@@ -134,40 +118,4 @@ export default async function postReportComment({ github, context, core }) {
issue_number: context.issue.number,
body,
});
const batches = Array.from(
{ length: Math.ceil(previousComments.length / 100) },
(_, index) => previousComments.slice(index * 100, (index + 1) * 100)
);
const commentsToMinimize = (
await Promise.all(
batches.map(async (batch) => {
const { nodes } = await github.graphql(
`query($ids: [ID!]!) {
nodes(ids: $ids) {
... on IssueComment {
id
isMinimized
}
}
}`,
{ ids: batch.map((comment) => comment.node_id) }
);
return nodes.filter((node) => !node.isMinimized);
})
)
).flat();
await Promise.all(
commentsToMinimize.map((comment) =>
github.graphql(
`mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) {
clientMutationId
}
}`,
{ id: comment.id }
)
)
);
}
@@ -46,6 +46,7 @@ const cumulativeOpts = (
getValue: (id) => values[id] ?? 0,
getLabel: (id, name) => name || id,
getEntityId: (id) => id,
findEffectiveParent: () => undefined,
...rest,
};
};
@@ -167,56 +168,6 @@ describe("buildSankeyDeviceNodes", () => {
expect(result.untrackedConsumption).toBe(0);
});
it("resolves the parent to its node id, not its stat_consumption", () => {
// Instantaneous cards key the hierarchy by stat_consumption but render
// stat_rate, so the effective parent must come back as the stat_rate.
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "p", stat_rate: "sensor.p" },
{
stat_consumption: "c",
stat_rate: "sensor.c",
included_in_stat: "p",
}
),
values: { "sensor.p": 100, "sensor.c": 40 },
minThreshold: 1,
initialUntracked: 100,
getId: (device) => device.stat_rate,
})
);
expect(result.parentLinks["sensor.c"]).toBe("sensor.p");
expect(result.links).toContainEqual({
source: "sensor.p",
target: "sensor.c",
});
expect(result.untrackedConsumption).toBe(0); // only the top-level parent
});
it("walks through an intermediate device that has no node id", () => {
// "middle" has no stat_rate, so the child must attach to the grandparent.
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devices(
{ stat_consumption: "p", stat_rate: "sensor.p" },
{ stat_consumption: "middle", included_in_stat: "p" },
{
stat_consumption: "c",
stat_rate: "sensor.c",
included_in_stat: "middle",
}
),
values: { "sensor.p": 100, "sensor.c": 40 },
minThreshold: 1,
initialUntracked: 100,
getId: (device) => device.stat_rate,
})
);
expect(result.parentLinks["sensor.c"]).toBe("sensor.p");
expect(result.untrackedConsumption).toBe(0);
});
it("adds a per-parent untracked residual above the floor", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({
@@ -226,6 +177,7 @@ describe("buildSankeyDeviceNodes", () => {
),
values: { parent: 10, child: 4 },
initialUntracked: 10,
findEffectiveParent: (includedInStat) => includedInStat,
})
);
expect(result.parentLinks.child).toBe("parent");
@@ -247,6 +199,7 @@ describe("buildSankeyDeviceNodes", () => {
values: { parent: 10, child: 9.5 },
untrackedFloor: 1,
initialUntracked: 10,
findEffectiveParent: (includedInStat) => includedInStat,
})
);
expect(result.deviceNodes.some((n) => n.id.startsWith("untracked_"))).toBe(
@@ -264,6 +217,7 @@ describe("buildSankeyDeviceNodes", () => {
),
values: { parent: 10, s1: 0.003, s2: 0.004 },
initialUntracked: 10,
findEffectiveParent: (includedInStat) => includedInStat,
})
);
const other = result.deviceNodes.find((n) => n.id === "other_parent");
@@ -353,62 +307,6 @@ describe("buildSankeyDeviceNodes", () => {
expect(result.untrackedConsumption).toBeCloseTo(10 - 0.008, 10);
});
it("excludes a nested small device from a rendered parent's cluster", () => {
// big(rendered) > b(small) > c(small): c is inside b's value, so only b
// may be attributed under big.
const devs = devices(
{ stat_consumption: "big" },
{ stat_consumption: "b", included_in_stat: "big" },
{ stat_consumption: "c", included_in_stat: "b" }
);
const values = { big: 10, b: 0.004, c: 0.003 };
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devs,
values,
initialUntracked: 10,
})
);
// b is the lone survivor, so it is shown directly instead of "Other"
expect(result.deviceNodes.map((n) => n.id)).toEqual([
"big",
"b",
"untracked_big",
]);
expect(result.parentLinks.b).toBe("big");
expect(
result.deviceNodes.find((n) => n.id === "untracked_big")?.value
).toBeCloseTo(10 - 0.004, 10);
expect(result.untrackedConsumption).toBe(0);
});
it("keeps a small device whose chain reaches a small ancestor past a rendered one", () => {
// g(small) > a(rendered) > c(small): c hangs off rendered a, so it never
// touches untracked and cannot be double-counted through g.
const devs = devices(
{ stat_consumption: "g" },
{ stat_consumption: "a", included_in_stat: "g" },
{ stat_consumption: "c", included_in_stat: "a" }
);
const values = { g: 0.0005, a: 0.5, c: 0.0004 };
const result = buildSankeyDeviceNodes(
cumulativeOpts({
devices: devs,
values,
initialUntracked: 1,
})
);
expect(result.deviceNodes.map((n) => n.id)).toEqual([
"a",
"g",
"c",
"untracked_a",
]);
expect(result.parentLinks.c).toBe("a");
// only the two top-level values (a and g) come off untracked
expect(result.untrackedConsumption).toBeCloseTo(1 - 0.5 - 0.0005, 10);
});
it("leaves a cyclic small-device cluster in untracked instead of looping", () => {
const result = buildSankeyDeviceNodes(
cumulativeOpts({