Compare commits

...

27 Commits

Author SHA1 Message Date
renovate[bot]
e749956eaa Update dependency @rspack/core to v2.0.2 (#51955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 19:23:29 +02:00
renovate[bot]
5b0f0dade5 Update dependency lint-staged to v17.0.2 (#51952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 15:06:17 +02:00
renovate[bot]
f86d2753f7 Update dependency lint-staged to v17 (#51949)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 10:31:16 +00:00
renovate[bot]
f3f549737f Update CodeMirror (#51948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 10:31:01 +00:00
Petar Petrov
d9929905b5 Localize trigger description in trace timeline (#51927) 2026-05-09 12:23:46 +02:00
renovate[bot]
25487e373e Update dependency sinon to v22 (#51945)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 09:20:09 +03:00
karwosts
2ff56d3eb7 Fix heading badge current-entity visibility (#51942) 2026-05-09 09:13:58 +03:00
renovate[bot]
6c4f7506b5 Update dependency tar to v7.5.14 (#51944)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 09:07:54 +03:00
karwosts
5755aebff6 Fix create new person with login (#51939) 2026-05-08 20:39:45 +02:00
dependabot[bot]
76996ea3cc Bump fast-uri from 3.1.0 to 3.1.2 (#51938)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 18:17:23 +00:00
renovate[bot]
d7d6766f80 Update dependency @babel/preset-env to v7.29.5 (#51935)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 20:11:42 +02:00
dependabot[bot]
b632e8e6f8 Bump flatted from 3.4.1 to 3.4.2 (#51937)
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.4.1 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.4.1...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 18:09:10 +00:00
Tom Carpenter
ee4eaaa613 Remove extra padding to right of ha-switch (#51932)
Fix empty padding to right of ha-switch

When the label slot for the ha-switch is empty, the initial margin is still present which causes an odd misalignment on the switches in e,g, the entities card.

To fix this, if the label slot is empty, hide the label to remove the unwanted margin.
2026-05-08 20:08:12 +02:00
renovate[bot]
395faebd0c Update formatjs monorepo (#51936)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 18:07:56 +00:00
Petar Petrov
71b8676e02 Treat unregistered entities as having no entity_category (#51925) 2026-05-08 20:06:31 +02:00
Petar Petrov
d54516dd42 Show external access as disabled for local-only users (#51931) 2026-05-08 19:58:49 +02:00
Aidan Timson
1a3eef9c4f Refactor config flow dialog (#51924)
* Move buttons to standard footers

* Fix negative margin, use space tokens

* Space tokens with tweaks

* Hide form if empty

* Standardise padding

* Only show skip if no devices are assigned

* Use ref instead of queries

* nothing

* Token

* Typing
2026-05-08 17:56:21 +03:00
Aidan Timson
1f2f9e6330 Filter all data points for integration page (#51923)
* Filter all datapoints for integration page (discovery, attention flows)

* Use multitermsearch

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Split

* memoise flows

* memoise entries too

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-08 17:33:22 +03:00
Petar Petrov
1774219f9a Clamp power sources graph usage line to non-negative (#51902) 2026-05-08 13:36:07 +02:00
Marcin Bauer
ac66ad1a32 Improve continue on error tooltip in automation editor (#51926)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 13:34:34 +02:00
Wendelin
7bb51c746d Allow ha-list-items within sub components shadow DOM (#51907)
* Allow ha-list-items within sub components shadow dom.

* fix sort

* Fix start end slots
2026-05-08 09:24:13 +03:00
Tom Carpenter
13e32c41e0 Round bar chart end time to half-hour mark for hourly periods (#51916)
* Don't round bar chart end time for hourly periods

If we do this, it causes the last hour of the energy dashboard bar charts to be cut off. This went unnoticed previously because they were placed at times of xx:00, while now they are times of xx:30.

* Round to 30minute for hourly bars rather than leaving unrounded
This better matches the axes with line charts by cutting off padding into the next day, whilst leaving mid-point bars visible.

* Update tests to account for new behaviour
2026-05-08 08:51:27 +03:00
Tom Carpenter
d89af52e3b Fix type exception in ha-chart-base _updateSankeyRoam() (#51917)
Fix type exception in chart _updateSankeyRoam

When there is no data for some series in the sankey chart, then the series map can contain null entries. This raised an exception as the _updateSankeyRoam tried to access the 'type' property on a null value.

Add an explicit check for null. Using != not !== to also filter undefined in case that ever shows up.
2026-05-08 08:48:24 +03:00
Simon Lamon
da6114fa5f Deduplicate workbox by updating patch (#51919)
Deduplicate by updating patch
2026-05-08 08:47:25 +03:00
renovate[bot]
c144533834 Update workbox monorepo to v7.4.1 (#51918)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 04:54:50 +00:00
renovate[bot]
e6c6ab93ef Update dependency typescript-eslint to v8.59.2 (#51914)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 06:46:15 +02:00
renovate[bot]
62df56e5d9 Update dependency barcode-detector to v3.1.3 (#51913)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 06:46:00 +02:00
34 changed files with 1480 additions and 865 deletions

View File

@@ -28,26 +28,26 @@
"dependencies": {
"@babel/runtime": "7.29.2",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.5",
"@codemirror/lint": "6.9.6",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.41.1",
"@codemirror/view": "6.42.0",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.1",
"@formatjs/intl-displaynames": "7.3.4",
"@formatjs/intl-durationformat": "0.10.7",
"@formatjs/intl-getcanonicallocales": "3.2.5",
"@formatjs/intl-listformat": "8.3.4",
"@formatjs/intl-locale": "5.3.4",
"@formatjs/intl-numberformat": "9.3.4",
"@formatjs/intl-pluralrules": "6.3.4",
"@formatjs/intl-relativetimeformat": "12.3.4",
"@formatjs/intl-datetimeformat": "7.4.2",
"@formatjs/intl-displaynames": "7.3.5",
"@formatjs/intl-durationformat": "0.10.8",
"@formatjs/intl-getcanonicallocales": "3.2.6",
"@formatjs/intl-listformat": "8.3.5",
"@formatjs/intl-locale": "5.3.5",
"@formatjs/intl-numberformat": "9.3.5",
"@formatjs/intl-pluralrules": "6.3.5",
"@formatjs/intl-relativetimeformat": "12.3.5",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -80,7 +80,7 @@
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.1.2",
"barcode-detector": "3.1.3",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -99,7 +99,7 @@
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.3",
"intl-messageformat": "11.2.4",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -121,19 +121,19 @@
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
"workbox-expiration": "7.4.0",
"workbox-precaching": "7.4.0",
"workbox-routing": "7.4.0",
"workbox-strategies": "7.4.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
"workbox-expiration": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.3",
"@babel/preset-env": "7.29.5",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.60.0",
@@ -142,7 +142,7 @@
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.9",
"@rspack/core": "2.0.1",
"@rspack/core": "2.0.2",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -186,7 +186,7 @@
"husky": "9.1.7",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"lint-staged": "16.4.0",
"lint-staged": "17.0.2",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -195,17 +195,17 @@
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.1.2",
"tar": "7.5.13",
"sinon": "22.0.0",
"tar": "7.5.14",
"terser-webpack-plugin": "5.5.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.1",
"typescript-eslint": "8.59.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
},
"resolutions": {
"lit": "3.3.2",

View File

@@ -0,0 +1,54 @@
/**
* Walks up the composed tree (jumping shadow roots → their hosts), returning
* the ancestor chain top-down. Used to compare two nodes that may live in
* different shadow trees — `Node.compareDocumentPosition` only works within a
* single root and returns `DOCUMENT_POSITION_DISCONNECTED` otherwise.
*/
const composedAncestorPath = (node: Node): Node[] => {
const path: Node[] = [];
let cur: Node | null = node;
while (cur) {
path.push(cur);
const parent = cur.parentNode;
if (parent instanceof ShadowRoot) {
cur = parent.host;
} else if (parent) {
cur = parent;
} else {
const root = cur.getRootNode();
cur = root instanceof ShadowRoot ? root.host : null;
}
}
return path.reverse();
};
/**
* Document-order comparator that works across shadow boundaries. Suitable as
* the `Array.prototype.sort` callback for collections of nodes that may live
* in different shadow trees.
*/
export const compareNodeOrder = (a: Node, b: Node): number => {
if (a === b) {
return 0;
}
const pa = composedAncestorPath(a);
const pb = composedAncestorPath(b);
let i = 0;
while (i < pa.length && i < pb.length && pa[i] === pb[i]) {
i++;
}
if (i === 0) {
return 0;
}
if (i === pa.length) {
return -1;
}
if (i === pb.length) {
return 1;
}
// pa[i] and pb[i] are siblings under the LCA, guaranteed same root.
// eslint-disable-next-line no-bitwise
return pa[i].compareDocumentPosition(pb[i]) & Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1;
};

View File

@@ -117,9 +117,6 @@ export const generateEntityFilter = (
}
}
if (entityCategories) {
if (!entity) {
return false;
}
const category = entity?.entity_category || "none";
if (!entityCategories.has(category)) {
return false;

View File

@@ -1110,7 +1110,7 @@ export class HaChartBase extends LitElement {
private _updateSankeyRoam() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
(s: any) => s != null && s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({

View File

@@ -228,6 +228,10 @@ export class HaSwitch extends Switch {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
:host(:empty) slot.label {
display: none;
}
`,
];
}

View File

@@ -1,6 +1,8 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../list/types";
import { HaRowItem } from "./ha-row-item";
/**
@@ -39,6 +41,12 @@ export class HaListItemBase extends HaRowItem {
if (!this.hasAttribute("role")) {
this.setAttribute("role", this.defaultRole);
}
fireEvent(this, "ha-list-item-register", { item: this });
}
public disconnectedCallback(): void {
super.disconnectedCallback();
fireEvent(this, "ha-list-item-unregister", { item: this });
}
/**

View File

@@ -62,26 +62,20 @@ export class HaRowItem extends LitElement {
}
protected _renderInner(): TemplateResult {
const hasStart = this._slotController.test("start");
const hasEnd = this._slotController.test("end");
const hasContent = this._slotController.test("content");
return html`
${hasStart
? html`<div part="start" class="start">
<slot name="start"></slot>
</div>`
: nothing}
<div part="start" class="start">
<slot name="start"></slot>
</div>
<div part="content" class="content">
${hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()}
</div>
${hasEnd
? html`<div part="end" class="end">
<slot name="end"></slot>
</div>`
: nothing}
<div part="end" class="end">
<slot name="end"></slot>
</div>
`;
}
@@ -148,6 +142,10 @@ export class HaRowItem extends LitElement {
align-items: center;
flex: 0 0 auto;
}
:host(:not(:has([slot="start"]))) .start,
:host(:not(:has([slot="end"]))) .end {
display: none;
}
.headline {
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -1,10 +1,12 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemBase } from "../item/ha-list-item-base";
import { compareNodeOrder } from "../../common/dom/compare-node-order";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HaListItemBase } from "../item/ha-list-item-base";
import "./types";
import type { HaListItemRegistrationDetail } from "./types";
/**
* @element ha-list-base
@@ -12,9 +14,11 @@ import "./types";
*
* @summary
* Base list container with roving-tabindex keyboard navigation (ArrowUp/Down,
* Home/End, optional Enter/Space activation, optional wrap-focus). Discovers
* slotted `HaListItemBase` descendants. Subclasses override `hostRole` and/or
* `render()` to specialize.
* Home/End, optional Enter/Space activation, optional wrap-focus). Tracks
* `HaListItemBase` descendants via the `ha-list-item-register` /
* `ha-list-item-unregister` events they fire on connect/disconnect — works
* across any nesting depth and shadow boundaries. Subclasses override
* `hostRole` and/or `render()` to specialize.
*
* @slot - List items (`<ha-list-item-*>`).
*
@@ -68,6 +72,14 @@ export class HaListBase extends LitElement {
Space: this._onActivate,
});
this.addEventListener("focusin", this._onFocusIn);
this.addEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.addEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public disconnectedCallback() {
@@ -75,11 +87,14 @@ export class HaListBase extends LitElement {
this._unbindKeys?.();
this._unbindKeys = undefined;
this.removeEventListener("focusin", this._onFocusIn);
}
public firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
this.updateListItems();
this.removeEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.removeEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public focus(options?: FocusOptions) {
@@ -115,18 +130,14 @@ export class HaListBase extends LitElement {
this._applyActive(focusItem);
}
/**
* Hook called whenever the items array has changed. Subclasses can override
* to layer in extra bookkeeping (e.g. selection state sync).
*/
public updateListItems() {
const next = this._discoverListItems();
const changed =
next.length !== this.items.length ||
next.some((it, i) => it !== this.items[i]);
if (!changed) {
return;
}
this.items = next;
this._recomputeFocusableIndexes();
if (
this._activeItemIndex >= next.length ||
this._activeItemIndex >= this.items.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
) {
@@ -135,6 +146,32 @@ export class HaListBase extends LitElement {
this._applyActive(false);
}
private _onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (this.items.includes(item)) {
return;
}
const next = [...this.items, item];
next.sort(compareNodeOrder);
this.items = next;
this.updateListItems();
};
private _onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (!this.items.includes(item)) {
return;
}
this.items = this.items.filter((it) => it !== item);
this.updateListItems();
};
private _recomputeFocusableIndexes() {
let first = -1;
let last = -1;
@@ -151,27 +188,12 @@ export class HaListBase extends LitElement {
this._hasFocusableItem = first !== -1;
}
public handleSlotChange = () => {
this.updateListItems();
};
protected render(): TemplateResult {
return html`<div part="base" class="base">
<slot @slotchange=${this.handleSlotChange}></slot>
<slot></slot>
</div>`;
}
private _discoverListItems(): HaListItemBase[] {
const slot =
this.renderRoot?.querySelector<HTMLSlotElement>("slot:not([name])");
if (!slot) {
return [];
}
return slot
.assignedElements({ flatten: true })
.filter((el): el is HaListItemBase => el instanceof HaListItemBase);
}
private _isFocusable(index: number): boolean {
const item = this.items[index];
return !!item && item.interactive && !item.disabled;

View File

@@ -31,7 +31,7 @@ export class HaListNav extends HaListBase {
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base" role="list">
<slot @slotchange=${this.handleSlotChange}></slot>
<slot></slot>
</div>
</nav>`;
}

View File

@@ -11,9 +11,15 @@ export interface HaListActivatedDetail {
item: HaListItemBase;
}
export interface HaListItemRegistrationDetail {
item: HaListItemBase;
}
declare global {
interface HASSDomEvents {
"ha-list-selected": HaListSelectedDetail;
"ha-list-activated": HaListActivatedDetail;
"ha-list-item-register": HaListItemRegistrationDetail;
"ha-list-item-unregister": HaListItemRegistrationDetail;
}
}

View File

@@ -18,6 +18,7 @@ import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LogbookEntry } from "../../data/logbook";
import { localizeTriggerDescription } from "../../data/logbook";
import type {
ChooseAction,
IfAction,
@@ -332,7 +333,10 @@ class ActionRenderer {
: "other",
alias: triggerStep.changed_variables.trigger?.alias,
triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
trigger: this.trace.trigger,
trigger: localizeTriggerDescription(
this.hass.localize,
this.trace.trigger
),
time: formatDateTimeWithSeconds(
new Date(triggerStep.timestamp),
this.hass.locale,

View File

@@ -195,6 +195,49 @@ export const localizeTriggerSource = (
return source;
};
// Mapping from a phrase key to the bare-phrase translation key (without the
// "triggered by" prefix), used by localizeTriggerDescription below.
const triggerDescriptionKeys: Record<
TriggerPhraseKeys,
| "numeric_state_of"
| "state_of"
| "event"
| "time"
| "time_pattern"
| "homeassistant_stopping"
| "homeassistant_starting"
> = {
triggered_by_numeric_state_of: "numeric_state_of",
triggered_by_state_of: "state_of",
triggered_by_event: "event",
triggered_by_time_pattern: "time_pattern",
triggered_by_time: "time",
triggered_by_homeassistant_stopping: "homeassistant_stopping",
triggered_by_homeassistant_starting: "homeassistant_starting",
};
// Like localizeTriggerSource, but returns just the bare localized trigger
// description (without the "triggered by" prefix). Used where the surrounding
// template already supplies its own "triggered by" wording.
export const localizeTriggerDescription = (
localize: LocalizeFunc,
source: string
) => {
for (const triggerPhraseKey of Object.keys(
triggerPhrases
) as TriggerPhraseKeys[]) {
const phrase = triggerPhrases[triggerPhraseKey];
if (source.startsWith(phrase)) {
const bareKey = triggerDescriptionKeys[triggerPhraseKey];
return source.replace(
phrase,
`${localize(`ui.components.logbook.${bareKey}`)}`
);
}
}
return source;
};
export const localizeStateMessage = (
hass: HomeAssistant,
localize: LocalizeFunc,

View File

@@ -3,10 +3,13 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-icon-button";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import {
@@ -40,14 +43,33 @@ interface FlowUpdateEvent {
stepPromise?: Promise<DataEntryFlowStep>;
}
interface FlowStepFooterStateChangedEvent {
loading?: boolean;
hasPendingUpdates?: boolean;
}
interface FormStepElement extends HTMLElement {
submit(): Promise<void>;
}
interface AbortStepElement extends HTMLElement {
close(): void;
}
interface CreateEntryStepElement extends HTMLElement {
finish(): Promise<void>;
}
declare global {
// for fire event
interface HASSDomEvents {
"flow-update": FlowUpdateEvent;
"flow-step-footer-state-changed": FlowStepFooterStateChangedEvent;
}
// for add event listener
interface HTMLElementEventMap {
"flow-update": HASSDomEvent<FlowUpdateEvent>;
"flow-step-footer-state-changed": HASSDomEvent<FlowStepFooterStateChangedEvent>;
}
}
@@ -73,6 +95,16 @@ class DataEntryFlowDialog extends LitElement {
@state() private _handler?: string;
@state() private _formStepLoading = false;
@state() private _createEntryHasPendingUpdates = false;
private _formStepRef = createRef<FormStepElement>();
private _abortStepRef = createRef<AbortStepElement>();
private _createEntryStepRef = createRef<CreateEntryStepElement>();
private _unsubDataEntryFlowProgress?: UnsubscribeFunc;
public async showDialog(params: DataEntryFlowDialogParams): Promise<void> {
@@ -366,11 +398,14 @@ class DataEntryFlowDialog extends LitElement {
${this._step.type === "form"
? html`
<step-flow-form
${ref(this._formStepRef)}
autofocus
narrow
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
></step-flow-form>
`
: this._step.type === "external"
@@ -384,6 +419,7 @@ class DataEntryFlowDialog extends LitElement {
: this._step.type === "abort"
? html`
<step-flow-abort
${ref(this._abortStepRef)}
.params=${this._params}
.step=${this._step}
.hass=${this.hass}
@@ -411,11 +447,14 @@ class DataEntryFlowDialog extends LitElement {
`
: html`
<step-flow-create-entry
${ref(this._createEntryStepRef)}
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.navigateToResult=${this._params
.navigateToResult ?? false}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
.devices=${this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
@@ -426,10 +465,95 @@ class DataEntryFlowDialog extends LitElement {
`}
`}
</div>
${this._renderFooter()}
</ha-dialog>
`;
}
private _renderFooter() {
if (!this._step || this._loading) {
return nothing;
}
switch (this._step.type) {
case "form":
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
.loading=${this._formStepLoading}
@click=${this._submitFormStep}
>
${this._params!.flowConfig.renderShowFormStepSubmitButton(
this.hass,
this._step
)}
</ha-button>
</ha-dialog-footer>
`;
case "abort":
return this._step.reason === "missing_credentials"
? nothing
: html`
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._closeAbortStep}
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.close"
)}
</ha-button>
</ha-dialog-footer>
`;
case "external":
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
href=${this._step.url}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
</ha-dialog-footer>
`;
case "create_entry": {
const devices = this._devices(
this._params!.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
this._params!.carryOverDevices
);
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
@click=${this._finishCreateEntryStep}
>
${this.hass.localize(
`ui.panel.config.integrations.config_flow.${
!devices.length ||
this._createEntryHasPendingUpdates ||
devices.some((device) => device.area_id)
? "finish"
: "finish_skip"
}`
)}
</ha-button>
</ha-dialog-footer>
`;
}
default:
return nothing;
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("flow-update", (ev) => {
@@ -479,6 +603,8 @@ class DataEntryFlowDialog extends LitElement {
}
this._step = undefined;
this._formStepLoading = false;
this._createEntryHasPendingUpdates = false;
await this.updateComplete;
this._step = _step;
if (
@@ -562,20 +688,36 @@ class DataEntryFlowDialog extends LitElement {
}
await this.updateComplete;
(
this.renderRoot.querySelector(
"step-flow-form[autofocus]"
) as HTMLElement | null
)?.focus();
this._formStepRef.value?.focus();
};
private _handleFooterStateChanged = (
ev: HASSDomEvent<FlowStepFooterStateChangedEvent>
) => {
if (ev.detail.loading !== undefined) {
this._formStepLoading = ev.detail.loading;
}
if (ev.detail.hasPendingUpdates !== undefined) {
this._createEntryHasPendingUpdates = ev.detail.hasPendingUpdates;
}
};
private _submitFormStep = () => {
this._formStepRef.value?.submit();
};
private _closeAbortStep = () => {
this._abortStepRef.value?.close();
};
private _finishCreateEntryStep = () => {
this._createEntryStepRef.value?.finish();
};
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.dialog-title {
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -8,7 +8,6 @@ import type { HomeAssistant } from "../../types";
import { showConfigFlowDialog } from "./show-dialog-config-flow";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import "../../components/ha-button";
@customElement("step-flow-abort")
class StepFlowAbort extends LitElement {
@@ -37,13 +36,6 @@ class StepFlowAbort extends LitElement {
<div class="content">
${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
</div>
<div class="buttons">
<ha-button appearance="plain" @click=${this._flowDone}
>${this.hass.localize(
"ui.panel.config.integrations.config_flow.close"
)}</ha-button
>
</div>
`;
}
@@ -68,6 +60,10 @@ class StepFlowAbort extends LitElement {
fireEvent(this, "flow-update", { step: undefined });
}
public close(): void {
this._flowDone();
}
static get styles(): CSSResultGroup {
return configFlowContentStyles;
}

View File

@@ -10,7 +10,6 @@ import {
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import "../../components/ha-area-picker";
import "../../components/ha-button";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
@@ -31,6 +30,10 @@ import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voi
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
interface DeviceTarget {
device: string;
}
@customElement("step-flow-create-entry")
class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig;
@@ -192,20 +195,18 @@ class StepFlowCreateEntry extends LitElement {
</div>
`}
</div>
<div class="buttons">
<ha-button @click=${this._flowDone}
>${localize(
`ui.panel.config.integrations.config_flow.${
!this.devices.length || Object.keys(this._deviceUpdate).length
? "finish"
: "finish_skip"
}`
)}</ha-button
>
</div>
`;
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_deviceUpdate")) {
fireEvent(this, "flow-step-footer-state-changed", {
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
});
}
}
private async _loadDomains() {
const entries = await getConfigEntries(this.hass);
this._domains = Object.fromEntries(
@@ -224,18 +225,20 @@ class StepFlowCreateEntry extends LitElement {
return updateDeviceRegistryEntry(this.hass, deviceId, {
name_by_user: update.name,
area_id: update.area,
}).catch((err: any) => {
}).catch((err: unknown) => {
const message =
err instanceof Error ? err.message : "Unknown error";
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_device",
{ error: err.message }
{ error: message }
),
});
});
}
);
await Promise.allSettled(deviceUpdates);
const entityUpdates: Promise<any>[] = [];
const entityUpdates: Promise<unknown>[] = [];
const entityIds: string[] = [];
renamedDevices.forEach((deviceId) => {
const entities = this._deviceEntities(
@@ -281,8 +284,15 @@ class StepFlowCreateEntry extends LitElement {
}
}
public finish(): Promise<void> {
return this._flowDone();
}
private async _areaPicked(ev: ValueChangedEvent<string>) {
const picker = ev.currentTarget as any;
const picker = ev.currentTarget as DeviceTarget | null;
if (!picker) {
return;
}
const device = picker.device;
const area = ev.detail.value;
@@ -294,8 +304,11 @@ class StepFlowCreateEntry extends LitElement {
}
private _deviceNameChanged(ev: InputEvent): void {
const picker = ev.currentTarget as HaInput;
const device = (picker as any).device;
const picker = ev.currentTarget as (HaInput & DeviceTarget) | null;
if (!picker) {
return;
}
const device = picker.device;
const name = picker.value;
if (!(device in this._deviceUpdate)) {
@@ -311,22 +324,13 @@ class StepFlowCreateEntry extends LitElement {
css`
.devices {
display: flex;
margin: -4px;
max-height: 600px;
overflow-y: auto;
gap: var(--ha-space-2);
flex-direction: column;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.devices {
/* header - margin content - footer */
max-height: calc(100vh - 52px - 20px - 52px);
}
}
.device {
border: 1px solid var(--divider-color);
padding: 6px;
border-radius: var(--ha-border-radius-sm);
margin: 4px;
display: inline-block;
}
.device-info {
@@ -352,11 +356,6 @@ class StepFlowCreateEntry extends LitElement {
ha-input {
margin: var(--ha-space-2) 0;
}
.buttons > *:last-child {
margin-left: auto;
margin-inline-start: auto;
margin-inline-end: initial;
}
.error {
color: var(--error-color);
}

View File

@@ -1,11 +1,10 @@
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import "../../components/ha-button";
@customElement("step-flow-external")
class StepFlowExternal extends LitElement {
@@ -16,18 +15,9 @@ class StepFlowExternal extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepExternal;
protected render(): TemplateResult {
const localize = this.hass.localize;
return html`
<div class="content">
${this.flowConfig.renderExternalStepDescription(this.hass, this.step)}
<div class="open-button">
<ha-button href=${this.step.url} target="_blank" rel="noreferrer">
${localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
</div>
</div>
`;
}
@@ -38,18 +28,7 @@ class StepFlowExternal extends LitElement {
}
static get styles(): CSSResultGroup {
return [
configFlowContentStyles,
css`
.open-button {
text-align: center;
padding: 24px 0;
}
.open-button a {
text-decoration: none;
}
`,
];
return [configFlowContentStyles];
}
}

View File

@@ -1,11 +1,11 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import "../../components/ha-button";
import "../../components/ha-alert";
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
import "../../components/ha-form/ha-form";
@@ -19,7 +19,7 @@ import { autocompleteLoginFields } from "../../data/auth";
import type { DataEntryFlowStepForm } from "../../data/data_entry_flow";
import { previewModule } from "../../data/preview";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
@@ -47,6 +47,8 @@ class StepFlowForm extends LitElement {
private _errors?: Record<string, string>;
private _formRef = createRef<HTMLElementTagNameMap["ha-form"]>();
static shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
@@ -88,24 +90,27 @@ class StepFlowForm extends LitElement {
${this.flowConfig.renderShowFormStepDescription(this.hass, this.step)}
${this._errorMsg
? html`<ha-alert alert-type="error">${this._errorMsg}</ha-alert>`
: ""}
<ha-form
?autofocus=${this.autoFocus}
.hass=${this.hass}
.narrow=${this.narrow}
.data=${stepData}
.disabled=${this._loading}
@value-changed=${this._stepDataChanged}
.schema=${autocompleteLoginFields(
this.handleReadOnlyFields(step.data_schema)
)}
.error=${this._errors}
.computeLabel=${this._labelCallback}
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
></ha-form>
: nothing}
${step.data_schema.length
? html`<ha-form
${ref(this._formRef)}
?autofocus=${this.autoFocus}
.hass=${this.hass}
.narrow=${this.narrow}
.data=${stepData}
.disabled=${this._loading}
@value-changed=${this._stepDataChanged}
.schema=${autocompleteLoginFields(
this.handleReadOnlyFields(step.data_schema)
)}
.error=${this._errors}
.computeLabel=${this._labelCallback}
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
></ha-form>`
: nothing}
</div>
${step.preview
? html`<div class="preview" @set-flow-errors=${this._setError}>
@@ -125,14 +130,6 @@ class StepFlowForm extends LitElement {
})}
</div>`
: nothing}
<div class="buttons">
<ha-button @click=${this._submitStep} .loading=${this._loading}>
${this.flowConfig.renderShowFormStepSubmitButton(
this.hass,
this.step
)}
</ha-button>
</div>
`;
}
@@ -145,8 +142,17 @@ class StepFlowForm extends LitElement {
this.addEventListener("keydown", this._handleKeyDown);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_loading")) {
fireEvent(this, "flow-step-footer-state-changed", {
loading: this._loading,
});
}
}
public override focus(_options?: FocusOptions): void {
this.renderRoot.querySelector("ha-form")?.focus();
this._formRef.value?.focus();
}
protected willUpdate(changedProps: PropertyValues): void {
@@ -229,13 +235,15 @@ class StepFlowForm extends LitElement {
const flowId = this.step.flow_id;
const toSendData = {};
const toSendData: Record<string, unknown> = {};
Object.keys(stepData).forEach((key) => {
const value = stepData[key];
const isEmpty = [undefined, ""].includes(value);
const field = this.step.data_schema?.find((f) => f.name === key);
const selector = (field as HaFormSelector)?.selector ?? {};
const read_only = (Object.values(selector)[0] as any)?.read_only;
const read_only = (
Object.values(selector)[0] as { read_only?: boolean } | null | undefined
)?.read_only;
if (!isEmpty && !read_only) {
toSendData[key] = value;
}
@@ -277,7 +285,13 @@ class StepFlowForm extends LitElement {
}
}
private _stepDataChanged(ev: CustomEvent): void {
public submit(): Promise<void> {
return this._submitStep();
}
private _stepDataChanged(
ev: ValueChangedEvent<Record<string, unknown>>
): void {
this._stepData = ev.detail.value;
}
@@ -321,13 +335,9 @@ class StepFlowForm extends LitElement {
ha-alert,
ha-form {
margin-top: 24px;
margin-top: var(--ha-space-6);
display: block;
}
.buttons {
padding: 16px;
}
`,
];
}

View File

@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-spinner";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
@@ -28,7 +28,7 @@ class StepFlowLoading extends LitElement {
return html`
<div class="content">
<ha-spinner size="large"></ha-spinner>
${description ? html`<div>${description}</div>` : ""}
${description ? html`<div>${description}</div>` : nothing}
</div>
`;
}
@@ -40,7 +40,7 @@ class StepFlowLoading extends LitElement {
text-align: center;
}
ha-spinner {
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
`;
}

View File

@@ -78,7 +78,7 @@ class StepFlowMenu extends LitElement {
);
return html`
${description ? html`<div class="content">${description}</div>` : ""}
${description ? html`<div class="content">${description}</div>` : nothing}
<div class="options">
${options.map(
(option) => html`
@@ -119,17 +119,17 @@ class StepFlowMenu extends LitElement {
configFlowContentStyles,
css`
.options {
margin-top: 20px;
margin-bottom: 16px;
margin-top: var(--ha-space-5);
margin-bottom: var(--ha-space-4);
}
.content {
padding-bottom: 16px;
padding-bottom: var(--ha-space-4);
}
.content + .options {
margin-top: 8px;
margin-top: var(--ha-space-2);
}
ha-list-item {
--mdc-list-side-padding: 24px;
--mdc-list-side-padding: var(--ha-space-6);
}
`,
];

View File

@@ -53,7 +53,7 @@ class StepFlowProgress extends LitElement {
text-align: center;
}
ha-spinner {
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
`,
];

View File

@@ -2,12 +2,9 @@ import { css } from "lit";
export const configFlowContentStyles = css`
h2 {
margin: 24px 38px 0 0;
margin: var(--ha-space-6) var(--ha-space-10) 0 0;
margin-inline-start: 0px;
margin-inline-end: 38px;
padding: 0 24px;
padding-inline-start: 24px;
padding-inline-end: 24px;
margin-inline-end: var(--ha-space-10);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-family: var(
@@ -28,19 +25,8 @@ export const configFlowContentStyles = css`
.content,
.preview {
margin-top: 20px;
padding: 0 24px;
margin-top: var(--ha-space-5);
}
.buttons {
position: relative;
padding: 16px;
margin: 8px 0 0;
color: var(--primary-color);
display: flex;
justify-content: flex-end;
}
ha-markdown {
overflow-wrap: break-word;
}

View File

@@ -331,7 +331,7 @@ export default class HaAutomationActionRow extends LitElement {
></ha-svg-icon>
<ha-tooltip for="svg-icon">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.continue_on_error"
"ui.panel.config.automation.editor.actions.continue_on_error_description"
)}
</ha-tooltip>`
: nothing}
@@ -1148,7 +1148,7 @@ export default class HaAutomationActionRow extends LitElement {
overflowStyles,
css`
ha-svg-icon.arrow-right {
--icon-primary-color: var(--ha-color-fill-neutral-normal-resting);
--icon-primary-color: var(--ha-color-fill-neutral-loud-resting);
}
ha-svg-icon#svg-icon {
--icon-primary-color: var(--ha-color-fill-neutral-loud-active);

View File

@@ -224,7 +224,7 @@ class HaConfigSystemNavigation extends LitElement {
return;
}
}
this._externalAccess = this.hass.config.external_url !== null;
this._externalAccess = this.hass.config.external_url != null;
}
private async _fetchLabFeatures() {

View File

@@ -291,39 +291,19 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this.configEntriesInProgress
);
const discoveryFlows = configEntriesInProgress
.filter((flow) => !ATTENTION_SOURCES.includes(flow.context.source))
.sort((a, b) =>
caseInsensitiveStringCompare(
a.localized_title || "zzz",
b.localized_title || "zzz",
this.hass.locale.language
)
);
const attentionFlows = configEntriesInProgress.filter((flow) =>
ATTENTION_SOURCES.includes(flow.context.source)
const discoveryFlows = this._discoveryFlows(
configEntriesInProgress,
this.hass.locale.language
);
const attentionEntries = configEntries.filter((entry) =>
ERROR_STATES.includes(entry.state)
);
const attentionFlows = this._attentionFlows(configEntriesInProgress);
const normalEntries = configEntries
.filter(
(entry) =>
entry.source !== "ignore" && !ERROR_STATES.includes(entry.state)
)
.sort((a, b) => {
if (Boolean(a.disabled_by) !== Boolean(b.disabled_by)) {
return a.disabled_by ? 1 : -1;
}
return caseInsensitiveStringCompare(
a.title,
b.title,
this.hass.locale.language
);
});
const attentionEntries = this._attentionEntries(configEntries);
const normalEntries = this._normalEntries(
configEntries,
this.hass.locale.language
);
const normalData = this._buildNormalEntryData(
normalEntries,
@@ -344,6 +324,14 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this._filter,
this.hass.areas
);
const filteredDiscoveryData = this._filterDiscoveryTree(
discoveryFlows,
this._filter
);
const filteredAttentionFlows = this._filterAttentionFlowTree(
attentionFlows,
this._filter
);
const filteredAttentionData = this._filterAttentionTree(
attentionData,
this._filter,
@@ -692,7 +680,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</ha-alert>
</div>`
: nothing}
${discoveryFlows.length
${filteredDiscoveryData.length
? html`
<div class="section">
<h3 class="section-header">
@@ -701,7 +689,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
)}
</h3>
<ha-md-list class="discovered">
${discoveryFlows.map(
${filteredDiscoveryData.map(
(flow) =>
html`<ha-md-list-item class="discovered">
${flow.localized_title}
@@ -720,7 +708,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</div>
`
: nothing}
${attentionFlows.length || filteredAttentionData.length
${filteredAttentionFlows.length || filteredAttentionData.length
? html`
<div class="section">
<h3 class="section-header">
@@ -728,9 +716,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
`ui.panel.config.integrations.integration_page.attention_entries`
)}
</h3>
${attentionFlows.length
${filteredAttentionFlows.length
? html`<ha-md-list class="attention">
${attentionFlows.map((flow) => {
${filteredAttentionFlows.map((flow) => {
const attention = ATTENTION_SOURCES.includes(
flow.context.source
);
@@ -994,6 +982,49 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
private _buildAttentionEntryData = memoizeOne(this._buildEntryData);
private _normalEntries = memoizeOne(
(
data: ConfigEntry[],
language: HomeAssistant["locale"]["language"]
): ConfigEntry[] =>
data
.filter(
(entry) =>
entry.source !== "ignore" && !ERROR_STATES.includes(entry.state)
)
.sort((a, b) => {
if (Boolean(a.disabled_by) !== Boolean(b.disabled_by)) {
return a.disabled_by ? 1 : -1;
}
return caseInsensitiveStringCompare(a.title, b.title, language);
})
);
private _attentionEntries = memoizeOne((data: ConfigEntry[]): ConfigEntry[] =>
data.filter((entry) => ERROR_STATES.includes(entry.state))
);
private _discoveryFlows = memoizeOne(
(
data: DataEntryFlowProgressExtended[],
language: HomeAssistant["locale"]["language"]
): DataEntryFlowProgressExtended[] =>
data
.filter((flow) => !ATTENTION_SOURCES.includes(flow.context.source))
.sort((a, b) =>
caseInsensitiveStringCompare(
a.localized_title || "zzz",
b.localized_title || "zzz",
language
)
)
);
private _attentionFlows = memoizeOne(
(data: DataEntryFlowProgressExtended[]): DataEntryFlowProgressExtended[] =>
data.filter((flow) => ATTENTION_SOURCES.includes(flow.context.source))
);
private _filterTree = memoizeOne(
(
data: ConfigEntryData[],
@@ -1089,6 +1120,35 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this._filterTree(data, filter, areas)
);
private _filterFlowTree = (
data: DataEntryFlowProgressExtended[],
filter: string
): DataEntryFlowProgressExtended[] => {
if (!filter) {
return data;
}
const TITLE_KEYS = ["localized_title"];
return multiTermSearch(
data.filter((item) => item.localized_title),
filter,
TITLE_KEYS,
undefined,
{ keys: TITLE_KEYS }
);
};
private _filterDiscoveryTree = memoizeOne(
(data: DataEntryFlowProgressExtended[], filter: string) =>
this._filterFlowTree(data, filter)
);
private _filterAttentionFlowTree = memoizeOne(
(data: DataEntryFlowProgressExtended[], filter: string) =>
this._filterFlowTree(data, filter)
);
private _filterAttentionTree = memoizeOne(
(data: ConfigEntryData[], filter: string, areas: HomeAssistant["areas"]) =>
this._filterTree(data, filter, areas)

View File

@@ -178,7 +178,7 @@ class DialogPersonDetail extends LitElement implements HassDialog {
<ha-switch
slot="end"
@change=${this._allowLoginChanged}
.disabled=${this._user &&
?disabled=${this._user &&
(this._user.id === this.hass.user?.id ||
this._user.system_generated ||
this._user.is_owner)}

View File

@@ -60,17 +60,21 @@ export function getSuggestedMax(
noRounding: boolean
): Date {
// Maximum period depends on whether plotting a line chart or discrete bars.
// - For line charts we must be plotting all the way to end of a given period,
// otherwise we cut off the last period of data.
// - For bar charts we need to round down to the start of the final bars period
// to avoid unnecessary padding of the chart.
// - For line charts use noRounding true as we must always plot all the way
// to end of a given period, otherwise we cut off the last period of data.
// - For bar charts with 5minute intervals, leave the full time range
// to ensure we don't cut off any bars
// - For bar charts of hourly intervals, round to half-period to avoid excess
// padding but not cut off the final bar if placed mid interval.
// - For bar charts with whole numbers of days we need to round down to the
// start of the final bars period to avoid unnecessary padding of the chart.
let suggestedMax = new Date(end);
if (noRounding || period === "5minute") {
return suggestedMax;
}
suggestedMax.setMinutes(0, 0, 0);
if (period === "hour") {
suggestedMax.setMinutes(30, 0, 0);
return suggestedMax;
}
// Sometimes around DST we get a time of 0:59 instead of 23:59 as expected.
@@ -78,7 +82,7 @@ export function getSuggestedMax(
if (suggestedMax.getHours() === 0) {
suggestedMax = subHours(suggestedMax, 1);
}
suggestedMax.setHours(0);
suggestedMax.setHours(0, 0, 0, 0);
if (period === "day" || period === "week") {
return suggestedMax;
}

View File

@@ -315,14 +315,18 @@ export class HuiPowerSourcesGraphCard
typeof item === "object" && "value" in item!
? item.value![0]
: item![0];
usageData[i] = [x, 0];
let sum = 0;
this._chartData.forEach((dataset) => {
const y =
typeof dataset.data![i] === "object" && "value" in dataset.data![i]!
? dataset.data![i].value![1]
: dataset.data![i]![1];
usageData[i]![1] += y as number;
sum += y as number;
});
// Consumption can't be negative; sources unaccounted for in the
// configuration (e.g. solar exporting to grid without a configured
// solar source) would otherwise drag the usage line below zero.
usageData[i] = [x, Math.max(0, sum)];
});
this._chartData.push({
...commonSeriesOptions,

View File

@@ -9,6 +9,7 @@ import { checkConditionsMet } from "../common/validate-condition";
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
import type { LovelaceHeadingBadge } from "../types";
import type { LovelaceHeadingBadgeConfig } from "./types";
import { getConfigEntityId } from "../common/get-config-entity-id";
declare global {
interface HASSDomEvents {
@@ -95,6 +96,13 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("config")) {
this._conditionContext = {
...this._conditionContext,
entity_id: this.config ? getConfigEntityId(this.config) : undefined,
};
}
if (!this._element) {
this.load();
}

View File

@@ -676,6 +676,13 @@
"triggered_by_time_pattern": "triggered by time pattern",
"triggered_by_homeassistant_stopping": "triggered by Home Assistant stopping",
"triggered_by_homeassistant_starting": "triggered by Home Assistant starting",
"numeric_state_of": "numeric state of",
"state_of": "state of",
"event": "event",
"time": "time",
"time_pattern": "time pattern",
"homeassistant_stopping": "Home Assistant stopping",
"homeassistant_starting": "Home Assistant starting",
"show_trace": "[%key:ui::panel::config::automation::editor::show_trace%]",
"retrieval_error": "Could not load activity",
"not_loaded": "[%key:ui::dialogs::helper_settings::platform_not_loaded%]",
@@ -5586,6 +5593,7 @@
"unsupported_action": "No visual editor support for this action",
"type_select": "Action type",
"continue_on_error": "Continue on error",
"continue_on_error_description": "If this action fails, the next action will still run.",
"action": "Action",
"copied_to_clipboard": "Action copied to clipboard",
"cut_to_clipboard": "Action cut to clipboard",

View File

@@ -55,6 +55,11 @@ const mockHass: HomeAssistant = {
state: "off",
attributes: { device_class: "light" },
},
"binary_sensor.unregistered_battery": {
entity_id: "binary_sensor.unregistered_battery",
state: "off",
attributes: { device_class: "battery" },
},
} as any,
entities: {
"light.living_room": {
@@ -302,6 +307,20 @@ describe("generateEntityFilter", () => {
expect(filter("light.living_room")).toBe(true);
expect(filter("sensor.humidity")).toBe(false);
});
it("should treat entities without a registry entry as having no category", () => {
const noneFilter = generateEntityFilter(mockHass, {
entity_category: "none",
});
const diagnosticFilter = generateEntityFilter(mockHass, {
entity_category: "diagnostic",
});
expect(noneFilter("binary_sensor.unregistered_battery")).toBe(true);
expect(diagnosticFilter("binary_sensor.unregistered_battery")).toBe(
false
);
});
});
describe("label filtering", () => {

47
test/data/logbook.test.ts Normal file
View File

@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import {
localizeTriggerDescription,
localizeTriggerSource,
} from "../../src/data/logbook";
const fakeLocalize = ((key: string) => `<${key}>`) as any;
describe("localizeTriggerSource", () => {
it("replaces a known phrase with the prefixed translation", () => {
expect(localizeTriggerSource(fakeLocalize, "Home Assistant starting")).toBe(
"<ui.components.logbook.triggered_by_homeassistant_starting>"
);
});
it("preserves trailing context after the matched phrase", () => {
expect(
localizeTriggerSource(fakeLocalize, "state of binary_sensor.foo")
).toBe("<ui.components.logbook.triggered_by_state_of> binary_sensor.foo");
});
it("returns the source unchanged when no phrase matches", () => {
expect(localizeTriggerSource(fakeLocalize, "something else")).toBe(
"something else"
);
});
});
describe("localizeTriggerDescription", () => {
it("returns just the bare-phrase translation, without 'triggered by'", () => {
expect(
localizeTriggerDescription(fakeLocalize, "Home Assistant starting")
).toBe("<ui.components.logbook.homeassistant_starting>");
});
it("preserves trailing context after the matched phrase", () => {
expect(
localizeTriggerDescription(fakeLocalize, "state of binary_sensor.foo")
).toBe("<ui.components.logbook.state_of> binary_sensor.foo");
});
it("returns the source unchanged when no phrase matches", () => {
expect(localizeTriggerDescription(fakeLocalize, "something else")).toBe(
"something else"
);
});
});

View File

@@ -31,10 +31,10 @@ describe("getSuggestedMax", () => {
assert.equal(result.getTime(), end.getTime());
});
it("rounds down to start of hour for hour period", () => {
it("rounds down to middle of hour for hour period", () => {
const end = new Date("2024-03-15T14:37:22.000");
const result = getSuggestedMax("hour", end, false);
assert.equal(result.getMinutes(), 0);
assert.equal(result.getMinutes(), 30);
assert.equal(result.getSeconds(), 0);
assert.equal(result.getMilliseconds(), 0);
assert.equal(result.getHours(), 14);

1415
yarn.lock

File diff suppressed because it is too large Load Diff