Compare commits

..
Author SHA1 Message Date
Przemysław Kłys 6a9fca2cb9 Fix logbook labels for restored button states (#54203)
* Fix logbook labels for unavailable timestamp entities

* Fix restored button context classification
2026-09-17 06:40:09 +00:00
Petar Petrov 55ba59074e Fix energy distribution home ring overflowing to 100% grid (#54191)
* Fix energy distribution home ring overflowing to 100% grid

Size the home circle from allocated used_solar, used_battery, and
used_grid instead of net used_total so misaligned hours cannot produce
a negative grid arc.

* Add gallery demo for energy distribution home ring

Mock the issue 54185 daily totals with realistic hourly PV, battery,
and grid statistics, including an export-lagged card that overflows
the old ring math.

* Remove gallery energy distribution demo from this PR

Keep the home-ring fix and its unit tests; drop the gallery page
that was only used for local browser verification.

* Keep a zero-consumption home ring as a plain border

When solar is configured but allocated flows are all zero, leave the
arcs unset so the card can show the home outline instead of zero-length
dashes on a borderless circle.

* Keep a defined zero low-carbon home arc

The energy distribution card mounts the home SVG when solar or
lowCarbon is defined. Omitting a 0 leftover hid grid and battery
arcs on a no-solar 100% high-carbon ring.
2026-09-16 15:49:29 +02:00
Maarten Lakerveld eeb674dd5a Show the selected light control on light group members (#54167)
* Show the selected light control on light group members

When a light group's more-info dialog is switched to color temperature, the
member tiles listed below now show a color temperature slider instead of
brightness, so the children can be adjusted on the same axis as the group.

The light control fires light-main-control-changed whenever its main control
changes, and more-info-content picks the member tile feature from that.
Members that do not support color temperature keep their brightness slider,
and the color mode keeps brightness since there is no tile feature for it.
Legacy group.* entities are unchanged.

* Hide member light control when group is in RGB color mode

There is no RGB tile feature, so showing a brightness slider on group
members while the group control is set to color is inconsistent. Show no
control in that case instead, per review feedback.
2026-09-16 15:44:11 +02:00
Jan-Philipp Benecke afe6e84e3c Make trace pages resizable two-pane layouts (#54216)
* Make trace pages resizable two-pane layouts

* Remove cast

* Remove cast
2026-09-16 16:06:56 +03:00
Bruno Pantaleão Gonçalves 3ddbc8f4e3 Communicate entity control to external bus (#54215)
* Communicate entity control to external bus

* Prefer target entity ids and report disconnecting calls

* Split and lowercase entity ids like Core does

* Keep entity control reporting best effort

* Assert wildcard entity ids are dropped
2026-09-16 15:25:32 +03:00
Jan-Philipp Benecke 216468cf50 Strip unreferenced duplicate trigger IDs (#54214) 2026-09-16 13:52:04 +02:00
51 changed files with 2052 additions and 1692 deletions
+17 -3
View File
@@ -5,6 +5,7 @@ import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { UNAVAILABLE, UNKNOWN } from "./entity/entity";
import { isNumericEntity } from "./history";
const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages";
@@ -280,17 +281,22 @@ const STATE_ACTION_MESSAGES: Record<
radio_frequency: "command_sent",
};
const RESTORABLE_BUTTON_DOMAINS = new Set(["button", "input_button"]);
export const localizeStateMessage = (
hass: HomeAssistant,
state: string,
stateObj: HassEntity,
domain: string,
attributes?: LogbookEntry["attributes"]
entry?: LogbookEntry
): string => {
if (state === UNKNOWN || state === UNAVAILABLE) {
return hass.formatEntityState(stateObj, state);
}
// Events show the triggered event type, falling back to a generic label when
// the type is unknown (the timestamp state is meaningless on its own).
if (domain === "event") {
const eventType = attributes?.event_type;
const eventType = entry?.attributes?.event_type;
if (eventType != null) {
return hass.formatEntityAttributeValue(stateObj, "event_type", eventType);
}
@@ -298,7 +304,15 @@ export const localizeStateMessage = (
}
const actionKey: LogbookActionMessage | undefined =
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
if (actionKey) {
const stateTimestamp = Date.parse(state);
const matchesEntryTime =
entry === undefined ||
(Number.isFinite(stateTimestamp) &&
Math.abs(stateTimestamp - entry.when * 1000) < 1000);
if (
actionKey &&
(!RESTORABLE_BUTTON_DOMAINS.has(domain) || matchesEntryTime)
) {
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.${actionKey}`);
}
// Every other domain reuses the backend state translation, so the logbook
+2 -4
View File
@@ -3,7 +3,6 @@ import { getCollection } from "home-assistant-js-websocket";
import type { HuiBadge } from "../panels/lovelace/badges/hui-badge";
import type { HuiCard } from "../panels/lovelace/cards/hui-card";
import type { HuiSection } from "../panels/lovelace/sections/hui-section";
import type { LovelacePath } from "../panels/lovelace/editor/lovelace-path";
import type { Lovelace } from "../panels/lovelace/types";
import type { HomeAssistant } from "../types";
import type { LovelaceSectionConfig } from "./lovelace/config/section";
@@ -19,9 +18,7 @@ export interface LovelaceViewElement extends HTMLElement {
hass?: HomeAssistant;
lovelace?: Lovelace;
narrow?: boolean;
// Temporary compatibility: custom view layouts still read the view index
index?: number;
path?: LovelacePath;
cards?: HuiCard[];
badges?: HuiBadge[];
sections?: HuiSection[];
@@ -34,7 +31,8 @@ export interface LovelaceSectionElement extends HTMLElement {
hass?: HomeAssistant;
lovelace?: Lovelace;
preview?: boolean;
path?: LovelacePath;
viewIndex?: number;
index?: number;
cards?: HuiCard[];
isStrategy: boolean;
importOnly?: boolean;
+19 -1
View File
@@ -1,4 +1,6 @@
import type { Context, HomeAssistant } from "../types";
import { ensureArray } from "../common/array/ensure-array";
import { isValidEntityId } from "../common/entity/valid_entity_id";
import type { Context, HomeAssistant, ServiceCallRequest } from "../types";
import type { Action } from "./script";
export const callExecuteScript = (
@@ -22,3 +24,19 @@ export const serviceCallWillDisconnect = (
"update.home_assistant_core_update",
"update.home_assistant_operating_system_update",
].includes(serviceData?.entity_id));
// Core merges the target into the service data, so a target entity_id
// replaces the legacy service data one rather than adding to it. Its schema
// also accepts comma separated ids and lowercases them.
export const getServiceCallEntityIds = (
serviceData?: ServiceCallRequest["serviceData"],
target?: ServiceCallRequest["target"]
): string[] => [
...new Set(
(ensureArray(target?.entity_id ?? serviceData?.entity_id) ?? [])
.filter((id): id is string => typeof id === "string")
.flatMap((id) => id.split(","))
.map((id) => id.trim().toLowerCase())
.filter(isValidEntityId)
),
];
@@ -11,6 +11,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-attribute-icon";
@@ -85,12 +86,26 @@ class MoreInfoLight extends LitElement {
private _setMainControl(ev: any) {
ev.stopPropagation();
this._mainControl = ev.currentTarget.control;
this._changeMainControl(ev.currentTarget.control);
}
private _resetMainControl(ev: any) {
ev.stopPropagation();
this._mainControl = "brightness";
this._changeMainControl("brightness");
}
public connectedCallback(): void {
super.connectedCallback();
// A container that outlives this control (e.g. more-info-content when the
// dialog moves between entities) resyncs with the default control.
fireEvent(this, "light-main-control-changed", {
control: this._mainControl,
});
}
private _changeMainControl(control: MainControl) {
this._mainControl = control;
fireEvent(this, "light-main-control-changed", { control });
}
private get _stateOverride() {
@@ -399,4 +414,14 @@ declare global {
interface HTMLElementTagNameMap {
"more-info-light": MoreInfoLight;
}
interface HASSDomEvents {
"light-main-control-changed": { control: MainControl };
}
interface HTMLElementEventMap {
"light-main-control-changed": HASSDomEvent<
HASSDomEvents["light-main-control-changed"]
>;
}
}
+115 -84
View File
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { computeEntityName } from "../../common/entity/compute_entity_name";
@@ -11,6 +12,7 @@ import "../../components/ha-badge";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import { supportsCoverPositionCardFeature } from "../../panels/lovelace/card-features/hui-cover-position-card-feature";
import { supportsLightBrightnessCardFeature } from "../../panels/lovelace/card-features/hui-light-brightness-card-feature";
import { supportsLightColorTempCardFeature } from "../../panels/lovelace/card-features/hui-light-color-temp-card-feature";
import type { LovelaceCardFeatureConfig } from "../../panels/lovelace/card-features/types";
import type { TileCardConfig } from "../../panels/lovelace/cards/types";
import { importMoreInfoControl } from "../../panels/lovelace/custom-card-helpers";
@@ -25,6 +27,8 @@ interface EntityInfo {
deviceId: string | undefined;
}
type LightMainControl = HASSDomEvents["light-main-control-changed"]["control"];
@customElement("more-info-content")
class MoreInfoContent extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -37,6 +41,17 @@ class MoreInfoContent extends LitElement {
@property({ attribute: false }) public data?: Record<string, any>;
// Mirrors the mode selected in the light control so group members
// show the matching slider.
@state() private _lightMainControl: LightMainControl = "brightness";
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("light-main-control-changed", (ev) => {
this._lightMainControl = ev.detail.control;
});
}
protected render() {
let moreInfoType: string | undefined;
@@ -73,7 +88,10 @@ class MoreInfoContent extends LitElement {
? html`
<hui-section
.hass=${this.hass}
.config=${this._entitiesSectionConfig(memberIds)}
.config=${this._entitiesSectionConfig(
memberIds,
this._lightMainControl
)}
>
</hui-section>
`
@@ -104,97 +122,110 @@ class MoreInfoContent extends LitElement {
}
);
private _entitiesSectionConfig = memoizeOne((entityIds: string[]) => {
const hass = this.hass!;
private _entitiesSectionConfig = memoizeOne(
(entityIds: string[], lightMainControl: LightMainControl) => {
const hass = this.hass!;
// Get entity names and areas for all visible entities
const entityInfos = entityIds
.map<EntityInfo | null>((entityId) => {
const entry = hass.entities[entityId];
if (entry?.hidden) {
return null;
}
const stateObj = hass.states[entityId];
if (!stateObj) {
return null;
}
const entityName = computeEntityName(
stateObj,
hass.entities,
hass.devices
);
const { area, device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const areaId = area?.area_id;
const deviceId = device?.id;
return { entityId, entityName, areaId, deviceId };
})
.filter(Boolean) as EntityInfo[];
// Get entity names and areas for all visible entities
const entityInfos = entityIds
.map<EntityInfo | null>((entityId) => {
const entry = hass.entities[entityId];
if (entry?.hidden) {
return null;
}
const stateObj = hass.states[entityId];
if (!stateObj) {
return null;
}
const entityName = computeEntityName(
stateObj,
hass.entities,
hass.devices
);
const { area, device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const areaId = area?.area_id;
const deviceId = device?.id;
return { entityId, entityName, areaId, deviceId };
})
.filter(Boolean) as EntityInfo[];
// Check if all entities have the same entity name
const entityNames = new Set(entityInfos.map((info) => info.entityName));
const allSameEntityName = entityNames.size === 1;
// Check if all entities have the same entity name
const entityNames = new Set(entityInfos.map((info) => info.entityName));
const allSameEntityName = entityNames.size === 1;
// Check if all entities have the same area
const areaIds = new Set(entityInfos.map((info) => info.areaId));
const allSameArea = areaIds.size === 1;
// Check if all entities have the same area
const areaIds = new Set(entityInfos.map((info) => info.areaId));
const allSameArea = areaIds.size === 1;
// Check if all entities belong to the same device
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
const allSameDevice = deviceIds.size === 1;
// Check if all entities belong to the same device
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
const allSameDevice = deviceIds.size === 1;
// Build name and state content config based on conditions. The device name
// is redundant when every member belongs to the same device, so omit it
// (and fall back to the entity name so the tile still has a label).
const name: EntityNameItem[] = [];
// Build name and state content config based on conditions. The device name
// is redundant when every member belongs to the same device, so omit it
// (and fall back to the entity name so the tile still has a label).
const name: EntityNameItem[] = [];
if (!allSameDevice) {
name.push({ type: "device" });
}
if (!allSameEntityName || allSameDevice) {
name.push({ type: "entity" });
}
const stateContent = ["state"];
if (!allSameArea) {
stateContent.push("area_name");
}
const cards = entityInfos.map(({ entityId }) => {
const features: LovelaceCardFeatureConfig[] = [];
const context = { entity_id: entityId };
if (supportsCoverPositionCardFeature(hass, context)) {
features.push({
type: "cover-position",
});
} else if (supportsLightBrightnessCardFeature(hass, context)) {
features.push({
type: "light-brightness",
});
if (!allSameDevice) {
name.push({ type: "device" });
}
return {
type: "tile",
entity: entityId,
name,
state_content: stateContent,
features_position: "inline",
features,
grid_options: { columns: 12 },
} as TileCardConfig;
});
if (!allSameEntityName || allSameDevice) {
name.push({ type: "entity" });
}
return {
type: "grid",
cards,
};
});
const stateContent = ["state"];
if (!allSameArea) {
stateContent.push("area_name");
}
const cards = entityInfos.map(({ entityId }) => {
const features: LovelaceCardFeatureConfig[] = [];
const context = { entity_id: entityId };
if (supportsCoverPositionCardFeature(hass, context)) {
features.push({
type: "cover-position",
});
} else if (lightMainControl === "color") {
// There is no RGB tile feature yet, so when the group is set to
// color the members show no control rather than a mismatched
// brightness slider.
} else if (
lightMainControl === "color_temp" &&
supportsLightColorTempCardFeature(hass, context)
) {
features.push({
type: "light-color-temp",
});
} else if (supportsLightBrightnessCardFeature(hass, context)) {
features.push({
type: "light-brightness",
});
}
return {
type: "tile",
entity: entityId,
name,
state_content: stateContent,
features_position: "inline",
features,
grid_options: { columns: 12 },
} as TileCardConfig;
});
return {
type: "grid",
cards,
};
}
);
static styles = css`
hui-section {
+10
View File
@@ -184,6 +184,15 @@ interface EMOutgoingMessageAddEntityTo extends EMMessage {
};
}
interface EMOutgoingMessageEntityControlled extends EMMessage {
type: "entity/controlled";
payload: {
entity_ids: string[];
domain: string;
service: string;
};
}
interface EMOutgoingMessageMoreInfoOpened extends EMMessage {
type: "more_info/opened";
payload: {
@@ -239,6 +248,7 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo
| EMOutgoingMessageEntityControlled
| EMOutgoingMessageFocusElement
| EMOutgoingMessageReloadAndClearCache
| EMOutgoingMessageAssistSettings;
@@ -13,9 +13,13 @@ import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -49,9 +53,14 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { fileDownload } from "../../../util/file_download";
import "../../../components/ha-trace-picker";
import "../../../components/ha-split-panel";
import type { HaSplitPanel } from "../../../components/ha-split-panel";
const TABS = ["details", "timeline", "logbook", "automation_config"] as const;
const STORAGE_KEY_SPLIT_POSITION = "automation-trace-split-position";
const DEFAULT_SPLIT_POSITION = 20;
@customElement("ha-automation-trace")
export class HaAutomationTrace extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -84,6 +93,8 @@ export class HaAutomationTrace extends LitElement {
@state() private _view: (typeof TABS)[number] | "blueprint" = "details";
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
@query("hat-script-graph") private _graph?: HatScriptGraph;
protected render(): TemplateResult {
@@ -91,10 +102,6 @@ export class HaAutomationTrace extends LitElement {
? this.hass.states[this._entityId]
: undefined;
const graph = this._graph;
const trackedNodes = graph?.trackedNodes;
const renderedNodes = graph?.renderedNodes;
const title = stateObj?.attributes.friendly_name || this._entityId;
let devButtons: TemplateResult | string = "";
@@ -240,94 +247,19 @@ export class HaAutomationTrace extends LitElement {
? ""
: html`
<div class="main">
<div class="graph">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
${
this.narrow
? this._renderPanes()
: html`
<ha-split-panel
class="split"
.position=${this._splitPosition}
@wa-reposition=${this._splitRepositioned}
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.${view}`
)}
</ha-tab-group-tab>
${this._renderPanes()}
</ha-split-panel>
`
)}
${
this._trace.blueprint_inputs
? html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === "blueprint"}
panel="blueprint"
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.blueprint_config`
)}
</ha-tab-group-tab>
`
: ""
}
</ha-tab-group>
${
this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? nothing
: this._view === "details"
? html`
<ha-trace-path-details
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
.renderedNodes=${renderedNodes!}
></ha-trace-path-details>
`
: this._view === "automation_config"
? html`
<ha-trace-config
.trace=${this._trace}
></ha-trace-config>
`
: this._view === "logbook"
? html`
<ha-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
></ha-trace-logbook>
`
: this._view === "blueprint"
? html`
<ha-trace-blueprint-config
.trace=${this._trace}
></ha-trace-blueprint-config>
`
: html`
<ha-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-trace-timeline>
`
}
</div>
}
</div>
`
}
@@ -335,12 +267,115 @@ export class HaAutomationTrace extends LitElement {
`;
}
private _renderPanes(): TemplateResult {
const graph = this._graph;
const trackedNodes = graph?.trackedNodes;
const renderedNodes = graph?.renderedNodes;
return html`
<div class="graph" slot="start">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info" slot="end">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.${view}`
)}
</ha-tab-group-tab>
`
)}
${
this._trace!.blueprint_inputs
? html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === "blueprint"}
panel="blueprint"
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.blueprint_config`
)}
</ha-tab-group-tab>
`
: ""
}
</ha-tab-group>
${
this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? nothing
: this._view === "details"
? html`
<ha-trace-path-details
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
.renderedNodes=${renderedNodes!}
></ha-trace-path-details>
`
: this._view === "automation_config"
? html`
<ha-trace-config .trace=${this._trace}></ha-trace-config>
`
: this._view === "logbook"
? html`
<ha-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
></ha-trace-logbook>
`
: this._view === "blueprint"
? html`
<ha-trace-blueprint-config
.trace=${this._trace}
></ha-trace-blueprint-config>
`
: html`
<ha-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-trace-timeline>
`
}
</div>
`;
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("triggers");
this.hass.loadBackendTranslation("conditions");
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
if (storedPosition) {
const parsed = parseFloat(storedPosition);
if (!isNaN(parsed) && parsed > 0 && parsed < 100) {
this._splitPosition = parsed;
}
}
if (!this.automationId) {
return;
}
@@ -428,6 +463,19 @@ export class HaAutomationTrace extends LitElement {
this._selected = ev.detail;
}
private _splitRepositioned(ev: HASSDomTargetEvent<HaSplitPanel>) {
this._splitPosition = ev.target.position;
this._storeSplitPosition();
}
private _storeSplitPosition = debounce(
() => {
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
},
500,
false
);
private _refreshTraces() {
this._loadTraces();
}
@@ -618,13 +666,22 @@ export class HaAutomationTrace extends LitElement {
padding: 16px;
}
ha-split-panel.split {
flex: 1;
min-height: 0;
min-width: 0;
--ha-split-panel-min: 10%;
--ha-split-panel-max: 80%;
--ha-split-panel-divider-hit-area: var(--ha-space-4);
}
.graph {
border-right: 1px solid var(--divider-color);
max-width: 50%;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0;
}
hat-script-graph {
flex: 1;
@@ -642,6 +699,8 @@ export class HaAutomationTrace extends LitElement {
}
.info {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
background-color: var(--card-background-color);
}
@@ -454,9 +454,11 @@ export const cleanupRemovedGeneratedTriggerReferences = (
};
/**
* Assign a fresh generated ID to every leaf sharing a stored ID, including manual IDs.
* Assign a fresh generated ID to every referenced leaf sharing a stored ID.
* Expand trigger-condition references to all replacements for the old ID, preserving
* their original "any of these triggers" meaning. Return the original config if IDs are unique.
* their original "any of these triggers" meaning. Duplicate IDs that no trigger
* condition references are stripped instead, so the fix does not create generated
* IDs nobody uses. Return the original config if IDs are unique.
* Templates and action data that inspect trigger.id directly are not rewritten;
* the controller's confirmation dialog warns about that limitation.
*/
@@ -470,6 +472,21 @@ export const makeDuplicateTriggerIdsUnique = (
return config;
}
const referencedIds = new Set<string>();
new AutomationTriggerConditionMapper((condition) =>
mapReferencedTriggerIds(condition, (id) => {
referencedIds.add(id);
return id;
})
).map(config);
const referencedDuplicates = new Set(
[...duplicates].filter((id) => referencedIds.has(id))
);
const unreferencedDuplicates = new Set(
[...duplicates].filter((id) => !referencedIds.has(id))
);
const reservedIds = new Set(ids.filter((id) => !duplicates.has(id)));
const generatedIds = new Set<string>();
const assignments = new Map<Trigger, string>();
@@ -479,7 +496,7 @@ export const makeDuplicateTriggerIdsUnique = (
// trigger its own ID, references to the old ID must expand to every new ID.
flattenTriggers(config.triggers).forEach((trigger) => {
const id = getTriggerId(trigger);
if (!id || !duplicates.has(id)) {
if (!id || !referencedDuplicates.has(id)) {
return;
}
const generatedId = getGeneratedTriggerId(reservedIds, generatedIds);
@@ -487,14 +504,32 @@ export const makeDuplicateTriggerIdsUnique = (
replacementIds.set(id, [...(replacementIds.get(id) || []), generatedId]);
});
const triggers = walkLeafTriggers(config.triggers, (trigger) => {
if (assignments.has(trigger)) {
return { ...trigger, id: assignments.get(trigger) };
}
if (isTriggerList(trigger)) {
return trigger;
}
const id = getTriggerId(trigger);
if (id && unreferencedDuplicates.has(id)) {
const { id: _id, ...rest } = trigger;
return rest as Trigger;
}
return trigger;
}) as Trigger | Trigger[];
if (!referencedDuplicates.size) {
return {
...config,
triggers,
};
}
return {
...new AutomationTriggerConditionMapper((condition) =>
mapReferencedTriggerIds(condition, (id) => replacementIds.get(id) ?? id)
).map(config),
triggers: walkLeafTriggers(config.triggers, (trigger) =>
assignments.has(trigger)
? { ...trigger, id: assignments.get(trigger) }
: trigger
) as Trigger | Trigger[],
triggers,
};
};
+154 -95
View File
@@ -13,8 +13,12 @@ import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -45,9 +49,14 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { fileDownload } from "../../../util/file_download";
import "../../../components/ha-trace-picker";
import "../../../components/ha-split-panel";
import type { HaSplitPanel } from "../../../components/ha-split-panel";
const TABS = ["details", "timeline", "logbook", "config"] as const;
const STORAGE_KEY_SPLIT_POSITION = "script-trace-split-position";
const DEFAULT_SPLIT_POSITION = 20;
@customElement("ha-script-trace")
export class HaScriptTrace extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -80,6 +89,8 @@ export class HaScriptTrace extends LitElement {
@state() private _view: (typeof TABS)[number] | "blueprint" = "details";
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
@query("hat-script-graph") private _graph?: HatScriptGraph;
protected render(): TemplateResult {
@@ -87,10 +98,6 @@ export class HaScriptTrace extends LitElement {
? this.hass.states[this._entityId]
: undefined;
const graph = this._graph;
const trackedNodes = graph?.trackedNodes;
const renderedNodes = graph?.renderedNodes;
const title = stateObj?.attributes.friendly_name || this._entityId;
let devButtons: TemplateResult | string = "";
@@ -220,96 +227,19 @@ export class HaScriptTrace extends LitElement {
? ""
: html`
<div class="main">
<div class="graph">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
${
this.narrow
? this._renderPanes()
: html`
<ha-split-panel
class="split"
.position=${this._splitPosition}
@wa-reposition=${this._splitRepositioned}
>
${this.hass.localize(
`ui.panel.config.automation.trace.tabs.${
view === "config" ? "script_config" : view
}`
)}
</ha-tab-group-tab>
${this._renderPanes()}
</ha-split-panel>
`
)}
${
this._trace.blueprint_inputs
? html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === "blueprint"}
panel="blueprint"
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.blueprint_config`
)}
</ha-tab-group-tab>
`
: ""
}
</ha-tab-group>
${
this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? ""
: this._view === "details"
? html`
<ha-trace-path-details
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
.renderedNodes=${renderedNodes!}
></ha-trace-path-details>
`
: this._view === "config"
? html`
<ha-trace-config
.trace=${this._trace}
></ha-trace-config>
`
: this._view === "logbook"
? html`
<ha-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
></ha-trace-logbook>
`
: this._view === "blueprint"
? html`
<ha-trace-blueprint-config
.trace=${this._trace}
></ha-trace-blueprint-config>
`
: html`
<ha-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-trace-timeline>
`
}
</div>
}
</div>
`
}
@@ -317,9 +247,114 @@ export class HaScriptTrace extends LitElement {
`;
}
private _renderPanes(): TemplateResult {
const graph = this._graph;
const trackedNodes = graph?.trackedNodes;
const renderedNodes = graph?.renderedNodes;
return html`
<div class="graph" slot="start">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info" slot="end">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
>
${this.hass.localize(
`ui.panel.config.automation.trace.tabs.${
view === "config" ? "script_config" : view
}`
)}
</ha-tab-group-tab>
`
)}
${
this._trace!.blueprint_inputs
? html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === "blueprint"}
panel="blueprint"
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.blueprint_config`
)}
</ha-tab-group-tab>
`
: ""
}
</ha-tab-group>
${
this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? ""
: this._view === "details"
? html`
<ha-trace-path-details
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
.renderedNodes=${renderedNodes!}
></ha-trace-path-details>
`
: this._view === "config"
? html`
<ha-trace-config .trace=${this._trace}></ha-trace-config>
`
: this._view === "logbook"
? html`
<ha-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
></ha-trace-logbook>
`
: this._view === "blueprint"
? html`
<ha-trace-blueprint-config
.trace=${this._trace}
></ha-trace-blueprint-config>
`
: html`
<ha-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-trace-timeline>
`
}
</div>
`;
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
if (storedPosition) {
const parsed = parseFloat(storedPosition);
if (!isNaN(parsed) && parsed > 0 && parsed < 100) {
this._splitPosition = parsed;
}
}
if (!this.scriptId) {
return;
}
@@ -409,6 +444,19 @@ export class HaScriptTrace extends LitElement {
this._selected = ev.detail;
}
private _splitRepositioned(ev: HASSDomTargetEvent<HaSplitPanel>) {
this._splitPosition = ev.target.position;
this._storeSplitPosition();
}
private _storeSplitPosition = debounce(
() => {
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
},
500,
false
);
private _refreshTraces() {
this._loadTraces();
}
@@ -594,13 +642,22 @@ export class HaScriptTrace extends LitElement {
padding: 16px;
}
ha-split-panel.split {
flex: 1;
min-height: 0;
min-width: 0;
--ha-split-panel-min: 10%;
--ha-split-panel-max: 80%;
--ha-split-panel-divider-hit-area: var(--ha-space-4);
}
.graph {
border-right: 1px solid var(--divider-color);
max-width: 50%;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0;
}
hat-script-graph {
flex: 1;
@@ -618,6 +675,8 @@ export class HaScriptTrace extends LitElement {
}
.info {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
background-color: var(--card-background-color);
}
+1 -7
View File
@@ -320,13 +320,7 @@ const computeLogbookValue = (
if (item.entity_id && item.state) {
return {
text: stateObj
? localizeStateMessage(
hass,
item.state,
stateObj,
domain!,
item.attributes
)
? localizeStateMessage(hass, item.state, stateObj, domain!, item)
: item.state,
type: "state",
};
+36 -34
View File
@@ -6,15 +6,14 @@ import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-ripple";
import "../../../components/ha-sortable";
import type { HaSortableOptions } from "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import "../components/hui-badge-edit-mode";
import type { LovelacePath } from "../editor/lovelace-path";
import { moveAtPath } from "../editor/lovelace-path";
import { moveBadge } from "../editor/config-util";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
import type { HuiBadge } from "./hui-badge";
@@ -31,7 +30,7 @@ export class HuiViewBadges extends LitElement {
@property({ attribute: false }) public badges: HuiBadge[] = [];
@property({ attribute: false }) public path!: LovelacePath;
@property({ attribute: false }) public viewIndex!: number;
@property({ type: Boolean, attribute: "show-add-label" })
public showAddLabel!: boolean;
@@ -83,29 +82,27 @@ export class HuiViewBadges extends LitElement {
return this._badgeConfigKeys.get(badge)!;
}
private _badgeMoved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>) {
private _badgeMoved(ev) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newConfig = moveAtPath(
this.lovelace.config,
[...this.path, oldIndex],
[...this.path, newIndex]
const newConfig = moveBadge(
this.lovelace!.config,
[this.viewIndex!, oldIndex],
[this.viewIndex!, newIndex]
);
this.lovelace.saveConfig(newConfig);
this.lovelace!.saveConfig(newConfig);
}
private _badgeAdded(ev: HASSDomEvent<HASSDomEvents["item-added"]>) {
private _badgeAdded(ev) {
ev.stopPropagation();
const { index, data } = ev.detail;
const oldPath = data as LovelacePath;
const newConfig = moveAtPath(this.lovelace.config, oldPath, [
...this.path,
index,
]);
this.lovelace.saveConfig(newConfig);
const oldPath = data as LovelaceCardPath;
const newPath = [this.viewIndex!, index] as LovelaceCardPath;
const newConfig = moveBadge(this.lovelace!.config, oldPath, newPath);
this.lovelace!.saveConfig(newConfig);
}
private _badgeRemoved(ev: HASSDomEvent<HASSDomEvents["item-removed"]>) {
private _badgeRemoved(ev) {
ev.stopPropagation();
// Do nothing, it's handled by the "item-added" event from the new parent.
}
@@ -119,7 +116,7 @@ export class HuiViewBadges extends LitElement {
}
private _addBadge() {
fireEvent(this, "ll-create-badge", { path: this.path });
fireEvent(this, "ll-create-badge");
}
render() {
@@ -142,6 +139,7 @@ export class HuiViewBadges extends LitElement {
@drag-end=${this._dragEnd}
group="badge"
draggable-selector="[data-sortable]"
.rollback=${false}
.options=${BADGE_SORTABLE_OPTIONS}
invert-swap
>
@@ -150,20 +148,26 @@ export class HuiViewBadges extends LitElement {
badges,
(badge) => this._getBadgeKey(badge),
(badge, idx) => {
if (!editMode) {
return badge;
}
const badgePath = [...this.path, idx];
const badgePath = [
this.viewIndex,
idx,
] as LovelaceCardPath;
return html`
<hui-badge-edit-mode
data-sortable
.lovelace=${this.lovelace}
.path=${badgePath}
.hiddenOverlay=${this._dragging}
.sortableData=${badgePath}
>
${badge}
</hui-badge-edit-mode>
${
editMode
? html`
<hui-badge-edit-mode
data-sortable
.lovelace=${this.lovelace}
.path=${badgePath}
.hiddenOverlay=${this._dragging}
.sortableData=${badgePath}
>
${badge}
</hui-badge-edit-mode>
`
: badge
}
`;
}
)}
@@ -234,7 +238,6 @@ export class HuiViewBadges extends LitElement {
margin-right: -8px;
margin-inline-end: -8px;
margin-inline-start: 0;
order: 2;
}
.badges > * {
@@ -249,7 +252,6 @@ export class HuiViewBadges extends LitElement {
}
.add {
order: 1;
position: relative;
display: flex;
flex-direction: row;
@@ -0,0 +1,73 @@
/**
* Home-circle stroke lengths for the energy distribution card.
*
* Hourly allocation can produce used_solar + used_battery + used_grid larger
* than net used_total when some hours export more than they produce. Dividing
* by net used_total then overflows the circle and the leftover grid arc goes
* negative, which browsers paint as a solid grid ring.
*
* These arcs use the sum of the allocated home flows as the denominator so
* they always fit the circumference.
*/
export const ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE = 238.76104;
export interface EnergyDistributionHomeCircleArcs {
solar?: number;
battery?: number;
lowCarbon?: number;
highCarbon?: number;
grid?: number;
}
export const computeEnergyDistributionHomeCircleArcs = ({
usedSolar = 0,
usedBattery = 0,
usedGrid = 0,
hasSolar,
hasGrid,
highCarbonConsumption,
circumference = ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE,
}: {
usedSolar?: number;
usedBattery?: number;
usedGrid?: number;
hasSolar: boolean;
hasGrid: boolean;
highCarbonConsumption?: number;
circumference?: number;
}): EnergyDistributionHomeCircleArcs => {
const solar = Math.max(usedSolar, 0);
const battery = Math.max(usedBattery, 0);
const grid = Math.max(usedGrid, 0);
const ringTotal = solar + battery + grid;
const arcs: EnergyDistributionHomeCircleArcs = {};
// Leave arcs unset so the card can fall back to the plain home border
// instead of painting zero-length dashes over a borderless circle.
if (ringTotal <= 0) {
return arcs;
}
const share = (value: number): number => circumference * (value / ringTotal);
if (hasSolar) {
arcs.solar = share(solar);
}
if (battery > 0) {
arcs.battery = share(battery);
}
if (hasGrid) {
if (highCarbonConsumption !== undefined) {
const highCarbon = Math.min(Math.max(highCarbonConsumption, 0), grid);
arcs.highCarbon = share(highCarbon);
// Keep 0 defined: the card mounts the home SVG when solar or
// lowCarbon is defined, not when the low-carbon stroke is painted.
arcs.lowCarbon = share(grid - highCarbon);
arcs.grid = arcs.highCarbon;
} else {
arcs.grid = share(grid);
}
}
return arcs;
};
@@ -37,8 +37,10 @@ import type { LovelaceCard } from "../../types";
import type { EnergyDistributionCardConfig } from "../types";
import { formatNumber } from "../../../../common/number/format_number";
import { round } from "../../../../common/number/round";
const CIRCLE_CIRCUMFERENCE = 238.76104;
import {
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE_CIRCUMFERENCE,
computeEnergyDistributionHomeCircleArcs,
} from "./energy-distribution-home-circle";
// Flows are differences of sums; anything that rounds to 0 Wh is noise
const hasFlow = (value: number | null): value is number =>
@@ -321,22 +323,8 @@ class HuiEnergyDistrubutionCard
const totalHomeConsumption = Math.max(0, consumption.total.used_total);
let homeSolarCircumference: number | undefined;
if (hasSolarProduction) {
homeSolarCircumference =
CIRCLE_CIRCUMFERENCE * (solarConsumption! / totalHomeConsumption);
}
let homeBatteryCircumference: number | undefined;
if (batteryConsumption) {
homeBatteryCircumference =
CIRCLE_CIRCUMFERENCE * (batteryConsumption / totalHomeConsumption);
}
let lowCarbonEnergy: number | undefined;
let homeLowCarbonCircumference: number | undefined;
let homeHighCarbonCircumference: number | undefined;
let highCarbonConsumption: number | undefined;
// This fallback is used in the demo
let electricityMapUrl = "https://app.electricitymaps.com";
@@ -360,7 +348,6 @@ class HuiEnergyDistrubutionCard
if (highCarbonEnergy !== null) {
lowCarbonEnergy = totalFromGrid - highCarbonEnergy;
let highCarbonConsumption: number;
if (gridConsumption !== totalFromGrid) {
// Only get the part that was used for consumption and not the battery
highCarbonConsumption =
@@ -368,18 +355,23 @@ class HuiEnergyDistrubutionCard
} else {
highCarbonConsumption = highCarbonEnergy;
}
homeHighCarbonCircumference =
CIRCLE_CIRCUMFERENCE * (highCarbonConsumption / totalHomeConsumption);
homeLowCarbonCircumference =
CIRCLE_CIRCUMFERENCE -
(homeSolarCircumference || 0) -
(homeBatteryCircumference || 0) -
homeHighCarbonCircumference;
}
}
const {
solar: homeSolarCircumference,
battery: homeBatteryCircumference,
lowCarbon: homeLowCarbonCircumference,
grid: homeGridCircumference,
} = computeEnergyDistributionHomeCircleArcs({
usedSolar: solarConsumption ?? 0,
usedBattery: batteryConsumption ?? 0,
usedGrid: gridConsumption,
hasSolar: hasSolarProduction,
hasGrid: Boolean(hasGrid),
highCarbonConsumption,
});
const totalLines =
gridConsumption +
(solarConsumption || 0) +
@@ -670,16 +662,8 @@ class HuiEnergyDistrubutionCard
cx="40"
cy="40"
r="38"
stroke-dasharray="${
homeHighCarbonCircumference ??
CIRCLE_CIRCUMFERENCE -
homeSolarCircumference! -
(homeBatteryCircumference || 0)
} ${
homeHighCarbonCircumference !== undefined
? CIRCLE_CIRCUMFERENCE - homeHighCarbonCircumference
: homeSolarCircumference! +
(homeBatteryCircumference || 0)
stroke-dasharray="${homeGridCircumference} ${
CIRCLE_CIRCUMFERENCE - (homeGridCircumference ?? 0)
}"
stroke-dashoffset="0"
shape-rendering="geometricPrecision"
@@ -27,15 +27,19 @@ import {
} from "../../../data/lovelace/config/badge";
import { haStyle } from "../../../resources/styles";
import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog";
import type { LovelacePath } from "../editor/lovelace-path";
import { getAtPath, getParentPath } from "../editor/lovelace-path";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import {
findLovelaceItems,
getLovelaceContainerPath,
parseLovelaceCardPath,
} from "../editor/lovelace-path";
import type { Lovelace } from "../types";
@customElement("hui-badge-edit-mode")
export class HuiBadgeEditMode extends LitElement {
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ attribute: false }) public path!: LovelacePath;
@property({ type: Array }) public path!: LovelaceCardPath;
@property({ attribute: "hidden-overlay", type: Boolean })
public hiddenOverlay = false;
@@ -59,15 +63,11 @@ export class HuiBadgeEditMode extends LitElement {
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: LovelaceBadgeConfig;
protected _clipboard?: string | Partial<LovelaceBadgeConfig>;
private get _badgeConfig() {
return ensureBadgeConfig(
getAtPath<Partial<LovelaceBadgeConfig> | string>(
this.lovelace.config,
this.path
)!
);
private get _badges() {
const containerPath = getLovelaceContainerPath(this.path!);
return findLovelaceItems("badges", this.lovelace!.config, containerPath)!;
}
private _touchStarted = false;
@@ -207,28 +207,33 @@ export class HuiBadgeEditMode extends LitElement {
private _cutBadge(): void {
this._copyBadge();
fireEvent(this, "ll-delete-badge", { path: this.path, silent: true });
fireEvent(this, "ll-delete-badge", { path: this.path!, silent: true });
}
private _copyBadge(): void {
this._clipboard = deepClone(this._badgeConfig);
const { cardIndex } = parseLovelaceCardPath(this.path!);
const cardConfig = this._badges[cardIndex];
this._clipboard = deepClone(cardConfig);
}
private _duplicateBadge(): void {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const containerPath = getLovelaceContainerPath(this.path!);
const badgeConfig = ensureBadgeConfig(this._badges![cardIndex]);
showEditBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: getParentPath(this.path),
badgeConfig: this._badgeConfig,
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: containerPath as [number],
badgeConfig,
});
}
private _editBadge(): void {
fireEvent(this, "ll-edit-badge", { path: this.path });
fireEvent(this, "ll-edit-badge", { path: this.path! });
}
private _deleteBadge(): void {
fireEvent(this, "ll-delete-badge", { path: this.path, silent: false });
fireEvent(this, "ll-delete-badge", { path: this.path!, silent: false });
}
static get styles(): CSSResultGroup {
@@ -21,14 +21,14 @@ import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import { haStyle } from "../../../resources/styles";
import type { LovelacePath } from "../editor/lovelace-path";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
@customElement("hui-card-edit-mode")
export class HuiCardEditMode extends LitElement {
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ attribute: false }) public path!: LovelacePath;
@property({ type: Array }) public path!: LovelaceCardPath;
@property({ type: Boolean, attribute: "hidden-overlay" })
public hiddenOverlay = false;
@@ -21,7 +21,6 @@ import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { saveConfig } from "../../../data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { isStrategyView } from "../../../data/lovelace/config/view";
import {
showAlertDialog,
@@ -32,16 +31,16 @@ import type { HomeAssistant } from "../../../types";
import { computeCardSize } from "../common/compute-card-size";
import {
addCard,
deleteCard,
moveCardToContainer,
moveCardToIndex,
} from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import {
deleteAtPath,
getAtPath,
getParentPath,
getViewPath,
normalizeCardPath,
type LovelaceCardPath,
type LovelaceContainerPath,
findLovelaceItems,
getLovelaceContainerPath,
parseLovelaceCardPath,
} from "../editor/lovelace-path";
import { showSelectViewDialog } from "../editor/select-view/show-select-view-dialog";
import type { Lovelace, LovelaceCard } from "../types";
@@ -53,7 +52,7 @@ export class HuiCardOptions extends LitElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ attribute: false }) public path?: LovelacePath;
@property({ type: Array }) public path?: LovelaceCardPath;
@queryAssignedElements() private _assignedElements?: LovelaceCard[];
@@ -74,37 +73,24 @@ export class HuiCardOptions extends LitElement {
: 1;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// Temporary compatibility: custom view layouts still set [view, card] index tuples
if (changedProps.has("path") && this.path) {
this.path = normalizeCardPath(this.path);
}
}
protected updated(changedProps: PropertyValues<this>) {
if (!changedProps.has("path") || !this.path) {
return;
}
const viewPath = getViewPath(this.path);
const viewConfig = getAtPath<LovelaceViewConfig>(
this.lovelace!.config,
viewPath
const { viewIndex } = parseLovelaceCardPath(this.path);
this.classList.toggle(
"panel",
this.lovelace!.config.views[viewIndex].panel
);
this.classList.toggle("panel", viewConfig?.panel);
}
private get _cards() {
const cardsPath = getParentPath(this.path!);
return getAtPath<LovelaceCardConfig[]>(this.lovelace!.config, cardsPath)!;
}
private get _cardIndex(): number {
const path = this.path!;
return path[path.length - 1] as number;
const containerPath = getLovelaceContainerPath(this.path!);
return findLovelaceItems("cards", this.lovelace!.config, containerPath)!;
}
protected render(): TemplateResult {
const cardIndex = this._cardIndex;
const { cardIndex } = parseLovelaceCardPath(this.path!);
return html`
<div class="card"><slot></slot></div>
@@ -312,23 +298,21 @@ export class HuiCardOptions extends LitElement {
private _decreaseCardPosiion(): void {
const lovelace = this.lovelace!;
const path = this.path!;
lovelace.saveConfig(
moveCardToIndex(lovelace.config, path, this._cardIndex - 1)
);
const { cardIndex } = parseLovelaceCardPath(path);
lovelace.saveConfig(moveCardToIndex(lovelace.config, path, cardIndex - 1));
}
private _increaseCardPosition(): void {
const lovelace = this.lovelace!;
const path = this.path!;
lovelace.saveConfig(
moveCardToIndex(lovelace.config, path, this._cardIndex + 1)
);
const { cardIndex } = parseLovelaceCardPath(path);
lovelace.saveConfig(moveCardToIndex(lovelace.config, path, cardIndex + 1));
}
private async _changeCardPosition(): Promise<void> {
const lovelace = this.lovelace!;
const path = this.path!;
const cardIndex = this._cardIndex;
const { cardIndex } = parseLovelaceCardPath(path);
const positionString = await showPromptDialog(this, {
title: this.hass!.localize(
"ui.panel.lovelace.editor.change_position.title"
@@ -379,7 +363,7 @@ export class HuiCardOptions extends LitElement {
return;
}
const toPath: LovelacePath = ["views", viewIndex];
const toPath: LovelaceContainerPath = [viewIndex];
if (urlPath === this.lovelace!.urlPath) {
this.lovelace!.saveConfig(
@@ -398,17 +382,15 @@ export class HuiCardOptions extends LitElement {
return;
}
try {
const card = getAtPath<LovelaceCardConfig>(
this.lovelace.config,
this.path!
)!;
const { cardIndex } = parseLovelaceCardPath(this.path!);
const card = this._cards[cardIndex];
await saveConfig(
this.hass!,
urlPath,
addCard(newConfig, toPath, card)
);
this.lovelace!.saveConfig(
deleteAtPath(this.lovelace!.config, this.path!)
deleteCard(this.lovelace!.config, this.path!)
);
this.lovelace.showToast({
@@ -14,13 +14,11 @@ import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-svg-icon";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { duplicateSection } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { deleteAtPath, getAtPath } from "../editor/lovelace-path";
import { deleteSection, duplicateSection } from "../editor/config-util";
import { findLovelaceContainer } from "../editor/lovelace-path";
import { showEditSectionDialog } from "../editor/section-editor/show-edit-section-dialog";
import type { Lovelace } from "../types";
@@ -30,7 +28,9 @@ export class HuiSectionEditMode extends LitElement {
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ attribute: false }) public path!: LovelacePath;
@property({ attribute: false }) public index!: number;
@property({ attribute: false }) public viewIndex!: number;
protected render(): TemplateResult {
return html`
@@ -98,22 +98,26 @@ export class HuiSectionEditMode extends LitElement {
saveConfig: (newConfig) => {
this.lovelace!.saveConfig(newConfig);
},
path: this.path,
viewIndex: this.viewIndex,
sectionIndex: this.index,
});
}
private _duplicateSection(): void {
const newConfig = duplicateSection(this.lovelace!.config, this.path);
const newConfig = duplicateSection(
this.lovelace!.config,
this.viewIndex,
this.index
);
this.lovelace!.saveConfig(newConfig);
}
private async _deleteSection() {
const section = getAtPath<LovelaceSectionRawConfig>(
this.lovelace!.config,
this.path
);
const path = [this.viewIndex, this.index] as [number, number];
const cardCount = section && "cards" in section && section.cards?.length;
const section = findLovelaceContainer(this.lovelace!.config, path);
const cardCount = "cards" in section && section.cards?.length;
if (cardCount) {
const confirm = await showConfirmationDialog(this, {
@@ -130,7 +134,11 @@ export class HuiSectionEditMode extends LitElement {
if (!confirm) return;
}
const newConfig = deleteAtPath(this.lovelace!.config, this.path);
const newConfig = deleteSection(
this.lovelace!.config,
this.viewIndex,
this.index
);
this.lovelace!.saveConfig(newConfig);
}
@@ -128,7 +128,7 @@ export const addEntitiesToLovelaceView = async (
alert(hass.localize("ui.panel.lovelace.add_entities.saving_failed"));
}
},
path: ["views", 0],
path: [0],
entities,
});
return;
@@ -154,7 +154,7 @@ export const addEntitiesToLovelaceView = async (
);
}
},
path: ["views", viewIndex],
path: [viewIndex],
entities,
});
},
@@ -11,10 +11,12 @@ import "../../../../components/ha-dialog-header";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { appendAtPath, getAtPath, getParentPath } from "../lovelace-path";
import { addBadge } from "../config-util";
import { findLovelaceContainer } from "../lovelace-path";
import "./hui-badge-picker";
import "./hui-badge-suggestion-picker";
import type { CreateBadgeDialogParams } from "./show-create-badge-dialog";
@@ -31,7 +33,7 @@ export class HuiCreateDialogBadge
@state() private _open = false;
@state() private _containerConfig!: { title?: string };
@state() private _containerConfig!: LovelaceViewConfig;
@state() private _currTab: "badge" | "entity" = "entity";
@@ -44,11 +46,10 @@ export class HuiCreateDialogBadge
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const containerPath = getParentPath(params.path);
const containerConfig = getAtPath<{ title?: string }>(
const containerConfig = findLovelaceContainer(
params.lovelaceConfig,
containerPath
)!;
params.path
);
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
@@ -220,7 +221,7 @@ export class HuiCreateDialogBadge
const lovelaceConfig = this._params!.lovelaceConfig;
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;
const newConfig = appendAtPath(lovelaceConfig, containerPath, config);
const newConfig = addBadge(lovelaceConfig, containerPath, config);
await saveConfig(newConfig);
this.closeDialog();
}
@@ -16,6 +16,7 @@ import "../../../../components/ha-icon-button";
import "../../../../components/ha-spinner";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import { ensureBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import {
getCustomBadgeEntry,
isCustomType,
@@ -32,16 +33,11 @@ import type { HomeAssistant } from "../../../../types";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../badges/hui-badge";
import { getConfigEntityId } from "../../common/get-config-entity-id";
import { addBadge, replaceBadge } from "../config-util";
import { getBadgeDefaultConfig } from "../get-badge-default-config";
import { getBadgeDocumentationURL } from "../get-dashboard-documentation-url";
import type { ConfigChangedEvent } from "../hui-element-editor";
import type { LovelacePath } from "../lovelace-path";
import {
appendAtPath,
getAtPath,
getParentPath,
setAtPath,
} from "../lovelace-path";
import { findLovelaceContainer } from "../lovelace-path";
import type { GUIModeChangedEvent } from "../types";
import "./hui-badge-element-editor";
import type { HuiBadgeElementEditor } from "./hui-badge-element-editor";
@@ -73,7 +69,7 @@ export class HuiDialogEditBadge
@state() private _badgeConfig?: LovelaceBadgeConfig;
@state() private _containerConfig!: { title?: string };
@state() private _containerConfig!: LovelaceViewConfig;
@state() private _saving = false;
@@ -94,11 +90,10 @@ export class HuiDialogEditBadge
this._guiModeAvailable = true;
this._open = true;
const containerPath = getParentPath(this._collectionPath);
const containerConfig = getAtPath<{ title?: string }>(
const containerConfig = findLovelaceContainer(
params.lovelaceConfig,
containerPath
)!;
params.path
);
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
@@ -109,10 +104,7 @@ export class HuiDialogEditBadge
if ("badgeConfig" in params) {
this._badgeConfig = params.badgeConfig;
} else {
const badge = getAtPath<Partial<LovelaceBadgeConfig> | string>(
params.lovelaceConfig,
params.path
);
const badge = this._containerConfig.badges?.[params.badgeIndex];
this._badgeConfig = badge != null ? ensureBadgeConfig(badge) : badge;
}
@@ -133,11 +125,6 @@ export class HuiDialogEditBadge
}
}
private get _collectionPath(): LovelacePath {
const params = this._params!;
return "badgeConfig" in params ? params.path : getParentPath(params.path);
}
public closeDialog(): boolean {
if (this.isEffectiveDirtyState) {
this._confirmCancel();
@@ -421,15 +408,15 @@ export class HuiDialogEditBadge
return;
}
this._saving = true;
const params = this._params!;
await params.saveConfig(
"badgeConfig" in params
? appendAtPath(
params.lovelaceConfig,
this._collectionPath,
const path = this._params!.path;
await this._params!.saveConfig(
"badgeConfig" in this._params!
? addBadge(this._params!.lovelaceConfig, path, this._badgeConfig!)
: replaceBadge(
this._params!.lovelaceConfig,
[...path, this._params!.badgeIndex],
this._badgeConfig!
)
: setAtPath(params.lovelaceConfig, params.path, this._badgeConfig!)
);
this._saving = false;
this._markDirtyStateClean();
@@ -1,11 +1,10 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelacePath } from "../lovelace-path";
export interface CreateBadgeDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelacePath;
path: [number];
suggestedBadges?: string[];
entities?: string[]; // We can pass entity id's that will be added to the config when a badge is picked
}
@@ -1,13 +1,19 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelacePath } from "../lovelace-path";
export type EditBadgeDialogParams = {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelacePath;
} & ({ badgeConfig: LovelaceBadgeConfig } | {});
path: [number];
} & (
| {
badgeIndex: number;
}
| {
badgeConfig: LovelaceBadgeConfig;
}
);
export const importEditBadgeDialog = () => import("./hui-dialog-edit-badge");
@@ -11,20 +11,13 @@ import "../../../../components/ha-dialog-header";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
LovelaceSectionConfig,
LovelaceSectionRawConfig,
} from "../../../../data/lovelace/config/section";
import type {
LovelaceViewConfig,
LovelaceViewRawConfig,
} from "../../../../data/lovelace/config/view";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { addCardAtPath, getCardSectionConfig } from "../config-util";
import type { LovelacePath } from "../lovelace-path";
import { getAtPath, getParentPath, getPathTarget } from "../lovelace-path";
import { addCard } from "../config-util";
import { findLovelaceContainer } from "../lovelace-path";
import "./hui-card-picker";
import "./hui-suggestion-picker";
import type { CreateCardDialogParams } from "./show-create-card-dialog";
@@ -55,13 +48,10 @@ export class HuiCreateDialogCard
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
const containerConfig = getAtPath<
LovelaceViewRawConfig | LovelaceSectionRawConfig
>(params.lovelaceConfig, this._containerPath(params.path));
if (!containerConfig) {
throw new Error("Container does not exist");
}
const containerConfig = findLovelaceContainer(
params.lovelaceConfig,
params.path
);
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
@@ -76,13 +66,6 @@ export class HuiCreateDialogCard
return true;
}
private _containerPath(path: LovelacePath): LovelacePath {
const parentPath = getParentPath(path);
return getPathTarget(path) === "slot"
? getParentPath(parentPath)
: parentPath;
}
private _dialogClosed(): void {
this._open = false;
this._params = undefined;
@@ -120,7 +103,7 @@ export class HuiCreateDialogCard
<span slot="title">${title}</span>
${
getPathTarget(this._params.path) !== "slot"
!this._params.saveCard
? html`
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
<ha-tab-group-tab
@@ -240,10 +223,15 @@ export class HuiCreateDialogCard
ev: CustomEvent<{ config: LovelaceCardConfig }>
): Promise<void> {
const config = ev.detail.config;
const lovelaceConfig = this._params!.lovelaceConfig;
const path = this._params!.path;
const saveConfig = this._params!.saveConfig;
await saveConfig(addCardAtPath(lovelaceConfig, path, config));
if (this._params!.saveCard) {
await this._params!.saveCard(config);
} else {
const lovelaceConfig = this._params!.lovelaceConfig;
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;
const newConfig = addCard(lovelaceConfig, containerPath, config);
await saveConfig(newConfig);
}
this.closeDialog();
}
@@ -257,17 +245,34 @@ export class HuiCreateDialogCard
}
}
if (this._params!.saveCard) {
showEditCardDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveCardConfig: this._params!.saveCard,
cardConfig: config,
isNew: true,
});
this.closeDialog();
return;
}
const lovelaceConfig = this._params!.lovelaceConfig;
const path = this._params!.path;
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;
const sectionConfig =
containerPath.length === 2
? findLovelaceContainer(lovelaceConfig, containerPath)
: undefined;
showEditCardDialog(this, {
lovelaceConfig,
saveCardConfig: async (newCardConfig) => {
await saveConfig(addCardAtPath(lovelaceConfig, path, newCardConfig));
const newConfig = addCard(lovelaceConfig, containerPath, newCardConfig);
await saveConfig(newConfig);
},
cardConfig: config,
sectionConfig: getCardSectionConfig(lovelaceConfig, path),
sectionConfig,
isNew: true,
});
@@ -13,7 +13,6 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view";
import { isStrategyView } from "../../../../data/lovelace/config/view";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
@@ -22,8 +21,8 @@ import "../../cards/hui-card";
import "../../sections/hui-section";
import { getViewType } from "../../views/get-view-type";
import { addCards, addSection } from "../config-util";
import type { LovelacePath } from "../lovelace-path";
import { getAtPath, getParentPath, getViewPath } from "../lovelace-path";
import type { LovelaceContainerPath } from "../lovelace-path";
import { parseLovelaceContainerPath } from "../lovelace-path";
import { showCreateCardDialog } from "./show-create-card-dialog";
import type { SuggestCardDialogParams } from "./show-suggest-card-dialog";
@@ -75,16 +74,11 @@ export class HuiDialogSuggestCard extends LitElement {
return false;
}
const viewPath = getViewPath(this._params.path);
const viewConfig = getAtPath<LovelaceViewRawConfig>(
this._params.lovelaceConfig,
viewPath
);
const { viewIndex } = parseLovelaceContainerPath(this._params.path);
const viewConfig = this._params!.lovelaceConfig.views[viewIndex];
return (
!!viewConfig &&
!isStrategyView(viewConfig) &&
getViewType(viewConfig) === "sections"
!isStrategyView(viewConfig) && getViewType(viewConfig) === "sections"
);
}
@@ -234,7 +228,7 @@ export class HuiDialogSuggestCard extends LitElement {
showCreateCardDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveConfig: this._params!.saveConfig,
path: [...this._params!.path, "cards"],
path: this._params!.path,
entities: this._params!.entities,
});
this.closeDialog();
@@ -242,27 +236,28 @@ export class HuiDialogSuggestCard extends LitElement {
private _computeNewConfig(
config: LovelaceConfig,
path: LovelacePath
path: LovelaceContainerPath
): LovelaceConfig {
if (!this._viewSupportsSection) {
return addCards(config, path, this._cardConfig!);
}
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
// If container is a view, add a section
const parentPath = getParentPath(path);
if (parentPath[parentPath.length - 1] !== "sections") {
if (sectionIndex === undefined) {
const newSection = this._sectionConfig ?? {
type: "grid",
cards: this._cardConfig,
};
return addSection(config, path, newSection);
return addSection(config, viewIndex, newSection);
}
// Else add cards to section
const newCards = this._sectionConfig
? this._sectionConfig.cards || []
: this._cardConfig!;
return addCards(config, path, newCards);
return addCards(config, [viewIndex, sectionIndex], newCards);
}
private async _save(): Promise<void> {
@@ -1,13 +1,15 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelacePath } from "../lovelace-path";
import type { LovelaceContainerPath } from "../lovelace-path";
export interface CreateCardDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelacePath;
path: LovelaceContainerPath;
suggestedCards?: string[];
entities?: string[]; // We can pass entity id's that will be added to the config when a card is picked
saveCard?: (cardConfig: LovelaceCardConfig) => void; // Optional: pick a single card and return it via callback, hides entity tab
}
export const importCreateCardDialog = () => import("./hui-dialog-create-card");
@@ -2,13 +2,13 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelacePath } from "../lovelace-path";
import type { LovelaceContainerPath } from "../lovelace-path";
export interface SuggestCardDialogParams {
lovelaceConfig?: LovelaceConfig;
yaml?: boolean;
saveConfig?: (config: LovelaceConfig) => void;
path?: LovelacePath;
path?: LovelaceContainerPath;
entities?: string[]; // We pass this to create dialog when user chooses "Pick own"
cardConfig: LovelaceCardConfig[]; // We can pass a suggested config,s
sectionConfig?: LovelaceSectionConfig;
+322 -76
View File
@@ -1,97 +1,180 @@
import deepClone from "deep-clone-simple";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import { ensureBadgeConfig } from "../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type {
LovelaceSectionConfig,
LovelaceSectionRawConfig,
} from "../../../data/lovelace/config/section";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { isStrategyView } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { LovelacePath } from "./lovelace-path";
import type { LovelaceCardPath, LovelaceContainerPath } from "./lovelace-path";
import {
appendAtPath,
deleteAtPath,
getAtPath,
getParentPath,
insertAtPath,
getPathTarget,
moveAtPath,
pathEquals,
setAtPath,
findLovelaceContainer,
findLovelaceItems,
getLovelaceContainerPath,
parseLovelaceCardPath,
parseLovelaceContainerPath,
updateLovelaceContainer,
updateLovelaceItems,
} from "./lovelace-path";
export const addCard = (
config: LovelaceConfig,
containerPath: LovelacePath,
path: LovelaceContainerPath,
cardConfig: LovelaceCardConfig
): LovelaceConfig =>
appendAtPath(config, [...containerPath, "cards"], cardConfig);
): LovelaceConfig => {
const cards = findLovelaceItems("cards", config, path);
const newCards = cards ? [...cards, cardConfig] : [cardConfig];
const newConfig = updateLovelaceItems("cards", config, path, newCards);
return newConfig;
};
export const addCards = (
config: LovelaceConfig,
containerPath: LovelacePath,
path: LovelaceContainerPath,
cardConfigs: LovelaceCardConfig[]
): LovelaceConfig =>
cardConfigs.reduce(
(newConfig, cardConfig) => addCard(newConfig, containerPath, cardConfig),
config
): LovelaceConfig => {
const cards = findLovelaceItems("cards", config, path);
const newCards = cards ? [...cards, ...cardConfigs] : [...cardConfigs];
const newConfig = updateLovelaceItems("cards", config, path, newCards);
return newConfig;
};
export const replaceCard = (
config: LovelaceConfig,
path: LovelaceCardPath,
cardConfig: LovelaceCardConfig
): LovelaceConfig => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const cards = findLovelaceItems("cards", config, containerPath);
const newCards = (cards ?? []).map((origConf, ind) =>
ind === cardIndex ? cardConfig : origConf
);
export const addCardAtPath = (
const newConfig = updateLovelaceItems(
"cards",
config,
containerPath,
newCards
);
return newConfig;
};
export const deleteCard = (
config: LovelaceConfig,
path: LovelacePath,
path: LovelaceCardPath
): LovelaceConfig => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const cards = findLovelaceItems("cards", config, containerPath);
const newCards = (cards ?? []).filter((_origConf, ind) => ind !== cardIndex);
const newConfig = updateLovelaceItems(
"cards",
config,
containerPath,
newCards
);
return newConfig;
};
export const insertCard = (
config: LovelaceConfig,
path: LovelaceCardPath,
cardConfig: LovelaceCardConfig
): LovelaceConfig =>
getPathTarget(path) === "slot"
? setAtPath(config, path, cardConfig)
: appendAtPath(config, path, cardConfig);
) => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const cards = findLovelaceItems("cards", config, containerPath);
const newCards = cards
? [...cards.slice(0, cardIndex), cardConfig, ...cards.slice(cardIndex)]
: [cardConfig];
const newConfig = updateLovelaceItems(
"cards",
config,
containerPath,
newCards
);
return newConfig;
};
export const moveCardToIndex = (
config: LovelaceConfig,
cardPath: LovelacePath,
path: LovelaceCardPath,
index: number
): LovelaceConfig => {
const collectionPath = getParentPath(cardPath);
const cards = getAtPath<LovelaceCardConfig[]>(config, collectionPath) ?? [];
const newIndex = Math.max(Math.min(index, cards.length - 1), 0);
return moveAtPath(config, cardPath, [...collectionPath, newIndex]);
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const cards = findLovelaceItems("cards", config, containerPath);
const newCards = cards ? [...cards] : [];
const oldIndex = cardIndex;
const newIndex = Math.max(Math.min(index, newCards.length - 1), 0);
const card = newCards[oldIndex];
newCards.splice(oldIndex, 1);
newCards.splice(newIndex, 0, card);
const newConfig = updateLovelaceItems(
"cards",
config,
containerPath,
newCards
);
return newConfig;
};
export const moveCardToContainer = (
config: LovelaceConfig,
cardPath: LovelacePath,
containerPath: LovelacePath
fromPath: LovelaceCardPath,
toPath: LovelaceContainerPath
): LovelaceConfig => {
const fromCardsPath = getParentPath(cardPath);
const toCardsPath = [...containerPath, "cards"];
if (pathEquals(fromCardsPath, toCardsPath)) {
const {
cardIndex: fromCardIndex,
viewIndex: fromViewIndex,
sectionIndex: fromSectionIndex,
} = parseLovelaceCardPath(fromPath);
const { viewIndex: toViewIndex, sectionIndex: toSectionIndex } =
parseLovelaceContainerPath(toPath);
if (fromViewIndex === toViewIndex && fromSectionIndex === toSectionIndex) {
throw new Error("You cannot move a card to the view or section it is in.");
}
const card = getAtPath<LovelaceCardConfig>(config, cardPath)!;
const newConfig = addCard(config, containerPath, card);
return deleteAtPath(newConfig, cardPath);
const fromContainerPath = getLovelaceContainerPath(fromPath);
const cards = findLovelaceItems("cards", config, fromContainerPath);
const card = cards![fromCardIndex];
let newConfig = addCard(config, toPath, card);
newConfig = deleteCard(newConfig, fromPath);
return newConfig;
};
export const getCardSectionConfig = (
export const moveCard = (
config: LovelaceConfig,
path: LovelacePath
): LovelaceSectionConfig | undefined => {
const parentPath = getParentPath(path);
const containerPath =
getPathTarget(path) === "item" ? getParentPath(parentPath) : parentPath;
if (
containerPath[containerPath.length - 2] !== "sections" ||
typeof containerPath[containerPath.length - 1] !== "number"
) {
return undefined;
}
const section = getAtPath<LovelaceSectionRawConfig>(config, containerPath);
if (!section || isStrategySection(section)) {
return undefined;
}
return section;
fromPath: LovelaceCardPath,
toPath: LovelaceCardPath
): LovelaceConfig => {
const { cardIndex: fromCardIndex } = parseLovelaceCardPath(fromPath);
const fromContainerPath = getLovelaceContainerPath(fromPath);
const cards = findLovelaceItems("cards", config, fromContainerPath);
const card = cards![fromCardIndex];
let newConfig = deleteCard(config, fromPath);
newConfig = insertCard(newConfig, toPath, card);
return newConfig;
};
export const addView = (
@@ -183,34 +266,197 @@ export const moveViewToDashboard = (
export const addSection = (
config: LovelaceConfig,
containerPath: LovelacePath,
viewIndex: number,
sectionConfig: LovelaceSectionRawConfig
): LovelaceConfig =>
appendAtPath(config, [...containerPath, "sections"], sectionConfig);
): LovelaceConfig => {
const view = findLovelaceContainer(config, [viewIndex]);
if (isStrategyView(view)) {
throw new Error("Deleting sections in a strategy is not supported.");
}
const sections = view.sections
? [...view.sections, sectionConfig]
: [sectionConfig];
const newConfig = updateLovelaceContainer(config, [viewIndex], {
...view,
sections,
});
return newConfig;
};
export const deleteSection = (
config: LovelaceConfig,
viewIndex: number,
sectionIndex: number
): LovelaceConfig => {
const view = findLovelaceContainer(config, [viewIndex]);
if (isStrategyView(view)) {
throw new Error("Deleting sections in a strategy is not supported.");
}
const sections = view.sections?.filter(
(_origSection, index) => index !== sectionIndex
);
const newConfig = updateLovelaceContainer(config, [viewIndex], {
...view,
sections,
});
return newConfig;
};
export const duplicateSection = (
config: LovelaceConfig,
sectionPath: LovelacePath
viewIndex: number,
sectionIndex: number
): LovelaceConfig => {
const index = sectionPath[sectionPath.length - 1] as number;
const sectionsPath = getParentPath(sectionPath);
const section = getAtPath<LovelaceSectionRawConfig>(config, sectionPath);
return insertAtPath(config, [...sectionsPath, index + 1], deepClone(section));
const view = findLovelaceContainer(config, [viewIndex]);
if (isStrategyView(view)) {
throw new Error("Duplicating sections in a strategy is not supported.");
}
const clone = deepClone(view.sections![sectionIndex]);
return insertSection(config, viewIndex, sectionIndex + 1, clone);
};
export const insertSection = (
config: LovelaceConfig,
viewIndex: number,
sectionIndex: number,
sectionConfig: LovelaceSectionRawConfig
): LovelaceConfig => {
const view = findLovelaceContainer(config, [viewIndex]);
if (isStrategyView(view)) {
throw new Error("Inserting sections in a strategy is not supported.");
}
const sections = view.sections
? [
...view.sections.slice(0, sectionIndex),
sectionConfig,
...view.sections.slice(sectionIndex),
]
: [sectionConfig];
const newConfig = updateLovelaceContainer(config, [viewIndex], {
...view,
sections,
});
return newConfig;
};
export const moveSection = (
config: LovelaceConfig,
fromPath: [number, number],
toPath: [number, number]
): LovelaceConfig => {
const section = findLovelaceContainer(config, fromPath);
let newConfig = deleteSection(config, fromPath[0], fromPath[1]);
newConfig = insertSection(newConfig, toPath[0], toPath[1], section);
return newConfig;
};
export const addBadge = (
config: LovelaceConfig,
containerPath: LovelacePath,
path: LovelaceContainerPath,
badgeConfig: LovelaceBadgeConfig
): LovelaceConfig =>
appendAtPath(config, [...containerPath, "badges"], badgeConfig);
): LovelaceConfig => {
const badges = findLovelaceItems("badges", config, path);
const newBadges = badges ? [...badges, badgeConfig] : [badgeConfig];
const newConfig = updateLovelaceItems("badges", config, path, newBadges);
return newConfig;
};
export const addBadges = (
config: LovelaceConfig,
containerPath: LovelacePath,
badgeConfigs: LovelaceBadgeConfig[]
): LovelaceConfig =>
badgeConfigs.reduce(
(newConfig, badgeConfig) => addBadge(newConfig, containerPath, badgeConfig),
config
path: LovelaceContainerPath,
badgeConfig: LovelaceBadgeConfig[]
): LovelaceConfig => {
const badges = findLovelaceItems("badges", config, path);
const newBadges = badges ? [...badges, ...badgeConfig] : [...badgeConfig];
const newConfig = updateLovelaceItems("badges", config, path, newBadges);
return newConfig;
};
export const replaceBadge = (
config: LovelaceConfig,
path: LovelaceCardPath,
cardConfig: LovelaceBadgeConfig
): LovelaceConfig => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const badges = findLovelaceItems("badges", config, containerPath);
const newBadges = (badges ?? []).map((origConf, ind) =>
ind === cardIndex ? cardConfig : origConf
);
const newConfig = updateLovelaceItems(
"badges",
config,
containerPath,
newBadges
);
return newConfig;
};
export const deleteBadge = (
config: LovelaceConfig,
path: LovelaceCardPath
): LovelaceConfig => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const badges = findLovelaceItems("badges", config, containerPath);
const newBadges = (badges ?? []).filter(
(_origConf, ind) => ind !== cardIndex
);
const newConfig = updateLovelaceItems(
"badges",
config,
containerPath,
newBadges
);
return newConfig;
};
export const insertBadge = (
config: LovelaceConfig,
path: LovelaceCardPath,
badgeConfig: LovelaceBadgeConfig
) => {
const { cardIndex } = parseLovelaceCardPath(path);
const containerPath = getLovelaceContainerPath(path);
const badges = findLovelaceItems("badges", config, containerPath);
const newBadges = badges
? [...badges.slice(0, cardIndex), badgeConfig, ...badges.slice(cardIndex)]
: [badgeConfig];
const newConfig = updateLovelaceItems(
"badges",
config,
containerPath,
newBadges
);
return newConfig;
};
export const moveBadge = (
config: LovelaceConfig,
fromPath: LovelaceCardPath,
toPath: LovelaceCardPath
): LovelaceConfig => {
const { cardIndex: fromCardIndex } = parseLovelaceCardPath(fromPath);
const fromContainerPath = getLovelaceContainerPath(fromPath);
const badges = findLovelaceItems("badges", config, fromContainerPath);
const badge = badges![fromCardIndex];
let newConfig = deleteBadge(config, fromPath);
newConfig = insertBadge(newConfig, toPath, ensureBadgeConfig(badge));
return newConfig;
};
+4 -4
View File
@@ -1,11 +1,11 @@
import type { HomeAssistant } from "../../../types";
import type { Lovelace } from "../types";
import type { LovelacePath } from "./lovelace-path";
import { deleteAtPath } from "./lovelace-path";
import { deleteBadge } from "./config-util";
import type { LovelaceCardPath } from "./lovelace-path";
import { fireEvent } from "../../../common/dom/fire_event";
export interface DeleteBadgeParams {
path: LovelacePath;
path: LovelaceCardPath;
silent: boolean;
}
@@ -17,7 +17,7 @@ export async function performDeleteBadge(
try {
const { path, silent } = params;
const oldConfig = lovelace.config;
const newConfig = deleteAtPath(oldConfig, path);
const newConfig = deleteBadge(oldConfig, path);
await lovelace.saveConfig(newConfig);
if (silent) {
+4 -4
View File
@@ -1,11 +1,11 @@
import type { HomeAssistant } from "../../../types";
import type { Lovelace } from "../types";
import type { LovelacePath } from "./lovelace-path";
import { deleteAtPath } from "./lovelace-path";
import { deleteCard } from "./config-util";
import type { LovelaceCardPath } from "./lovelace-path";
import { fireEvent } from "../../../common/dom/fire_event";
export interface DeleteCardParams {
path: LovelacePath;
path: LovelaceCardPath;
silent: boolean;
}
@@ -17,7 +17,7 @@ export async function performDeleteCard(
try {
const { path, silent } = params;
const oldConfig = lovelace.config;
const newConfig = deleteAtPath(oldConfig, path);
const newConfig = deleteCard(oldConfig, path);
await lovelace.saveConfig(newConfig);
if (silent) {
+186 -219
View File
@@ -1,245 +1,212 @@
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/section";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../data/lovelace/config/view";
import { isStrategyView } from "../../../data/lovelace/config/view";
export type LovelacePath = (string | number)[];
export type LovelaceItemKind = "view" | "section" | "card" | "badge";
export type LovelacePathTarget = "item" | "list" | "slot" | "node";
export type LovelaceCardPath = [number, number] | [number, number, number];
export type LovelaceContainerPath = [number] | [number, number];
const LIST_KEYS: Record<string, LovelaceItemKind> = {
views: "view",
sections: "section",
cards: "card",
badges: "badge",
};
const SLOT_KEYS: Record<string, LovelaceItemKind> = {
card: "card",
};
export const stringifyPath = (path: LovelacePath): string => path.join("/");
export const parsePath = (path: string): LovelacePath =>
path === ""
? []
: path
.split("/")
.map((segment) => (/^\d+$/.test(segment) ? Number(segment) : segment));
export const pathEquals = (a: LovelacePath, b: LovelacePath): boolean =>
a.length === b.length && a.every((segment, index) => segment === b[index]);
export const isAncestorPath = (
ancestor: LovelacePath,
path: LovelacePath
): boolean =>
ancestor.length < path.length &&
ancestor.every((segment, index) => segment === path[index]);
export const getParentPath = (path: LovelacePath): LovelacePath =>
path.slice(0, -1);
export const getViewPath = (path: LovelacePath): LovelacePath =>
path.slice(0, 2);
export const getPathTarget = (path: LovelacePath): LovelacePathTarget => {
const last = path[path.length - 1];
if (typeof last === "number") {
return "item";
export const parseLovelaceCardPath = (
path: LovelaceCardPath
): { viewIndex: number; sectionIndex?: number; cardIndex: number } => {
if (path.length === 2) {
return {
viewIndex: path[0],
cardIndex: path[1],
};
}
if (last in LIST_KEYS) {
return "list";
}
if (last in SLOT_KEYS) {
return "slot";
}
return "node";
};
// Temporary compatibility: custom view layouts still pass [view, card] or [view, section, card] index tuples
export const normalizeCardPath = (path: LovelacePath): LovelacePath => {
if (path.length < 2 || path.some((segment) => typeof segment === "string")) {
return path;
}
const [viewIndex, ...rest] = path;
return rest.length === 1
? ["views", viewIndex, "cards", rest[0]]
: ["views", viewIndex, "sections", rest[0], "cards", rest[1]];
};
export const getItemKind = (
path: LovelacePath
): LovelaceItemKind | undefined => {
for (let index = path.length - 1; index >= 0; index--) {
const segment = path[index];
if (typeof segment === "string") {
return LIST_KEYS[segment] ?? SLOT_KEYS[segment];
}
}
return undefined;
};
const isRecord = (node: unknown): node is Record<string, unknown> =>
typeof node === "object" && node !== null && !Array.isArray(node);
const isStrategyNode = (node: unknown): boolean =>
isRecord(node) && "strategy" in node;
const strategyError = (path: LovelacePath, depth: number): Error =>
new Error(
`Cannot edit inside a strategy: ${stringifyPath(path.slice(0, depth))}`
);
const getChild = (node: unknown, segment: string | number): unknown => {
if (Array.isArray(node)) {
return typeof segment === "number" ? node[segment] : undefined;
}
if (isRecord(node) && typeof segment === "string") {
return node[segment];
}
return undefined;
};
const readAtPath = (node: unknown, path: LovelacePath, depth = 0): unknown => {
if (depth === path.length) {
return node;
}
if (node === undefined) {
return undefined;
}
if (isStrategyNode(node)) {
throw strategyError(path, depth);
}
return readAtPath(getChild(node, path[depth]), path, depth + 1);
};
const updateAtPath = (
node: unknown,
path: LovelacePath,
updater: (node: unknown) => unknown,
depth = 0
): unknown => {
if (depth === path.length) {
return updater(node);
}
if (isStrategyNode(node)) {
throw strategyError(path, depth);
}
const segment = path[depth];
if (typeof segment === "number") {
if (!Array.isArray(node) || segment >= node.length) {
throw new Error(
`Cannot edit missing item: ${stringifyPath(path.slice(0, depth + 1))}`
);
}
const items = node.slice();
items[segment] = updateAtPath(items[segment], path, updater, depth + 1);
return items;
}
const record = isRecord(node) ? node : {};
return {
...record,
[segment]: updateAtPath(record[segment], path, updater, depth + 1),
viewIndex: path[0],
sectionIndex: path[1],
cardIndex: path[2],
};
};
const update = (
config: LovelaceConfig,
path: LovelacePath,
updater: (node: unknown) => unknown
): LovelaceConfig => updateAtPath(config, path, updater) as LovelaceConfig;
export const getAtPath = <T = unknown>(
config: LovelaceConfig,
path: LovelacePath
): T | undefined => readAtPath(config, path) as T | undefined;
export const setAtPath = (
config: LovelaceConfig,
path: LovelacePath,
value: unknown
): LovelaceConfig => update(config, path, () => value);
export const insertAtPath = (
config: LovelaceConfig,
path: LovelacePath,
value: unknown
): LovelaceConfig => {
const index = path[path.length - 1];
if (typeof index !== "number") {
return setAtPath(config, path, value);
export const parseLovelaceContainerPath = (
path: LovelaceContainerPath
): { viewIndex: number; sectionIndex?: number } => {
if (path.length === 1) {
return {
viewIndex: path[0],
};
}
return update(config, getParentPath(path), (node) => {
const items = Array.isArray(node) ? node.slice() : [];
items.splice(Math.max(0, Math.min(index, items.length)), 0, value);
return items;
});
return {
viewIndex: path[0],
sectionIndex: path[1],
};
};
export const appendAtPath = (
config: LovelaceConfig,
collectionPath: LovelacePath,
value: unknown
): LovelaceConfig =>
update(config, collectionPath, (node) =>
Array.isArray(node) ? [...node, value] : [value]
);
export const getLovelaceContainerPath = (
path: LovelaceCardPath
): LovelaceContainerPath => path.slice(0, -1) as LovelaceContainerPath;
export const deleteAtPath = (
interface FindLovelaceContainer {
(config: LovelaceConfig, path: [number]): LovelaceViewRawConfig;
(config: LovelaceConfig, path: [number, number]): LovelaceSectionRawConfig;
(
config: LovelaceConfig,
path: LovelaceContainerPath
): LovelaceViewRawConfig | LovelaceSectionRawConfig;
}
export const findLovelaceContainer: FindLovelaceContainer = ((
config: LovelaceConfig,
path: LovelacePath
): LovelaceConfig => {
if (path.length === 0 || getAtPath(config, path) === undefined) {
return config;
path: LovelaceContainerPath
): LovelaceViewRawConfig | LovelaceSectionRawConfig => {
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
const view = config.views[viewIndex];
if (!view) {
throw new Error("View does not exist");
}
const key = path[path.length - 1];
return update(config, getParentPath(path), (node) => {
if (typeof key === "number") {
return Array.isArray(node)
? node.filter((_item, index) => index !== key)
: node;
if (sectionIndex === undefined) {
return view;
}
if (isStrategyView(view)) {
throw new Error("Can not find section in a strategy view");
}
const section = view.sections?.[sectionIndex];
if (!section) {
throw new Error("Section does not exist");
}
return section;
}) as FindLovelaceContainer;
export const updateLovelaceContainer = (
config: LovelaceConfig,
path: LovelaceContainerPath,
containerConfig: LovelaceViewRawConfig | LovelaceSectionRawConfig
): LovelaceConfig => {
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
let updated = false;
const newViews = config.views.map((view, vIndex) => {
if (vIndex !== viewIndex) return view;
if (sectionIndex === undefined) {
updated = true;
return containerConfig as LovelaceViewRawConfig;
}
if (!isRecord(node)) {
return node;
if (isStrategyView(view)) {
throw new Error("Can not update section in a strategy view");
}
const record = { ...node };
delete record[key];
return record;
if (view.sections === undefined) {
throw new Error("Section does not exist");
}
const newSections = view.sections.map((section, sIndex) => {
if (sIndex !== sectionIndex) return section;
updated = true;
return containerConfig as LovelaceSectionRawConfig;
});
return {
...view,
sections: newSections,
};
});
if (!updated) {
throw new Error("Can not update cards in a non-existing view/section");
}
return {
...config,
views: newViews,
};
};
export const moveAtPath = (
interface LovelaceItemKeys {
cards: LovelaceCardConfig[];
badges: (Partial<LovelaceBadgeConfig> | string)[];
}
export const updateLovelaceItems = <T extends keyof LovelaceItemKeys>(
key: T,
config: LovelaceConfig,
from: LovelacePath,
to: LovelacePath
path: LovelaceContainerPath,
items: LovelaceItemKeys[T]
): LovelaceConfig => {
if (pathEquals(from, to)) {
return config;
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
let updated = false;
const newViews = config.views.map((view, vIndex) => {
if (vIndex !== viewIndex) return view;
if (isStrategyView(view)) {
throw new Error(`Can not update ${key} in a strategy view`);
}
if (sectionIndex === undefined) {
updated = true;
return {
...view,
[key]: items,
};
}
if (view.sections === undefined) {
throw new Error("Section does not exist");
}
const newSections = view.sections.map((section, sIndex) => {
if (sIndex !== sectionIndex) return section;
if (isStrategySection(section)) {
throw new Error(`Can not update ${key} in a strategy section`);
}
updated = true;
return {
...section,
[key]: items,
};
});
return {
...view,
sections: newSections,
};
});
if (!updated) {
throw new Error(`Can not update ${key} in a non-existing view/section`);
}
if (isAncestorPath(from, to)) {
throw new Error(
`Cannot move ${stringifyPath(from)} into itself: ${stringifyPath(to)}`
);
return {
...config,
views: newViews,
};
};
export const findLovelaceItems = <T extends keyof LovelaceItemKeys>(
key: T,
config: LovelaceConfig,
path: LovelaceContainerPath
): LovelaceItemKeys[T] | undefined => {
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(path);
const view = config.views[viewIndex];
if (!view) {
throw new Error("View does not exist");
}
const value = getAtPath(config, from);
if (value === undefined) {
throw new Error(`Nothing to move at ${stringifyPath(from)}`);
if (isStrategyView(view)) {
throw new Error("Can not find cards in a strategy view");
}
const deleted = deleteAtPath(config, from);
const target = [...to];
const sameList =
to.length === from.length &&
pathEquals(getParentPath(from), getParentPath(to));
const shiftedIndex = from.length - 1;
const fromIndex = from[shiftedIndex];
const toIndex = to[shiftedIndex];
if (
!sameList &&
to.length > from.length &&
isAncestorPath(getParentPath(from), to) &&
typeof toIndex === "number" &&
typeof fromIndex === "number" &&
toIndex > fromIndex
) {
target[shiftedIndex] = toIndex - 1;
if (sectionIndex === undefined) {
return view[key] as LovelaceItemKeys[T] | undefined;
}
return insertAtPath(deleted, target, value);
const section = view.sections?.[sectionIndex];
if (!section) {
throw new Error("Section does not exist");
}
if (isStrategySection(section)) {
throw new Error("Can not find cards in a strategy section");
}
if (key === "cards") {
return section[key as "cards"] as LovelaceItemKeys[T] | undefined;
}
throw new Error(`${key} is not supported in section`);
};
@@ -24,8 +24,10 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import { saveConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../../../../data/lovelace/config/view";
import { isStrategyView } from "../../../../data/lovelace/config/view";
import {
isStrategyView,
type LovelaceViewConfig,
} from "../../../../data/lovelace/config/view";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
@@ -35,13 +37,10 @@ import {
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { Lovelace } from "../../types";
import { addSection } from "../config-util";
import { addSection, deleteSection, moveSection } from "../config-util";
import {
deleteAtPath,
getAtPath,
getViewPath,
moveAtPath,
setAtPath,
findLovelaceContainer,
updateLovelaceContainer,
} from "../lovelace-path";
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
import "./hui-section-settings-editor";
@@ -66,7 +65,7 @@ export class HuiDialogEditSection
@state() private _config?: LovelaceSectionRawConfig;
@state() private _viewConfig?: LovelaceViewRawConfig;
@state() private _viewConfig?: LovelaceViewConfig;
@state() private _yamlMode = false;
@@ -92,15 +91,13 @@ export class HuiDialogEditSection
this.lovelace = params.lovelace;
this._config = getAtPath<LovelaceSectionRawConfig>(
params.lovelaceConfig,
params.path
);
const viewPath = getViewPath(params.path);
this._viewConfig = getAtPath<LovelaceViewRawConfig>(
params.lovelaceConfig,
viewPath
);
this._config = findLovelaceContainer(this._params.lovelaceConfig, [
this._params.viewIndex,
this._params.sectionIndex,
]);
this._viewConfig = findLovelaceContainer(this._params.lovelaceConfig, [
this._params.viewIndex,
]);
this._initDirtyTracking({ type: "deep" }, this._config);
}
@@ -319,7 +316,8 @@ export class HuiDialogEditSection
return;
}
const fromPath = this._params.path;
const fromViewIndex = this._params.viewIndex;
const fromSectionIndex = this._params.sectionIndex;
// Same dashboard
if (urlPath === this.lovelace.urlPath) {
@@ -327,12 +325,11 @@ export class HuiDialogEditSection
const toIndex = toView.sections?.length ?? 0;
try {
await this.lovelace.saveConfig(
moveAtPath(oldConfig, fromPath, [
"views",
viewIndex,
"sections",
toIndex,
])
moveSection(
oldConfig,
[fromViewIndex, fromSectionIndex],
[viewIndex, toIndex]
)
);
this.lovelace.showToast({
message: this.hass!.localize(
@@ -364,18 +361,20 @@ export class HuiDialogEditSection
const oldFromConfig = this.lovelace.config;
const oldToConfig = selectedDashConfig;
try {
const section = getAtPath<LovelaceSectionRawConfig>(
oldFromConfig,
fromPath
)!;
const section = findLovelaceContainer(oldFromConfig, [
fromViewIndex,
fromSectionIndex,
]) as LovelaceSectionRawConfig;
await saveConfig(
this.hass!,
urlPath,
addSection(oldToConfig, ["views", viewIndex], section)
addSection(oldToConfig, viewIndex, section)
);
await this.lovelace.saveConfig(deleteAtPath(oldFromConfig, fromPath));
await this.lovelace.saveConfig(
deleteSection(oldFromConfig, fromViewIndex, fromSectionIndex)
);
this.lovelace.showToast({
message: this.hass!.localize(
@@ -427,9 +426,9 @@ export class HuiDialogEditSection
if (!this._params || !this._config) {
return;
}
const newConfig = setAtPath(
const newConfig = updateLovelaceContainer(
this._params.lovelaceConfig,
this._params.path,
[this._params.viewIndex, this._params.sectionIndex],
this._config
);
@@ -1,13 +1,13 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { Lovelace } from "../../types";
import type { LovelacePath } from "../lovelace-path";
export interface EditSectionDialogParams {
lovelace: Lovelace;
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelacePath;
viewIndex: number;
sectionIndex: number;
}
const importEditSectionDialog = () => import("./hui-dialog-edit-section");
@@ -119,7 +119,7 @@ export class HuiUnusedEntities extends LitElement {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this.lovelace.saveConfig,
path: ["views", 0],
path: [0],
entities: this._selectedEntities,
cardConfig,
sectionConfig,
@@ -133,7 +133,7 @@ export class HuiUnusedEntities extends LitElement {
showSuggestCardDialog(this, {
lovelaceConfig: this.lovelace.config!,
saveConfig: this.lovelace.saveConfig,
path: ["views", viewIndex],
path: [viewIndex],
entities: this._selectedEntities,
cardConfig,
sectionConfig,
@@ -17,8 +17,8 @@ import type { HomeAssistant } from "../../../types";
import type { HuiCard } from "../cards/hui-card";
import { computeCardGridSize } from "../common/compute-card-grid-size";
import "../components/hui-card-edit-mode";
import type { LovelacePath } from "../editor/lovelace-path";
import { moveAtPath } from "../editor/lovelace-path";
import { moveCard } from "../editor/config-util";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
const CARD_SORTABLE_OPTIONS: HaSortableOptions = {
@@ -44,7 +44,9 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ attribute: false }) public path?: LovelacePath;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public viewIndex?: number;
@property({ attribute: false }) public isStrategy = false;
@@ -108,9 +110,11 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
const { rows, columns } = computeCardGridSize(gridOptions);
const cardPath: LovelacePath | undefined = editMode
? [...this.path!, "cards", idx]
: undefined;
const cardPath: LovelaceCardPath = [
this.viewIndex!,
this.index!,
idx,
];
return html`
<div
style=${styleMap({
@@ -129,7 +133,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
? html`
<hui-card-edit-mode
.lovelace=${this.lovelace!}
.path=${cardPath!}
.path=${cardPath}
.hiddenOverlay=${this._dragging}
.noEdit=${this.importOnly}
.noDuplicate=${this.importOnly}
@@ -170,22 +174,19 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
private _cardMoved(ev) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newConfig = moveAtPath(
const newConfig = moveCard(
this.lovelace!.config,
[...this.path!, "cards", oldIndex],
[...this.path!, "cards", newIndex]
[this.viewIndex!, this.index!, oldIndex],
[this.viewIndex!, this.index!, newIndex]
);
this.lovelace!.saveConfig(newConfig);
}
private _cardAdded(ev) {
ev.stopPropagation();
const { index, data } = ev.detail;
const newConfig = moveAtPath(this.lovelace!.config, data as LovelacePath, [
...this.path!,
"cards",
index,
]);
const oldPath = data as LovelaceCardPath;
const newPath = [this.viewIndex!, this.index!, index] as LovelaceCardPath;
const newConfig = moveCard(this.lovelace!.config, oldPath, newPath);
this.lovelace!.saveConfig(newConfig);
}
@@ -203,10 +204,7 @@ export class GridSection extends LitElement implements LovelaceSectionElement {
}
private _addCard() {
fireEvent(this, "ll-create-card", {
path: [...this.path!, "cards"],
suggested: ["tile", "heading"],
});
fireEvent(this, "ll-create-card", { suggested: ["tile", "heading"] });
}
static get styles(): CSSResultGroup {
+95 -6
View File
@@ -1,6 +1,8 @@
import deepClone from "deep-clone-simple";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { storage } from "../../../common/decorators/storage";
import { deepEqual } from "../../../common/util/deep-equal";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -18,7 +20,11 @@ import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-m
import "../cards/hui-card";
import type { HuiCard } from "../cards/hui-card";
import { createSectionElement } from "../create-element/create-section-element";
import type { LovelacePath } from "../editor/lovelace-path";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { addCard, replaceCard } from "../editor/config-util";
import { performDeleteCard } from "../editor/delete-card";
import { parseLovelaceCardPath } from "../editor/lovelace-path";
import {
checkStrategyShouldRegenerate,
generateLovelaceSectionStrategy,
@@ -47,7 +53,9 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
@property({ type: Boolean, attribute: "import-only" })
public importOnly = false;
@property({ attribute: false }) public path!: LovelacePath;
@property({ type: Number }) public index!: number;
@property({ attribute: false }) public viewIndex!: number;
@state() private _cards: HuiCard[] = [];
@@ -55,6 +63,14 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
private _layoutElement?: LovelaceSectionElement;
@storage({
key: "dashboardCardClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: LovelaceCardConfig;
private _createCardElement(cardConfig: LovelaceCardConfig) {
const element = document.createElement("hui-card");
element.hass = this.hass;
@@ -177,9 +193,6 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
if (changedProperties.has("importOnly")) {
this._layoutElement.importOnly = this.importOnly;
}
if (changedProperties.has("path")) {
this._layoutElement.path = this.path;
}
if (changedProperties.has("_cards")) {
this._layoutElement.cards = this._cards;
}
@@ -238,7 +251,8 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
this._layoutElement!.isStrategy = isStrategy;
this._layoutElement!.hass = this.hass;
this._layoutElement!.lovelace = this.lovelace;
this._layoutElement!.path = this.path;
this._layoutElement!.index = this.index;
this._layoutElement!.viewIndex = this.viewIndex;
this._layoutElement!.cards = this._cards;
if (addLayoutElement) {
@@ -303,6 +317,81 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
config
) as LovelaceSectionElement;
this._layoutElementType = config.type;
this._layoutElement.addEventListener("ll-create-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.viewIndex, this.index],
suggestedCards: ev.detail?.suggested,
});
});
this._layoutElement.addEventListener("ll-edit-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const sectionConfig = this.config;
if (isStrategySection(sectionConfig)) {
return;
}
const cardConfig = sectionConfig.cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveCardConfig: async (newCardConfig) => {
const newConfig = replaceCard(
this.lovelace!.config,
[this.viewIndex, this.index, cardIndex],
newCardConfig
);
await this.lovelace!.saveConfig(newConfig);
},
sectionConfig,
cardConfig,
});
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
performDeleteCard(this.hass, this.lovelace, ev.detail);
});
this._layoutElement.addEventListener("ll-duplicate-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const sectionConfig = this.config;
if (isStrategySection(sectionConfig)) {
return;
}
const cardConfig = sectionConfig.cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveCardConfig: async (newCardConfig) => {
const newConfig = addCard(
this.lovelace!.config,
[this.viewIndex, this.index],
newCardConfig
);
await this.lovelace!.saveConfig(newConfig);
},
cardConfig,
sectionConfig,
isNew: true,
});
});
this._layoutElement.addEventListener("ll-copy-card", (ev) => {
ev.stopPropagation();
if (!this.lovelace) return;
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const sectionConfig = this.config;
if (isStrategySection(sectionConfig)) {
return;
}
const cardConfig = sectionConfig.cards![cardIndex];
this._clipboard = deepClone(cardConfig);
});
}
private _createCards(config: LovelaceSectionConfig): void {
@@ -13,7 +13,6 @@ import type { HuiBadge } from "../badges/hui-badge";
import "../badges/hui-view-badges";
import type { HuiCard } from "../cards/hui-card";
import { computeCardSize } from "../common/compute-card-size";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
// Find column with < 5 size, else smallest column
@@ -42,7 +41,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public path?: LovelacePath;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public isStrategy = false;
@@ -86,7 +85,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
<hui-view-badges
.badges=${this.badges}
.lovelace=${this.lovelace}
.path=${[...this.path!, "badges"]}
.viewIndex=${this.index}
show-add-label
></hui-view-badges>
<div
@@ -161,7 +160,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
}
private _addCard(): void {
fireEvent(this, "ll-create-card", { path: [...this.path!, "cards"] });
fireEvent(this, "ll-create-card");
}
private _createRootElement(columns: HTMLDivElement[]) {
@@ -269,7 +268,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
const wrapper = document.createElement("hui-card-options");
wrapper.hass = this.hass;
wrapper.lovelace = this.lovelace;
wrapper.path = [...this.path!, "cards", index];
wrapper.path = [this.index!, index];
card.preview = true;
wrapper.appendChild(card);
columnEl.appendChild(wrapper);
+3 -4
View File
@@ -12,7 +12,6 @@ import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { HuiCard } from "../cards/hui-card";
import type { HuiCardOptions } from "../components/hui-card-options";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
let editCodeLoaded = false;
@@ -23,7 +22,7 @@ export class PanelView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ attribute: false }) public path?: LovelacePath;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public isStrategy = false;
@@ -97,7 +96,7 @@ export class PanelView extends LitElement implements LovelaceViewElement {
}
private _addCard(): void {
fireEvent(this, "ll-create-card", { path: [...this.path!, "cards"] });
fireEvent(this, "ll-create-card");
}
private _createCard(): void {
@@ -118,7 +117,7 @@ export class PanelView extends LitElement implements LovelaceViewElement {
const wrapper = document.createElement("hui-card-options");
wrapper.hass = this.hass;
wrapper.lovelace = this.lovelace;
wrapper.path = [...this.path!, "cards", 0];
wrapper.path = [this.index!, 0];
wrapper.hidePosition = true;
card.preview = true;
wrapper.appendChild(card);
+40 -39
View File
@@ -22,9 +22,13 @@ import type { HomeAssistant } from "../../../types";
import type { HuiBadge } from "../badges/hui-badge";
import type { HuiCard } from "../cards/hui-card";
import "../components/hui-section-edit-mode";
import { addSection } from "../editor/config-util";
import type { LovelacePath } from "../editor/lovelace-path";
import { getAtPath, moveAtPath } from "../editor/lovelace-path";
import { addSection, moveCard, moveSection } from "../editor/config-util";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import {
findLovelaceItems,
getLovelaceContainerPath,
parseLovelaceCardPath,
} from "../editor/lovelace-path";
import type { HuiSection } from "../sections/hui-section";
import "../sections/hui-section-background";
import type { Lovelace } from "../types";
@@ -44,7 +48,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ attribute: false }) public path?: LovelacePath;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public isStrategy = false;
@@ -219,7 +223,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.hass=${this.hass}
.badges=${this.badges}
.lovelace=${this.lovelace}
.path=${this.path!}
.viewIndex=${this.index}
.config=${this._config?.header}
></hui-view-header>
${
@@ -283,7 +287,8 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<hui-section-edit-mode
.hass=${this.hass}
.lovelace=${this.lovelace}
.path=${[...this.path!, "sections", idx]}
.index=${idx}
.viewIndex=${this.index}
>
${this._renderSection(
section,
@@ -349,7 +354,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.hass=${this.hass}
.badges=${this.badges}
.lovelace=${this.lovelace}
.path=${this.path!}
.viewIndex=${this.index}
.config=${this._config.sidebar}
@sidebar-visibility-changed=${
this._handleSidebarVisibilityChanged
@@ -362,7 +367,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
<hui-view-footer
.hass=${this.hass}
.lovelace=${this.lovelace}
.path=${this.path!}
.viewIndex=${this.index}
.config=${this._config?.footer}
></hui-view-footer>
<div class="imported-cards-section">
@@ -389,7 +394,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
.config=${this._importedCardSectionConfig(
this._config.cards
)}
.path=${this.path!}
.viewIndex=${this.index}
preview
import-only
></hui-section>
@@ -403,36 +408,32 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
}
private _handleCardAdded(ev) {
ev.stopPropagation();
// The dropped node only serves as a drop target, the new section renders the card from the config
(ev.detail.item as HTMLElement).remove();
const oldPath = ev.detail.data as LovelacePath;
const config = this.lovelace!.config;
const cardConfig = getAtPath<LovelaceCardConfig>(config, oldPath);
if (!cardConfig) {
return;
}
const { data } = ev.detail;
const oldPath = data as LovelaceCardPath;
const { cardIndex } = parseLovelaceCardPath(oldPath);
const containerPath = getLovelaceContainerPath(oldPath);
const cards = findLovelaceItems(
"cards",
this.lovelace!.config,
containerPath
);
const cardConfig = cards![cardIndex];
const configWithNewSection = addSection(
config,
this.path!,
this.lovelace!.config,
this.index!,
generateDefaultSection(this.hass.localize, cardConfig.type !== "heading") // If we move a heading card, we don't want to include a heading in the new section
);
const sectionsPath = [...this.path!, "sections"];
const newIndex =
getAtPath<unknown[]>(configWithNewSection, sectionsPath)!.length - 1;
const cardCount =
getAtPath<unknown[]>(configWithNewSection, [
...sectionsPath,
newIndex,
"cards",
])?.length ?? 0;
const newConfig = moveAtPath(configWithNewSection, oldPath, [
...sectionsPath,
newIndex,
"cards",
cardCount,
]);
const viewConfig = configWithNewSection.views[
this.index!
] as LovelaceViewConfig;
const newPath = [
this.index!,
viewConfig.sections!.length - 1,
1,
] as LovelaceCardPath;
const newConfig = moveCard(configWithNewSection, oldPath, newPath);
this.lovelace!.saveConfig(newConfig);
}
@@ -470,7 +471,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
private _createSection(): void {
const newConfig = addSection(
this.lovelace!.config,
this.path!,
this.index!,
generateDefaultSection(this.hass.localize, true)
);
this.lovelace!.saveConfig(newConfig);
@@ -480,10 +481,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newConfig = moveAtPath(
const newConfig = moveSection(
this.lovelace!.config,
[...this.path!, "sections", oldIndex],
[...this.path!, "sections", newIndex]
[this.index!, oldIndex],
[this.index!, newIndex]
);
this.lovelace!.saveConfig(newConfig);
}
@@ -11,8 +11,7 @@ import type { HuiBadge } from "../badges/hui-badge";
import "../badges/hui-view-badges";
import type { HuiCard } from "../cards/hui-card";
import type { HuiCardOptions } from "../components/hui-card-options";
import type { LovelacePath } from "../editor/lovelace-path";
import { setAtPath } from "../editor/lovelace-path";
import { replaceCard } from "../editor/config-util";
import type { Lovelace } from "../types";
@customElement("hui-sidebar-view")
@@ -21,7 +20,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ attribute: false }) public path?: LovelacePath;
@property({ type: Number }) public index?: number;
@property({ attribute: false }) public isStrategy = false;
@@ -94,7 +93,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
<hui-view-badges
.badges=${this.badges}
.lovelace=${this.lovelace}
.path=${[...this.path!, "badges"]}
.viewIndex=${this.index}
show-add-label
></hui-view-badges>
<div
@@ -114,7 +113,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
}
private _addCard(): void {
fireEvent(this, "ll-create-card", { path: [...this.path!, "cards"] });
fireEvent(this, "ll-create-card");
}
private _createCards(): void {
@@ -157,7 +156,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
element = document.createElement("hui-card-options");
element.hass = this.hass;
element.lovelace = this.lovelace;
element.path = [...this.path!, "cards", idx];
element.path = [this.index!, idx];
card.preview = true;
const movePositionButton = document.createElement("ha-icon-button");
movePositionButton.slot = "buttons";
@@ -169,7 +168,7 @@ export class SideBarView extends LitElement implements LovelaceViewElement {
movePositionButton.appendChild(moveIcon);
movePositionButton.addEventListener("click", () => {
this.lovelace!.saveConfig(
setAtPath(this.lovelace!.config, [...this.path!, "cards", idx], {
replaceCard(this.lovelace!.config, [this.index!, idx], {
...cardConfig!,
view_layout: {
position:
+44 -11
View File
@@ -9,14 +9,15 @@ import "../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import {
DEFAULT_FOOTER_MAX_WIDTH_PX,
type LovelaceViewConfig,
type LovelaceViewFooterConfig,
} from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { HuiCard } from "../cards/hui-card";
import { computeCardGridSize } from "../common/compute-card-grid-size";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import type { LovelacePath } from "../editor/lovelace-path";
import { setAtPath } from "../editor/lovelace-path";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { replaceView } from "../editor/config-util";
import { showEditViewFooterDialog } from "../editor/view-footer/show-edit-view-footer-dialog";
import type { Lovelace } from "../types";
@@ -30,7 +31,7 @@ export class HuiViewFooter extends LitElement {
@property({ attribute: false }) public config?: LovelaceViewFooterConfig;
@property({ attribute: false }) public path!: LovelacePath;
@property({ attribute: false }) public viewIndex!: number;
public connectedCallback(): void {
super.connectedCallback();
@@ -89,18 +90,24 @@ export class HuiViewFooter extends LitElement {
return element;
}
private get _cardPath(): LovelacePath {
return [...this.path, "footer", "card"];
}
private _addCard() {
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: this._cardPath,
path: [this.viewIndex],
saveCard: (newCardConfig: LovelaceCardConfig) => {
this._saveFooterConfig({ ...this.config, card: newCardConfig });
},
});
}
private _deleteCard(ev) {
ev.stopPropagation();
const newConfig = { ...this.config };
delete newConfig.card;
this._saveFooterConfig(newConfig);
}
private _configure() {
showEditViewFooterDialog(this, {
config: this.config || {},
@@ -110,10 +117,34 @@ export class HuiViewFooter extends LitElement {
});
}
private _editCard(ev) {
ev.stopPropagation();
const cardConfig = this.config?.card;
if (!cardConfig) return;
showEditCardDialog(this, {
cardConfig,
lovelaceConfig: this.lovelace.config,
saveCardConfig: (newCardConfig: LovelaceCardConfig) => {
this._saveFooterConfig({ ...this.config, card: newCardConfig });
},
});
}
private _saveFooterConfig(footerConfig: LovelaceViewFooterConfig) {
this.lovelace.saveConfig(
setAtPath(this.lovelace.config, [...this.path, "footer"], footerConfig)
const viewConfig = this.lovelace.config.views[
this.viewIndex
] as LovelaceViewConfig;
const config = { ...viewConfig, footer: footerConfig };
const updatedConfig = replaceView(
this.hass,
this.lovelace.config,
this.viewIndex,
config
);
this.lovelace.saveConfig(updatedConfig);
}
private _renderCard(card: HuiCard, editMode: boolean) {
@@ -133,8 +164,10 @@ export class HuiViewFooter extends LitElement {
editMode
? html`
<hui-card-edit-mode
@ll-edit-card=${this._editCard}
@ll-delete-card=${this._deleteCard}
.lovelace=${this.lovelace!}
.path=${this._cardPath}
.path=${[0]}
no-duplicate
no-move
>
+50 -13
View File
@@ -8,14 +8,16 @@ import "../../../components/ha-ripple";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceViewHeaderConfig } from "../../../data/lovelace/config/view";
import type {
LovelaceViewConfig,
LovelaceViewHeaderConfig,
} from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { HuiBadge } from "../badges/hui-badge";
import "../badges/hui-view-badges";
import type { HuiCard } from "../cards/hui-card";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import type { LovelacePath } from "../editor/lovelace-path";
import { setAtPath } from "../editor/lovelace-path";
import { replaceView } from "../editor/config-util";
import { showEditViewHeaderDialog } from "../editor/view-header/show-edit-view-header-dialog";
import type { Lovelace } from "../types";
@@ -35,7 +37,7 @@ export class HuiViewHeader extends LitElement {
@property({ attribute: false }) public config?: LovelaceViewHeaderConfig;
@property({ attribute: false }) public path!: LovelacePath;
@property({ attribute: false }) public viewIndex!: number;
private _checkHidden() {
const allHidden =
@@ -127,22 +129,55 @@ export class HuiViewHeader extends LitElement {
cardConfig,
lovelaceConfig: this.lovelace.config,
saveCardConfig: (newCardConfig: LovelaceCardConfig) => {
this.lovelace.saveConfig(
setAtPath(this.lovelace.config, this._cardPath, newCardConfig)
);
const newConfig = { ...this.config };
newConfig.card = newCardConfig;
this._saveHeaderConfig(newConfig);
},
isNew: true,
});
}
private get _cardPath(): LovelacePath {
return [...this.path, "header", "card"];
private _deleteCard(ev) {
ev.stopPropagation();
const newConfig = { ...this.config };
delete newConfig.card;
this._saveHeaderConfig(newConfig);
}
private _editCard(ev) {
ev.stopPropagation();
const cardConfig = this.config!.card;
if (!cardConfig) {
return;
}
showEditCardDialog(this, {
cardConfig,
lovelaceConfig: this.lovelace.config,
saveCardConfig: (newCardConfig: LovelaceCardConfig) => {
const newConfig = { ...this.config };
newConfig.card = newCardConfig;
this._saveHeaderConfig(newConfig);
},
});
}
private _saveHeaderConfig(headerConfig: LovelaceViewHeaderConfig) {
this.lovelace.saveConfig(
setAtPath(this.lovelace.config, [...this.path, "header"], headerConfig)
const viewConfig = this.lovelace.config.views[
this.viewIndex
] as LovelaceViewConfig;
const config = { ...viewConfig };
config.header = headerConfig;
const updatedConfig = replaceView(
this.hass,
this.lovelace.config,
this.viewIndex,
config
);
this.lovelace.saveConfig(updatedConfig);
}
private _configure = () => {
@@ -208,8 +243,10 @@ export class HuiViewHeader extends LitElement {
? card
? html`
<hui-card-edit-mode
@ll-edit-card=${this._editCard}
@ll-delete-card=${this._deleteCard}
.lovelace=${this.lovelace!}
.path=${this._cardPath}
.path=${[0]}
no-duplicate
no-move
>
@@ -240,7 +277,7 @@ export class HuiViewHeader extends LitElement {
<hui-view-badges
.badges=${this.badges}
.lovelace=${this.lovelace!}
.path=${[...this.path, "badges"]}
.viewIndex=${this.viewIndex!}
.showAddLabel=${this.badges.length === 0}
></hui-view-badges>
</div>
@@ -7,7 +7,6 @@ import type { LovelaceViewSidebarConfig } from "../../../data/lovelace/config/vi
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import type { HomeAssistant } from "../../../types";
import "../sections/hui-section";
import type { LovelacePath } from "../editor/lovelace-path";
import type { Lovelace } from "../types";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
@@ -23,7 +22,7 @@ export class HuiViewSidebar extends ConditionalListenerMixin<LovelaceViewSidebar
@property({ attribute: false }) public config?: LovelaceViewSidebarConfig;
@property({ attribute: false }) public path!: LovelacePath;
@property({ attribute: false }) public viewIndex!: number;
private _visible = true;
@@ -64,12 +63,12 @@ export class HuiViewSidebar extends ConditionalListenerMixin<LovelaceViewSidebar
${repeat(
this.config?.sections ?? [],
(section) => this._getSectionKey(section),
(section, idx) => html`
(section) => html`
<hui-section
.config=${section}
.hass=${this.hass}
.preview=${this.lovelace.editMode}
.path=${[...this.path, "sidebar", "sections", idx]}
.viewIndex=${this.viewIndex}
></hui-section>
`
)}
+95 -148
View File
@@ -3,7 +3,6 @@ import { consume } from "@lit/context";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { debounce } from "../../../common/util/debounce";
@@ -33,7 +32,7 @@ import { showCreateBadgeDialog } from "../editor/badge-editor/show-create-badge-
import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { getCardSectionConfig } from "../editor/config-util";
import { addCard, replaceCard } from "../editor/config-util";
import {
type DeleteBadgeParams,
performDeleteBadge,
@@ -42,15 +41,8 @@ import {
type DeleteCardParams,
performDeleteCard,
} from "../editor/delete-card";
import type { LovelacePath } from "../editor/lovelace-path";
import {
appendAtPath,
getAtPath,
getParentPath,
getPathTarget,
normalizeCardPath,
setAtPath,
} from "../editor/lovelace-path";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import { parseLovelaceCardPath } from "../editor/lovelace-path";
import { createErrorSectionConfig } from "../sections/hui-error-section";
import "../sections/hui-section";
import type { HuiSection } from "../sections/hui-section";
@@ -64,13 +56,13 @@ import { getViewType } from "./get-view-type";
declare global {
// for fire event
interface HASSDomEvents {
"ll-create-card": { path: LovelacePath; suggested?: string[] };
"ll-edit-card": { path: LovelacePath };
"ll-create-card": { suggested?: string[] } | undefined;
"ll-edit-card": { path: LovelaceCardPath };
"ll-delete-card": DeleteCardParams;
"ll-duplicate-card": { path: LovelacePath };
"ll-copy-card": { path: LovelacePath };
"ll-create-badge": { path: LovelacePath };
"ll-edit-badge": { path: LovelacePath };
"ll-duplicate-card": { path: LovelaceCardPath };
"ll-copy-card": { path: LovelaceCardPath };
"ll-create-badge": undefined;
"ll-edit-badge": { path: LovelaceCardPath };
"ll-delete-badge": DeleteBadgeParams;
}
interface HTMLElementEventMap {
@@ -116,8 +108,6 @@ export class HUIView extends ReactiveElement {
@consume({ context: childPanelReadyContext })
private _registerChildPanelReady?: RegisterChildPanelReady;
private _path = memoizeOne((index: number): LovelacePath => ["views", index]);
@storage({
key: "dashboardCardClipboard",
state: false,
@@ -126,132 +116,6 @@ export class HUIView extends ReactiveElement {
})
protected _clipboard?: LovelaceCardConfig;
constructor() {
super();
this.addEventListener(
"ll-create-card",
(ev: HASSDomEvent<HASSDomEvents["ll-create-card"]>) => {
// Temporary compatibility: custom view layouts still fire this event without a path
const detail = ev.detail as HASSDomEvents["ll-create-card"] | undefined;
const path = detail?.path ?? [...this._path(this.index), "cards"];
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path,
suggestedCards: detail?.suggested,
});
}
);
this.addEventListener(
"ll-edit-card",
(ev: HASSDomEvent<HASSDomEvents["ll-edit-card"]>) => {
const path = normalizeCardPath(ev.detail.path);
const cardConfig = this._getCardConfig(path);
if (!cardConfig) {
return;
}
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveCardConfig: async (newCardConfig) => {
const newConfig = setAtPath(
this.lovelace.config,
path,
newCardConfig
);
await this.lovelace.saveConfig(newConfig);
},
sectionConfig: getCardSectionConfig(this.lovelace.config, path),
cardConfig,
});
}
);
this.addEventListener(
"ll-delete-card",
(ev: HASSDomEvent<HASSDomEvents["ll-delete-card"]>) => {
if (!this.lovelace) return;
performDeleteCard(this.hass, this.lovelace, {
...ev.detail,
path: normalizeCardPath(ev.detail.path),
});
}
);
this.addEventListener(
"ll-duplicate-card",
(ev: HASSDomEvent<HASSDomEvents["ll-duplicate-card"]>) => {
const path = normalizeCardPath(ev.detail.path);
if (getPathTarget(path) !== "item") {
return;
}
const cardConfig = this._getCardConfig(path);
if (!cardConfig) {
return;
}
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveCardConfig: async (newCardConfig) => {
const cardsPath = getParentPath(path);
const newConfig = appendAtPath(
this.lovelace.config,
cardsPath,
newCardConfig
);
await this.lovelace.saveConfig(newConfig);
},
sectionConfig: getCardSectionConfig(this.lovelace.config, path),
cardConfig,
isNew: true,
});
}
);
this.addEventListener(
"ll-copy-card",
(ev: HASSDomEvent<HASSDomEvents["ll-copy-card"]>) => {
if (!this.lovelace) return;
const cardConfig = this._getCardConfig(
normalizeCardPath(ev.detail.path)
);
if (!cardConfig) {
return;
}
this._clipboard = deepClone(cardConfig);
}
);
this.addEventListener(
"ll-create-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-create-badge"]>) => {
showCreateBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: ev.detail.path,
});
}
);
this.addEventListener(
"ll-edit-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-edit-badge"]>) => {
showEditBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: ev.detail.path,
});
}
);
this.addEventListener(
"ll-delete-badge",
(ev: HASSDomEvent<HASSDomEvents["ll-delete-badge"]>) => {
if (!this.lovelace) return;
performDeleteBadge(this.hass, this.lovelace, ev.detail);
}
);
}
private _getCardConfig(path: LovelacePath): LovelaceCardConfig | undefined {
if (isStrategyView(this.lovelace.config.views[this.index])) {
return undefined;
}
return getAtPath<LovelaceCardConfig>(this.lovelace.config, path);
}
private _createCardElement(cardConfig: LovelaceCardConfig) {
const element = document.createElement("hui-card");
element.hass = this.hass;
@@ -284,6 +148,7 @@ export class HUIView extends ReactiveElement {
element.hass = this.hass;
element.lovelace = this.lovelace;
element.config = sectionConfig;
element.viewIndex = this.index;
element.preview = this.lovelace.editMode;
element.addEventListener(
"ll-rebuild",
@@ -458,7 +323,6 @@ export class HUIView extends ReactiveElement {
this._layoutElement!.narrow = this.narrow;
this._layoutElement!.lovelace = this.lovelace;
this._layoutElement!.index = this.index;
this._layoutElement!.path = this._path(this.index);
this._layoutElement!.cards = this._cards;
this._layoutElement!.badges = this._badges;
this._layoutElement!.sections = this._sections;
@@ -500,6 +364,89 @@ export class HUIView extends ReactiveElement {
},
{ once: true }
);
this._layoutElement.addEventListener("ll-create-card", (ev) => {
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
suggestedCards: ev.detail?.suggested,
});
});
this._layoutElement.addEventListener("ll-edit-card", (ev) => {
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const viewConfig = this.lovelace!.config.views[this.index];
if (isStrategyView(viewConfig)) {
return;
}
const cardConfig = viewConfig.cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveCardConfig: async (newCardConfig) => {
const newConfig = replaceCard(
this.lovelace!.config,
[this.index, cardIndex],
newCardConfig
);
await this.lovelace.saveConfig(newConfig);
},
cardConfig,
});
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
if (!this.lovelace) return;
performDeleteCard(this.hass, this.lovelace, ev.detail);
});
this._layoutElement.addEventListener("ll-create-badge", async () => {
showCreateBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
});
});
this._layoutElement.addEventListener("ll-edit-badge", (ev) => {
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
showEditBadgeDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
badgeIndex: cardIndex,
});
});
this._layoutElement.addEventListener("ll-delete-badge", async (ev) => {
if (!this.lovelace) return;
performDeleteBadge(this.hass, this.lovelace, ev.detail);
});
this._layoutElement.addEventListener("ll-duplicate-card", (ev) => {
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const viewConfig = this.lovelace!.config.views[this.index];
if (isStrategyView(viewConfig)) {
return;
}
const cardConfig = viewConfig.cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveCardConfig: async (newCardConfig) => {
const newConfig = addCard(
this.lovelace!.config,
[this.index],
newCardConfig
);
await this.lovelace!.saveConfig(newConfig);
},
cardConfig,
isNew: true,
});
});
this._layoutElement.addEventListener("ll-copy-card", (ev) => {
if (!this.lovelace) return;
const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const viewConfig = this.lovelace!.config.views[this.index];
if (isStrategyView(viewConfig)) {
return;
}
const cardConfig = viewConfig.cards![cardIndex];
this._clipboard = deepClone(cardConfig);
});
}
private _createBadges(config: LovelaceViewConfig): void {
@@ -535,7 +482,7 @@ export class HUIView extends ReactiveElement {
this._sections = config.sections.map((sectionConfig, index) => {
const element = this.createSectionElement(sectionConfig);
element.path = [...this._path(this.index), "sections", index];
element.index = index;
return element;
});
}
@@ -545,7 +492,7 @@ export class HUIView extends ReactiveElement {
config: LovelaceSectionConfig
): void {
const newSectionEl = this.createSectionElement(config);
newSectionEl.path = sectionElToReplace.path;
newSectionEl.index = sectionElToReplace.index;
if (sectionElToReplace.parentElement) {
sectionElToReplace.parentElement!.replaceChild(
newSectionEl,
+48 -3
View File
@@ -18,7 +18,10 @@ import {
subscribeFrontendUserData,
} from "../data/frontend";
import { forwardHaptic } from "../data/haptics";
import { serviceCallWillDisconnect } from "../data/service";
import {
getServiceCallEntityIds,
serviceCallWillDisconnect,
} from "../data/service";
import {
DateFormat,
FirstWeekday,
@@ -32,7 +35,12 @@ import { preserveUnchangedRecord } from "../common/util/preserve-unchanged-recor
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
import type { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
import type {
Constructor,
HomeAssistant,
ServiceCallRequest,
ServiceCallResponse,
} from "../types";
import {
addBrandsAuth,
clearBrandsTokenRefresh,
@@ -114,7 +122,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
);
}
try {
return (await callService(
const response = (await callService(
conn,
domain,
service,
@@ -122,11 +130,24 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
target,
returnResponse
)) as ServiceCallResponse;
this._reportEntityControlToExternalApp(
domain,
service,
serviceData,
target
);
return response;
} catch (err: any) {
if (
err.error?.code === ERR_CONNECTION_LOST &&
serviceCallWillDisconnect(domain, service, serviceData)
) {
this._reportEntityControlToExternalApp(
domain,
service,
serviceData,
target
);
return { context: { id: "" } };
}
if (this.hass?.debugConnection) {
@@ -405,4 +426,28 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this._updateHass({});
}
}
private _reportEntityControlToExternalApp(
domain: string,
service: string,
serviceData?: ServiceCallRequest["serviceData"],
target?: ServiceCallRequest["target"]
) {
const external = this.hass?.auth.external;
if (!external) {
return;
}
const entityIds = getServiceCallEntityIds(serviceData, target);
if (!entityIds.length) {
return;
}
try {
external.fireMessage({
type: "entity/controlled",
payload: { entity_ids: entityIds, domain, service },
});
} catch (_err) {
// Reporting is best effort and must not fail the service call.
}
}
};
+2 -2
View File
@@ -5866,8 +5866,8 @@
"missing_triggers": "This condition references a trigger that no longer exists. Uncheck it to clear it.",
"duplicate_ids": "Some triggers share the same trigger ID. Avoid reusing a trigger ID to group triggers and select multiple triggers here instead.",
"duplicate_ids_fix": "Fix",
"assign_unique_ids_title": "Assign unique trigger IDs?",
"assign_unique_ids_description": "Each trigger sharing an ID gets a unique one, and conditions using it are updated to match. Templates or actions that use trigger.id directly may need to be updated.",
"assign_unique_ids_title": "Fix duplicate trigger IDs?",
"assign_unique_ids_description": "Triggers that share a used ID get a unique ID, and conditions using it are updated to match. IDs that nothing uses are removed. Templates or actions that use trigger.id directly may need to be updated.",
"description": {
"picker": "Tests if the automation has been triggered by a specific trigger.",
"full": "If triggered by {id}",
+67
View File
@@ -1,11 +1,78 @@
import { describe, it, expect } from "vitest";
import type { HassEntity } from "home-assistant-js-websocket";
import {
localizeStateMessage,
localizeTriggerSource,
parseTriggerSource,
} from "../../src/data/logbook";
import type { HomeAssistant } from "../../src/types";
const fakeLocalize = ((key: string) => `<${key}>`) as any;
const fakeHass = {
localize: fakeLocalize,
formatEntityState: (_stateObj, state: string) => `<state:${state}>`,
} as unknown as HomeAssistant;
const fakeStateObj = {
entity_id: "button.restart",
state: "unknown",
attributes: {},
} as HassEntity;
describe("localizeStateMessage", () => {
it.each(["unknown", "unavailable"])(
"preserves the %s lifecycle state for timestamp entities",
(state) => {
expect(
localizeStateMessage(fakeHass, state, fakeStateObj, "button")
).toBe(`<state:${state}>`);
}
);
it("uses the action label for a timestamp state", () => {
expect(
localizeStateMessage(
fakeHass,
"2026-09-15T11:05:05+00:00",
fakeStateObj,
"button",
{
name: "Restart",
when: Date.parse("2026-09-15T11:05:05+00:00") / 1000,
}
)
).toBe("<ui.components.logbook.messages.pressed>");
});
it("does not treat a restored timestamp with inherited context as a new action", () => {
const restoredState = "2026-09-15T10:55:25+00:00";
expect(
localizeStateMessage(fakeHass, restoredState, fakeStateObj, "button", {
name: "Restart",
when: Date.parse("2026-09-15T11:05:05+00:00") / 1000,
context_domain: "homeassistant",
context_service: "reload_config_entry",
})
).toBe(`<state:${restoredState}>`);
});
it("keeps the action label for a producer timestamp", () => {
expect(
localizeStateMessage(
fakeHass,
"2026-09-15T10:55:25+00:00",
fakeStateObj,
"image",
{
name: "Satellite image",
when: Date.parse("2026-09-15T11:05:05+00:00") / 1000,
}
)
).toBe("<ui.components.logbook.messages.updated>");
});
});
describe("localizeTriggerSource", () => {
it("replaces a known phrase with the bare translation", () => {
expect(localizeTriggerSource(fakeLocalize, "Home Assistant starting")).toBe(
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { getServiceCallEntityIds } from "../../src/data/service";
describe("getServiceCallEntityIds", () => {
it("returns an empty list when no entities are targeted", () => {
expect(getServiceCallEntityIds()).toEqual([]);
expect(getServiceCallEntityIds({ brightness: 50 }, {})).toEqual([]);
expect(getServiceCallEntityIds({}, { area_id: "kitchen" })).toEqual([]);
});
it("reads a single entity from target or service data", () => {
expect(getServiceCallEntityIds({}, { entity_id: "light.a" })).toEqual([
"light.a",
]);
expect(getServiceCallEntityIds({ entity_id: "light.a" })).toEqual([
"light.a",
]);
});
it("prefers the target over the legacy service data entity ids", () => {
expect(
getServiceCallEntityIds(
{ entity_id: ["light.a", "light.b"] },
{ entity_id: ["light.b", "light.c"] }
)
).toEqual(["light.b", "light.c"]);
});
it("deduplicates entity ids", () => {
expect(
getServiceCallEntityIds({}, { entity_id: ["light.a", "light.a"] })
).toEqual(["light.a"]);
});
it("splits comma separated ids and lowercases them like Core does", () => {
expect(
getServiceCallEntityIds({}, { entity_id: "Light.A, light.b ,light.a" })
).toEqual(["light.a", "light.b"]);
});
it("ignores wildcard, malformed, and non-string entity ids", () => {
expect(getServiceCallEntityIds({ entity_id: "all" })).toEqual([]);
expect(
getServiceCallEntityIds(
{},
{ entity_id: ["all", "none", "", "light", 5] as unknown as string[] }
)
).toEqual([]);
});
});
@@ -290,6 +290,25 @@ describe("automation trigger IDs", () => {
]);
});
it("strips unreferenced duplicate IDs instead of generating new ones", () => {
const config: AutomationConfig = {
triggers: [
{ trigger: "state", entity_id: "light.kitchen", id: "motion" },
{ trigger: "time", at: "12:00:00", id: "motion" },
],
conditions: [{ condition: "trigger", id: "" }],
actions: [],
};
const updated = makeDuplicateTriggerIdsUnique(config);
expect(updated.triggers).toEqual([
{ trigger: "state", entity_id: "light.kitchen" },
{ trigger: "time", at: "12:00:00" },
]);
expect(updated.conditions).toBe(config.conditions);
});
it("removes generated trigger IDs that no condition or action references", () => {
const generatedA = `${GENERATED_TRIGGER_ID_PREFIX}aB3x`;
const generatedB = `${GENERATED_TRIGGER_ID_PREFIX}yZ7w`;
@@ -0,0 +1,119 @@
/**
* Protects the energy-distribution home ring from overflowing when hourly
* allocation yields more used_solar/used_battery/used_grid than net used_total
* (https://github.com/home-assistant/frontend/issues/54185).
*/
import { assert, describe, it } from "vitest";
import {
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE,
computeEnergyDistributionHomeCircleArcs,
} from "../../../../../src/panels/lovelace/cards/energy/energy-distribution-home-circle";
const sumDefinedArcs = (
arcs: ReturnType<typeof computeEnergyDistributionHomeCircleArcs>
): number =>
(arcs.solar ?? 0) +
(arcs.battery ?? 0) +
(arcs.lowCarbon ?? 0) +
(arcs.grid ?? 0);
describe("computeEnergyDistributionHomeCircleArcs", () => {
it("sizes arcs from allocated home flows so they fill the circle", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 8.5,
usedBattery: 4.6,
usedGrid: 0.12,
hasSolar: true,
hasGrid: true,
});
assert.approximately(arcs.solar!, CIRCLE * (8.5 / 13.22), 1e-6);
assert.approximately(arcs.battery!, CIRCLE * (4.6 / 13.22), 1e-6);
assert.approximately(arcs.grid!, CIRCLE * (0.12 / 13.22), 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
assert.isAtLeast(arcs.grid!, 0);
});
it("does not paint a full grid ring when used_solar exceeds net home energy", () => {
// Screenshot totals from #54185 with solar and export in different hours:
// used_solar 77.1, used_battery 0, used_grid 0, net used_total 13.22.
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 77.1,
usedBattery: 0,
usedGrid: 0,
hasSolar: true,
hasGrid: true,
});
assert.approximately(arcs.solar!, CIRCLE, 1e-6);
assert.isUndefined(arcs.battery);
assert.equal(arcs.grid, 0);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
});
it("keeps a zero-consumption ring from producing NaN or negative dashes", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 0,
usedBattery: 0,
usedGrid: 0,
hasSolar: true,
hasGrid: true,
});
assert.deepEqual(arcs, {});
});
it("splits grid into low-carbon and high-carbon without exceeding the grid share", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 4,
usedBattery: 0,
usedGrid: 6,
hasSolar: true,
hasGrid: true,
highCarbonConsumption: 2,
});
const ringTotal = 10;
assert.approximately(arcs.solar!, CIRCLE * (4 / ringTotal), 1e-6);
assert.approximately(arcs.lowCarbon!, CIRCLE * (4 / ringTotal), 1e-6);
assert.approximately(arcs.grid!, CIRCLE * (2 / ringTotal), 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
assert.isAtLeast(arcs.lowCarbon!, 0);
assert.isAtLeast(arcs.grid!, 0);
});
it("clamps high-carbon consumption to the grid share", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 5,
usedBattery: 0,
usedGrid: 5,
hasSolar: true,
hasGrid: true,
highCarbonConsumption: 50,
});
assert.equal(arcs.lowCarbon, 0);
assert.approximately(arcs.grid!, CIRCLE * 0.5, 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
});
it("keeps a defined zero low-carbon arc so a grid-only high-carbon ring still renders", () => {
// The card only mounts the home SVG when solar or lowCarbon is defined.
// Omitting 0 would drop battery/grid arcs in a no-solar 100% fossil grid.
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 0,
usedBattery: 4,
usedGrid: 6,
hasSolar: false,
hasGrid: true,
highCarbonConsumption: 6,
});
assert.isUndefined(arcs.solar);
assert.equal(arcs.lowCarbon, 0);
assert.approximately(arcs.battery!, CIRCLE * 0.4, 1e-6);
assert.approximately(arcs.grid!, CIRCLE * 0.6, 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
});
});
@@ -20,11 +20,7 @@ describe("moveCardToContainer", () => {
],
};
const result = moveCardToContainer(
config,
["views", 1, "cards", 0],
["views", 0]
);
const result = moveCardToContainer(config, [1, 0], [0]);
const expected: LovelaceConfig = {
views: [
{
@@ -50,11 +46,7 @@ describe("moveCardToContainer", () => {
],
};
const result = moveCardToContainer(
config,
["views", 1, "cards", 0],
["views", 0]
);
const result = moveCardToContainer(config, [1, 0], [0]);
const expected: LovelaceConfig = {
views: [
{
@@ -81,7 +73,7 @@ describe("moveCardToContainer", () => {
};
const result = () => {
moveCardToContainer(config, ["views", 1, "cards", 0], ["views", 1]);
moveCardToContainer(config, [1, 0], [1]);
};
assert.throws(
result,
@@ -166,7 +158,7 @@ describe("duplicateSection", () => {
],
};
const result = duplicateSection(config, ["views", 0, "sections", 0]);
const result = duplicateSection(config, 0, 0);
const expected: LovelaceConfig = {
views: [
@@ -197,7 +189,7 @@ describe("duplicateSection", () => {
],
};
const result = duplicateSection(config, ["views", 0, "sections", 0]);
const result = duplicateSection(config, 0, 0);
const view = result.views[0] as LovelaceViewConfig;
assert.equal(view.sections!.length, 2);
@@ -219,7 +211,7 @@ describe("duplicateSection", () => {
],
};
const result = duplicateSection(config, ["views", 0, "sections", 0]);
const result = duplicateSection(config, 0, 0);
const resultSections = (result.views[0] as LovelaceViewConfig).sections!;
assert.equal(resultSections.length, 2);
@@ -1,537 +0,0 @@
import { assert, describe, it } from "vitest";
import type { LovelaceCardConfig } from "../../../../src/data/lovelace/config/card";
import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
import type { LovelacePath } from "../../../../src/panels/lovelace/editor/lovelace-path";
import {
appendAtPath,
deleteAtPath,
getAtPath,
getItemKind,
getParentPath,
getViewPath,
insertAtPath,
isAncestorPath,
getPathTarget,
moveAtPath,
normalizeCardPath,
parsePath,
pathEquals,
setAtPath,
stringifyPath,
} from "../../../../src/panels/lovelace/editor/lovelace-path";
const createConfig = (): LovelaceConfig => ({
views: [
{
title: "Home",
badges: ["sensor.badge0", { type: "entity", entity: "sensor.badge1" }],
cards: [{ type: "v0-c0" }, { type: "v0-c1" }],
},
{
title: "Areas",
sections: [
{
type: "grid",
cards: [{ type: "s0-c0" }, { type: "s0-c1" }, { type: "s0-c2" }],
},
{ type: "grid", cards: [{ type: "s1-c0" }] },
{ strategy: { type: "areas" } },
],
},
{ strategy: { type: "original-states" } },
],
});
describe("stringifyPath / parsePath", () => {
it("round trips a nested card path", () => {
const path: LovelacePath = ["views", 0, "sections", 1, "cards", 2];
assert.strictEqual(stringifyPath(path), "views/0/sections/1/cards/2");
assert.deepEqual(parsePath("views/0/sections/1/cards/2"), path);
});
it("round trips a slot path", () => {
const path: LovelacePath = ["views", 0, "header", "card"];
assert.strictEqual(stringifyPath(path), "views/0/header/card");
assert.deepEqual(parsePath("views/0/header/card"), path);
});
it("parses numeric segments as numbers", () => {
const parsed = parsePath("views/12");
assert.deepEqual(parsed, ["views", 12]);
assert.strictEqual(typeof parsed[1], "number");
});
it("handles the root path", () => {
assert.strictEqual(stringifyPath([]), "");
assert.deepEqual(parsePath(""), []);
});
});
describe("path helpers", () => {
it("normalizes legacy card index tuples", () => {
assert.deepEqual(normalizeCardPath([0, 2]), ["views", 0, "cards", 2]);
assert.deepEqual(normalizeCardPath([0, 1, 2]), [
"views",
0,
"sections",
1,
"cards",
2,
]);
assert.deepEqual(normalizeCardPath(["views", 0, "cards", 2]), [
"views",
0,
"cards",
2,
]);
});
it("resolves the item kind from the last string segment", () => {
assert.strictEqual(getItemKind(["views", 0, "header", "card"]), "card");
assert.strictEqual(getItemKind(["views", 0, "cards", 1]), "card");
assert.strictEqual(getItemKind(["views", 0, "badges", 1]), "badge");
assert.strictEqual(getItemKind(["views", 0]), "view");
assert.strictEqual(
getItemKind(["views", 0, "sidebar", "sections", 0]),
"section"
);
assert.strictEqual(getItemKind(["views", 0, "header"]), undefined);
assert.strictEqual(getItemKind([]), undefined);
});
it("detects slot paths", () => {
assert.strictEqual(getPathTarget(["views", 0, "header", "card"]), "slot");
assert.strictEqual(getPathTarget(["views", 0, "cards"]), "list");
assert.strictEqual(getPathTarget(["views", 0, "cards", 1]), "item");
assert.strictEqual(getPathTarget(["views", 0, "header"]), "node");
assert.strictEqual(getPathTarget(["views", 0]), "item");
});
it("returns the parent path", () => {
assert.deepEqual(getParentPath(["views", 0, "cards", 1]), [
"views",
0,
"cards",
]);
assert.deepEqual(getParentPath([]), []);
});
it("returns the view path", () => {
assert.deepEqual(getViewPath(["views", 1, "sections", 0, "cards", 2]), [
"views",
1,
]);
assert.deepEqual(getViewPath(["views", 1]), ["views", 1]);
});
it("compares paths", () => {
assert.strictEqual(pathEquals(["views", 0], ["views", 0]), true);
assert.strictEqual(pathEquals(["views", 0], ["views", 1]), false);
assert.strictEqual(pathEquals(["views", 0], ["views", 0, "cards"]), false);
});
it("does not consider a path its own ancestor", () => {
assert.strictEqual(
isAncestorPath(["views", 0], ["views", 0, "cards", 1]),
true
);
assert.strictEqual(isAncestorPath(["views", 0], ["views", 0]), false);
assert.strictEqual(
isAncestorPath(["views", 0, "cards", 1], ["views", 0]),
false
);
assert.strictEqual(
isAncestorPath(["views", 1], ["views", 0, "cards", 1]),
false
);
});
});
describe("getAtPath", () => {
it("reads a nested card", () => {
const config = createConfig();
assert.deepEqual(
getAtPath(config, ["views", 1, "sections", 0, "cards", 1]),
{
type: "s0-c1",
}
);
});
it("returns undefined for a missing leaf", () => {
const config = createConfig();
assert.strictEqual(getAtPath(config, ["views", 0, "cards", 5]), undefined);
});
it("returns undefined for a missing intermediate", () => {
const config = createConfig();
assert.strictEqual(
getAtPath(config, ["views", 0, "sidebar", "sections", 0]),
undefined
);
});
it("throws when descending through a strategy view", () => {
const config = createConfig();
assert.throws(
() => getAtPath(config, ["views", 2, "cards"]),
"Cannot edit inside a strategy: views/2"
);
});
it("throws when descending through a strategy section", () => {
const config = createConfig();
assert.throws(
() => getAtPath(config, ["views", 1, "sections", 2, "cards"]),
"Cannot edit inside a strategy: views/1/sections/2"
);
});
it("returns a strategy view when it is the final target", () => {
const config = createConfig();
assert.deepEqual(getAtPath(config, ["views", 2]), {
strategy: { type: "original-states" },
});
});
it("throws for any non-empty path on a strategy dashboard", () => {
const config = {
strategy: { type: "original-states" },
} as unknown as LovelaceConfig;
assert.throws(
() => getAtPath(config, ["views"]),
"Cannot edit inside a strategy: "
);
assert.deepEqual(getAtPath(config, []), config);
});
});
describe("setAtPath", () => {
it("replaces a list item", () => {
const config = createConfig();
const result = setAtPath(config, ["views", 0, "cards", 1], {
type: "new-card",
});
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c0" },
{ type: "new-card" },
]);
});
it("replaces a slot", () => {
const config: LovelaceConfig = {
views: [{ header: { card: { type: "old" } } }],
};
const result = setAtPath(config, ["views", 0, "header", "card"], {
type: "new",
});
assert.deepEqual(getAtPath(result, ["views", 0, "header"]), {
card: { type: "new" },
});
});
it("refuses to create a missing list item", () => {
const config = createConfig();
assert.throws(
() => setAtPath(config, ["views", 9, "header", "card"], { type: "x" }),
"Cannot edit missing item: views/9"
);
assert.throws(
() => setAtPath(config, ["views", 0, "cards", 5], { type: "x" }),
"Cannot edit missing item: views/0/cards/5"
);
});
it("creates a missing header object", () => {
const config = createConfig();
const result = setAtPath(config, ["views", 0, "header", "card"], {
type: "heading",
});
assert.deepEqual(getAtPath(result, ["views", 0, "header"]), {
card: { type: "heading" },
});
});
it("does not mutate the input config", () => {
const config = createConfig();
setAtPath(config, ["views", 1, "sections", 0, "cards", 0], {
type: "new-card",
});
assert.deepEqual(config, createConfig());
});
it("keeps untouched branches identical", () => {
const config = createConfig();
const result = setAtPath(config, ["views", 1, "sections", 0, "cards", 0], {
type: "new-card",
});
// Renderers key repeat() on config object identity, so untouched nodes must keep their reference.
assert.strictEqual(
getAtPath(result, ["views", 1, "sections", 0, "cards", 1]),
getAtPath(config, ["views", 1, "sections", 0, "cards", 1])
);
assert.strictEqual(
getAtPath(result, ["views", 1, "sections", 1]),
getAtPath(config, ["views", 1, "sections", 1])
);
assert.strictEqual(
getAtPath(result, ["views", 0]),
getAtPath(config, ["views", 0])
);
});
});
describe("insertAtPath", () => {
it("splices in the middle", () => {
const config = createConfig();
const result = insertAtPath(config, ["views", 0, "cards", 1], {
type: "inserted",
});
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c0" },
{ type: "inserted" },
{ type: "v0-c1" },
]);
});
it("clamps an index beyond the length to the end", () => {
const config = createConfig();
const result = insertAtPath(config, ["views", 0, "cards", 99], {
type: "inserted",
});
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c0" },
{ type: "v0-c1" },
{ type: "inserted" },
]);
});
it("clamps a negative index to the start", () => {
const config = createConfig();
const result = insertAtPath(config, ["views", 0, "cards", -1], {
type: "inserted",
});
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "inserted" },
{ type: "v0-c0" },
{ type: "v0-c1" },
]);
});
it("creates a missing cards list", () => {
const config: LovelaceConfig = { views: [{ title: "Empty" }] };
const result = insertAtPath(config, ["views", 0, "cards", 0], {
type: "first",
});
assert.deepEqual(result, {
views: [{ title: "Empty", cards: [{ type: "first" }] }],
});
});
it("behaves like set when the last segment is a string", () => {
const config = createConfig();
const card: LovelaceCardConfig = { type: "heading" };
assert.deepEqual(
insertAtPath(config, ["views", 0, "header", "card"], card),
setAtPath(config, ["views", 0, "header", "card"], card)
);
});
});
describe("appendAtPath", () => {
it("appends to an existing list", () => {
const config = createConfig();
const result = appendAtPath(config, ["views", 0, "cards"], {
type: "appended",
});
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c0" },
{ type: "v0-c1" },
{ type: "appended" },
]);
});
it("creates a missing collection", () => {
const config: LovelaceConfig = { views: [{ title: "Empty" }] };
const result = appendAtPath(config, ["views", 0, "sidebar", "sections"], {
type: "grid",
});
assert.deepEqual(result, {
views: [{ title: "Empty", sidebar: { sections: [{ type: "grid" }] } }],
});
});
});
describe("deleteAtPath", () => {
it("removes a list item", () => {
const config = createConfig();
const result = deleteAtPath(config, ["views", 0, "cards", 0]);
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c1" },
]);
});
it("keeps an emptied list", () => {
const config = createConfig();
const result = deleteAtPath(config, [
"views",
1,
"sections",
1,
"cards",
0,
]);
assert.deepEqual(getAtPath(result, ["views", 1, "sections", 1]), {
type: "grid",
cards: [],
});
});
it("removes a slot key", () => {
const config: LovelaceConfig = {
views: [{ header: { layout: "start", card: { type: "old" } } }],
};
const result = deleteAtPath(config, ["views", 0, "header", "card"]);
assert.deepEqual(getAtPath(result, ["views", 0, "header"]), {
layout: "start",
});
});
it("returns the same config when the target is missing", () => {
const config = createConfig();
assert.strictEqual(deleteAtPath(config, ["views", 0, "cards", 9]), config);
assert.strictEqual(deleteAtPath(config, []), config);
});
});
describe("moveAtPath", () => {
it("moves forward inside the same list", () => {
const config: LovelaceConfig = {
views: [{ cards: [{ type: "a" }, { type: "b" }, { type: "c" }] }],
};
const result = moveAtPath(
config,
["views", 0, "cards", 0],
["views", 0, "cards", 2]
);
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "b" },
{ type: "c" },
{ type: "a" },
]);
});
it("moves backward inside the same list", () => {
const config: LovelaceConfig = {
views: [{ cards: [{ type: "a" }, { type: "b" }, { type: "c" }] }],
};
const result = moveAtPath(
config,
["views", 0, "cards", 2],
["views", 0, "cards", 0]
);
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "c" },
{ type: "a" },
{ type: "b" },
]);
});
it("moves a card between two sections", () => {
const config = createConfig();
const result = moveAtPath(
config,
["views", 1, "sections", 0, "cards", 0],
["views", 1, "sections", 1, "cards", 0]
);
assert.deepEqual(getAtPath(result, ["views", 1, "sections", 0, "cards"]), [
{ type: "s0-c1" },
{ type: "s0-c2" },
]);
assert.deepEqual(getAtPath(result, ["views", 1, "sections", 1, "cards"]), [
{ type: "s0-c0" },
{ type: "s1-c0" },
]);
});
it("moves a view card into the header slot", () => {
const config = createConfig();
const result = moveAtPath(
config,
["views", 0, "cards", 0],
["views", 0, "header", "card"]
);
assert.deepEqual(getAtPath(result, ["views", 0, "cards"]), [
{ type: "v0-c1" },
]);
assert.deepEqual(getAtPath(result, ["views", 0, "header"]), {
card: { type: "v0-c0" },
});
});
it("targets the shifted sibling when moving into a later sibling", () => {
const config: LovelaceConfig = {
views: [
{
sections: [
{ type: "grid", cards: [{ type: "s0-c0" }] },
{ type: "grid", cards: [{ type: "s1-c0" }] },
{ type: "grid", cards: [{ type: "s2-c0" }] },
],
},
],
};
const result = moveAtPath(
config,
["views", 0, "sections", 0],
["views", 0, "sections", 2, "cards", 0]
);
assert.deepEqual(result, {
views: [
{
sections: [
{ type: "grid", cards: [{ type: "s1-c0" }] },
{
type: "grid",
cards: [
{ type: "grid", cards: [{ type: "s0-c0" }] },
{ type: "s2-c0" },
],
},
],
},
],
});
});
it("returns the same config when source and target are equal", () => {
const config = createConfig();
assert.strictEqual(
moveAtPath(config, ["views", 0, "cards", 0], ["views", 0, "cards", 0]),
config
);
});
it("throws when moving into its own descendant", () => {
const config = createConfig();
assert.throws(
() =>
moveAtPath(
config,
["views", 1, "sections", 0],
["views", 1, "sections", 0, "cards", 0]
),
"Cannot move views/1/sections/0 into itself: views/1/sections/0/cards/0"
);
});
it("throws when the source does not exist", () => {
const config = createConfig();
assert.throws(
() =>
moveAtPath(config, ["views", 0, "cards", 9], ["views", 0, "cards", 0]),
"Nothing to move at views/0/cards/9"
);
});
});