mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-09 19:02:50 +00:00
Compare commits
27 Commits
automation
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e749956eaa | ||
|
|
5b0f0dade5 | ||
|
|
f86d2753f7 | ||
|
|
f3f549737f | ||
|
|
d9929905b5 | ||
|
|
25487e373e | ||
|
|
2ff56d3eb7 | ||
|
|
6c4f7506b5 | ||
|
|
5755aebff6 | ||
|
|
76996ea3cc | ||
|
|
d7d6766f80 | ||
|
|
b632e8e6f8 | ||
|
|
ee4eaaa613 | ||
|
|
395faebd0c | ||
|
|
71b8676e02 | ||
|
|
d54516dd42 | ||
|
|
1a3eef9c4f | ||
|
|
1f2f9e6330 | ||
|
|
1774219f9a | ||
|
|
ac66ad1a32 | ||
|
|
7bb51c746d | ||
|
|
13e32c41e0 | ||
|
|
d89af52e3b | ||
|
|
da6114fa5f | ||
|
|
c144533834 | ||
|
|
e6c6ab93ef | ||
|
|
62df56e5d9 |
54
package.json
54
package.json
@@ -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",
|
||||
|
||||
54
src/common/dom/compare-node-order.ts
Normal file
54
src/common/dom/compare-node-order.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -53,7 +53,7 @@ class StepFlowProgress extends LitElement {
|
||||
text-align: center;
|
||||
}
|
||||
ha-spinner {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
47
test/data/logbook.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user