mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-28 00:33:55 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fbc8ae758 | |||
| e18cc7ec3a |
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface CoreFrontendUserData {
|
||||
showEntityIdPicker?: boolean;
|
||||
default_panel?: string;
|
||||
apps_info_dismissed?: boolean;
|
||||
dashboard_favorite_card_types?: string[];
|
||||
}
|
||||
|
||||
export interface SidebarFrontendUserData {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user