Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba333b7283 | ||
|
|
aee678132a | ||
|
|
9287002e45 | ||
|
|
dcf64f43bd | ||
|
|
f934a72f41 | ||
|
|
71acef882c | ||
|
|
cef7461935 | ||
|
|
25231b3353 | ||
|
|
09ea28a8b0 |
No files matched your search
@@ -1,4 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 408 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 408 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -15,10 +15,6 @@ import {
|
||||
|
||||
const DEBOUNCE_WAIT = 300;
|
||||
|
||||
// A page can be opened with or without a trailing slash
|
||||
const trimSlash = (path: string) =>
|
||||
path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
|
||||
export interface QueryParamsControllerOptions {
|
||||
/** Whether the host keeps its state in the URL. */
|
||||
enabled: () => boolean;
|
||||
@@ -28,12 +24,6 @@ export interface QueryParamsControllerOptions {
|
||||
* the URL with `searchParams()` and apply it, then `write()` the state back.
|
||||
*/
|
||||
navigated: () => void;
|
||||
/**
|
||||
* The path of the host's page, for a page that can first be connected on
|
||||
* a sub-path it sends on to itself, like an add link. Defaults to the path
|
||||
* the host is first connected on.
|
||||
*/
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +57,7 @@ export class QueryParamsController<
|
||||
public hostConnected() {
|
||||
// A cached page is reconnected on the way to another page, before its
|
||||
// router has moved on, so it keeps the page it was first connected on
|
||||
this._path ??= this._options.path ?? currentPath();
|
||||
this._path ??= currentPath();
|
||||
this._connected = true;
|
||||
mainWindow.addEventListener("location-changed", this._navigated);
|
||||
mainWindow.addEventListener("popstate", this._navigated);
|
||||
@@ -137,9 +127,7 @@ export class QueryParamsController<
|
||||
|
||||
private _isActive(): boolean {
|
||||
return (
|
||||
this._connected &&
|
||||
this._options.enabled() &&
|
||||
trimSlash(currentPath()) === trimSlash(this._path ?? "")
|
||||
this._connected && this._options.enabled() && currentPath() === this._path
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,9 +158,7 @@ export class QueryParamsController<
|
||||
const current = mainWindow.location.search;
|
||||
const queryString = updateQueryString(current, values, this._config);
|
||||
if (current !== (queryString ? `?${queryString}` : "")) {
|
||||
// Keep a fragment like #config_entry=, the demo already routes with it
|
||||
const hash = __DEMO__ ? "" : mainWindow.location.hash;
|
||||
replaceCurrentUrl(`${constructUrlCurrentPath(queryString)}${hash}`);
|
||||
replaceCurrentUrl(constructUrlCurrentPath(queryString));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,8 @@ export const automationsLinkFromUrl = (
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
"ha-filter-categories": filter(values("category")),
|
||||
// The category filter picks one category
|
||||
"ha-filter-categories": filter(values("category").slice(0, 1)),
|
||||
"ha-filter-voice-assistants": filter(values("assistants")),
|
||||
"ha-filter-blueprints": filter(values("blueprint")),
|
||||
},
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import {
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
// `status` adds the ignored and disabled integrations to what is shown.
|
||||
export const integrationsQueryParamConfig = {
|
||||
string: ["search"],
|
||||
list: ["status"],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export const integrationQueryParamConfig = {
|
||||
string: ["search"],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type IntegrationsQueryParams = QueryParamValues<
|
||||
typeof integrationsQueryParamConfig
|
||||
>;
|
||||
|
||||
export interface IntegrationsState {
|
||||
search: string;
|
||||
showIgnored: boolean;
|
||||
showDisabled: boolean;
|
||||
}
|
||||
|
||||
export const integrationsQueryParams = (
|
||||
state: IntegrationsState
|
||||
): IntegrationsQueryParams => ({
|
||||
search: state.search,
|
||||
status: [
|
||||
...(state.showIgnored ? ["ignored"] : []),
|
||||
...(state.showDisabled ? ["disabled"] : []),
|
||||
],
|
||||
});
|
||||
|
||||
// What a link says to show, undefined when it has none of the page's params
|
||||
// so what was picked before stays. A link replaces the search and the toggles.
|
||||
export const integrationsLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): IntegrationsState | undefined => {
|
||||
if (!hasQueryParams(searchParams, integrationsQueryParamConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { search, status } = decodeQueryParams(
|
||||
searchParams,
|
||||
integrationsQueryParamConfig
|
||||
);
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
showIgnored: !!status?.includes("ignored"),
|
||||
showDisabled: !!status?.includes("disabled"),
|
||||
};
|
||||
};
|
||||
|
||||
export const integrationSearchFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): string | undefined => {
|
||||
if (!hasQueryParams(searchParams, integrationQueryParamConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
decodeQueryParams(searchParams, integrationQueryParamConfig).search ?? ""
|
||||
);
|
||||
};
|
||||
@@ -107,7 +107,8 @@ export const scenesLinkFromUrl = (
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
"ha-filter-categories": filter(values("category")),
|
||||
// The category filter picks one category
|
||||
"ha-filter-categories": filter(values("category").slice(0, 1)),
|
||||
"ha-filter-voice-assistants": filter(values("assistants")),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -109,7 +109,8 @@ export const scriptsLinkFromUrl = (
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
"ha-filter-categories": filter(values("category")),
|
||||
// The category filter picks one category
|
||||
"ha-filter-categories": filter(values("category").slice(0, 1)),
|
||||
"ha-filter-voice-assistants": filter(values("assistants")),
|
||||
"ha-filter-blueprints": filter(values("blueprint")),
|
||||
},
|
||||
|
||||
@@ -124,7 +124,28 @@ export interface DataTableRowData {
|
||||
|
||||
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
|
||||
|
||||
// Slot for the actions at the end of a group's header, they don't toggle the
|
||||
// group. Put `ha-icon-button`s with a label in it. They get the rows' icon
|
||||
// colour and a smaller size by default, which `--data-table-group-actions-color`,
|
||||
// `--data-table-group-actions-button-size` and
|
||||
// `--data-table-group-actions-icon-size` change for all of them; style a
|
||||
// button itself for its own colour or size.
|
||||
export const GROUP_ACTIONS_SLOT_PREFIX = "group-actions-";
|
||||
|
||||
export const groupActionsSlot = (group: string) =>
|
||||
`${GROUP_ACTIONS_SLOT_PREFIX}${group}`;
|
||||
|
||||
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
|
||||
|
||||
// The group a row's value of the group column puts it in. Rows without a value
|
||||
// share a reserved group, sorted to the bottom.
|
||||
const dataTableGroupKey = (
|
||||
value: string | number | boolean | null | undefined
|
||||
) => {
|
||||
const key = String(value);
|
||||
|
||||
return key === "undefined" ? UNDEFINED_GROUP_KEY : key;
|
||||
};
|
||||
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
|
||||
|
||||
// Default row height, used to fill the viewport with skeleton rows.
|
||||
@@ -209,6 +230,9 @@ export class HaDataTable extends LitElement {
|
||||
@query(".sticky-group-header .group-header")
|
||||
private _stickyGroupHeaderCell?: HTMLElement;
|
||||
|
||||
@query(".sticky-group-header .group-actions")
|
||||
private _stickyGroupActions?: HTMLElement;
|
||||
|
||||
// Group pinned to the top of the scroller while its own header is scrolled out of view
|
||||
@state() private _stickyGroup?: string;
|
||||
|
||||
@@ -480,6 +504,14 @@ export class HaDataTable extends LitElement {
|
||||
)
|
||||
: [];
|
||||
|
||||
// The pinned group header is the same row as its group's header
|
||||
const stickyGroupIndex =
|
||||
this._stickyGroup === undefined
|
||||
? -1
|
||||
: items.findIndex(
|
||||
(item) => item.append && item.groupHeader === this._stickyGroup
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="mdc-data-table">
|
||||
<slot name="header" @slotchange=${this._calcTableHeight}>
|
||||
@@ -720,25 +752,20 @@ export class HaDataTable extends LitElement {
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
class="mdc-data-table__content scroller ha-scrollbar ${classMap(
|
||||
{ grouped: Boolean(this.groupColumn) && !this.autoHeight }
|
||||
)}"
|
||||
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
|
||||
@scroll=${this._saveScrollPos}
|
||||
@visibilityChanged=${this._handleVisibilityChanged}
|
||||
.items=${items}
|
||||
.keyFunction=${this._keyFunction}
|
||||
.renderItem=${renderRow}
|
||||
></lit-virtualizer>
|
||||
${
|
||||
// Rendered before the rows, so the group's actions slot
|
||||
// shows here and not in its scrolled out header row
|
||||
this.groupColumn &&
|
||||
!this.autoHeight &&
|
||||
this._stickyGroup !== undefined
|
||||
? html`<div
|
||||
class="mdc-data-table__row sticky-group-header"
|
||||
aria-hidden="true"
|
||||
role="row"
|
||||
aria-rowindex=${ifDefined(
|
||||
stickyGroupIndex === -1
|
||||
? undefined
|
||||
: stickyGroupIndex + (this.loadError ? 3 : 2)
|
||||
)}
|
||||
.group=${this._stickyGroup}
|
||||
@click=${this._collapseStickyGroup}
|
||||
>
|
||||
@@ -751,6 +778,18 @@ export class HaDataTable extends LitElement {
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
class="mdc-data-table__content scroller ha-scrollbar ${classMap(
|
||||
{ grouped: Boolean(this.groupColumn) && !this.autoHeight }
|
||||
)}"
|
||||
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
|
||||
@scroll=${this._saveScrollPos}
|
||||
@visibilityChanged=${this._handleVisibilityChanged}
|
||||
.items=${items}
|
||||
.keyFunction=${this._keyFunction}
|
||||
.renderItem=${renderRow}
|
||||
></lit-virtualizer>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
@@ -1025,12 +1064,10 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
if (groupColumn) {
|
||||
const isGroupSortColumn = sortColumn === groupColumn;
|
||||
const grouped = groupBy(items, (item) => item[groupColumn]);
|
||||
if (grouped.undefined) {
|
||||
// make sure ungrouped items are at the bottom
|
||||
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
|
||||
delete grouped.undefined;
|
||||
}
|
||||
|
||||
const grouped = groupBy(items, (item) =>
|
||||
dataTableGroupKey(item[groupColumn])
|
||||
);
|
||||
const sortedEntries: [string, DataTableRowData[]][] = Object.keys(
|
||||
grouped
|
||||
)
|
||||
@@ -1107,9 +1144,11 @@ export class HaDataTable extends LitElement {
|
||||
localize: LocalizeFunc | undefined,
|
||||
sticky = false
|
||||
) {
|
||||
// The sticky copy is hidden from assistive technology, so its button is
|
||||
// made inert. It handles clicks on its whole row instead, as its cell
|
||||
// shifts with horizontal scrolling.
|
||||
// The sticky copy's button and name are hidden from assistive technology,
|
||||
// and its button is made inert. It handles clicks on its whole row
|
||||
// instead, as its cell shifts with horizontal scrolling. The group's
|
||||
// actions show in the sticky copy while it is pinned, so they stay
|
||||
// available there.
|
||||
return html`<div
|
||||
class="mdc-data-table__cell group-header"
|
||||
role="cell"
|
||||
@@ -1127,14 +1166,27 @@ export class HaDataTable extends LitElement {
|
||||
?inert=${sticky}
|
||||
>
|
||||
</ha-icon-button>
|
||||
${
|
||||
groupName === UNDEFINED_GROUP_KEY
|
||||
? localize?.("ui.components.data-table.ungrouped") || "Ungrouped"
|
||||
: groupName || ""
|
||||
}
|
||||
<span
|
||||
class="group-name"
|
||||
aria-hidden=${ifDefined(sticky ? "true" : undefined)}
|
||||
>
|
||||
${
|
||||
groupName === UNDEFINED_GROUP_KEY
|
||||
? localize?.("ui.components.data-table.ungrouped") || "Ungrouped"
|
||||
: groupName || ""
|
||||
}
|
||||
</span>
|
||||
<div class="group-actions" @click=${this._stopGroupToggle}>
|
||||
<slot name=${groupActionsSlot(groupName)}></slot>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Actions don't toggle the group their header belongs to
|
||||
private _stopGroupToggle(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
private _memFilterData = memoizeOne(
|
||||
(
|
||||
data: DataTableRowData[],
|
||||
@@ -1436,6 +1488,11 @@ export class HaDataTable extends LitElement {
|
||||
if (this._stickyGroupHeaderCell) {
|
||||
this._stickyGroupHeaderCell.style.transform = `translateX(${-scrollLeft}px)`;
|
||||
}
|
||||
|
||||
// Its actions stay at the end of the visible row, like the rows' own
|
||||
if (this._stickyGroupActions) {
|
||||
this._stickyGroupActions.style.transform = `translateX(${scrollLeft}px)`;
|
||||
}
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
@@ -1503,18 +1560,15 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
public collapseAllGroups() {
|
||||
if (
|
||||
!this.groupColumn ||
|
||||
!this.data.some((item) => item[this.groupColumn!])
|
||||
) {
|
||||
const groupColumn = this.groupColumn;
|
||||
|
||||
if (!groupColumn || !this.data.some((item) => item[groupColumn])) {
|
||||
return;
|
||||
}
|
||||
const grouped = groupBy(this.data, (item) => item[this.groupColumn!]);
|
||||
if (grouped.undefined) {
|
||||
// undefined is a reserved group name
|
||||
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
|
||||
delete grouped.undefined;
|
||||
}
|
||||
|
||||
const grouped = groupBy(this.data, (item) =>
|
||||
dataTableGroupKey(item[groupColumn])
|
||||
);
|
||||
this._collapsedGroups = Object.keys(grouped);
|
||||
this._lastSelectedRowId = null;
|
||||
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
|
||||
@@ -1873,6 +1927,16 @@ export class HaDataTable extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
/* Clipping would make the cell the actions' sticky scroll container,
|
||||
and cut off the pinned header's actions */
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.group-header ha-icon-button {
|
||||
@@ -1883,6 +1947,34 @@ export class HaDataTable extends LitElement {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
/* Centre in the row, not below the header's top padding */
|
||||
align-self: stretch;
|
||||
margin-top: -12px;
|
||||
margin-inline-start: auto;
|
||||
/* Smaller than the rows' icon buttons, inherited by the slotted ones */
|
||||
--_button-size: var(--data-table-group-actions-button-size, 36px);
|
||||
--ha-icon-button-size: var(--_button-size);
|
||||
--mdc-icon-size: var(--data-table-group-actions-icon-size, 20px);
|
||||
/* Centre the last action over the row's last cell, such as an
|
||||
overflow menu, which is a 48px icon button */
|
||||
padding-inline-end: calc(
|
||||
var(--ha-space-4) + (48px - var(--_button-size)) / 2
|
||||
);
|
||||
/* Stay in view when the rows scroll horizontally */
|
||||
position: sticky;
|
||||
inset-inline-end: 0;
|
||||
cursor: default;
|
||||
/* Inherited by the slotted icon buttons, like the rows' icon cells */
|
||||
color: var(
|
||||
--data-table-group-actions-color,
|
||||
var(--secondary-text-color)
|
||||
);
|
||||
}
|
||||
|
||||
.sticky-group-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import {
|
||||
mdiDelete,
|
||||
mdiDotsVertical,
|
||||
@@ -11,7 +12,6 @@ import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
@@ -35,9 +35,8 @@ import type { HaDropdownSelectEvent } from "./ha-dropdown";
|
||||
import "./ha-dropdown-item";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon";
|
||||
import "./ha-list";
|
||||
import "./ha-list-item";
|
||||
import "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable";
|
||||
|
||||
@customElement("ha-filter-categories")
|
||||
export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
@@ -95,44 +94,47 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
<ha-list-selectable
|
||||
<ha-list
|
||||
@selected=${this._categorySelected}
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.category.caption"
|
||||
)}
|
||||
@ha-list-item-selected=${this._categoryAdded}
|
||||
@ha-list-item-deselected=${this._categoryRemoved}
|
||||
activatable
|
||||
>
|
||||
${repeat(
|
||||
this._categories,
|
||||
(category) => category.category_id,
|
||||
${
|
||||
this._categories.length > 0
|
||||
? html`<ha-list-item
|
||||
.selected=${!this.value?.length}
|
||||
.activated=${!this.value?.length}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.category.filter.show_all"
|
||||
)}</ha-list-item
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
${this._categories.map(
|
||||
(category) =>
|
||||
html`<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
html`<ha-list-item
|
||||
.value=${category.category_id}
|
||||
.selected=${
|
||||
this.value?.includes(category.category_id) ?? false
|
||||
}
|
||||
.selected=${this.value?.includes(category.category_id)}
|
||||
.activated=${this.value?.includes(category.category_id)}
|
||||
graphic="icon"
|
||||
hasMeta
|
||||
>
|
||||
${
|
||||
category.icon
|
||||
? html`<ha-icon
|
||||
slot="start"
|
||||
slot="graphic"
|
||||
.icon=${category.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${mdiTag}
|
||||
slot="start"
|
||||
slot="graphic"
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<span slot="headline">${category.name}</span>
|
||||
${category.name}
|
||||
<ha-dropdown
|
||||
@click=${stopPropagation}
|
||||
@keydown=${this._handleMenuKeydown}
|
||||
@wa-select=${this._handleAction}
|
||||
slot="end"
|
||||
slot="meta"
|
||||
.categoryId=${category.category_id}
|
||||
>
|
||||
<ha-icon-button
|
||||
@@ -159,9 +161,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
</ha-list-item-option>`
|
||||
</ha-list-item>`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
</ha-list>
|
||||
<ha-list-item graphic="icon" @click=${this._addCategory}>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.category.editor.add")}
|
||||
@@ -211,10 +213,10 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
try {
|
||||
await deleteCategoryRegistryEntry(this.hass, this.scope!, id);
|
||||
|
||||
if (this.value?.includes(id)) {
|
||||
this._setValue(this.value.filter((categoryId) => categoryId !== id));
|
||||
}
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: [],
|
||||
items: undefined,
|
||||
});
|
||||
} catch (err: any) {
|
||||
alert(`Failed to delete: ${err.message}`);
|
||||
}
|
||||
@@ -235,39 +237,25 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
// The list toggles the focused row on Enter and Space, which would pick the
|
||||
// category instead of opening its menu.
|
||||
private _handleMenuKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private _categoryAdded(ev: CustomEvent<number>) {
|
||||
const categoryId = this._categories[ev.detail]?.category_id;
|
||||
|
||||
if (!categoryId || this.value?.includes(categoryId)) {
|
||||
private async _categorySelected(ev: CustomEvent<SelectedDetail<number>>) {
|
||||
if (!ev.detail.index) {
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: [],
|
||||
items: undefined,
|
||||
});
|
||||
this.value = [];
|
||||
return;
|
||||
}
|
||||
const index = ev.detail.index - 1;
|
||||
|
||||
this._setValue([...(this.value || []), categoryId]);
|
||||
}
|
||||
|
||||
private _categoryRemoved(ev: CustomEvent<number>) {
|
||||
const categoryId = this._categories[ev.detail]?.category_id;
|
||||
|
||||
if (!categoryId) {
|
||||
const val = this._categories![index]?.category_id;
|
||||
if (!val) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setValue((this.value || []).filter((id) => id !== categoryId));
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
this.value = value;
|
||||
this.value = [val];
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: value.length ? value : undefined,
|
||||
value: this.value,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
@@ -310,22 +298,13 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
/* The list scrolls through its own container, not through the host. */
|
||||
ha-list-selectable {
|
||||
display: flex;
|
||||
ha-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
ha-list-selectable::part(base) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
/* Keeps a row with its menu button as tall as the other filters. */
|
||||
ha-list-item-option {
|
||||
--ha-row-item-padding-block: var(--ha-space-2);
|
||||
}
|
||||
ha-list-item-option ha-icon-button {
|
||||
--ha-icon-button-size: 32px;
|
||||
--mdc-list-item-meta-size: auto;
|
||||
--mdc-list-side-padding-right: var(--ha-space-1);
|
||||
--mdc-list-side-padding-left: var(--ha-space-4);
|
||||
--ha-icon-button-size: 36px;
|
||||
}
|
||||
ha-list-item {
|
||||
--mdc-list-item-graphic-margin: var(--ha-space-4);
|
||||
|
||||
@@ -4,10 +4,7 @@ import type {
|
||||
HassEntityBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { createDurationData } from "../common/datetime/create_duration_data";
|
||||
import { applyDurationSign } from "../common/datetime/normalize_duration";
|
||||
import type { WeekdayShort } from "../common/datetime/weekday";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { navigate } from "../common/navigate";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import { createSearchParam } from "../common/url/search-params";
|
||||
@@ -527,26 +524,6 @@ export const pickRowConfig = <T extends object>(
|
||||
return config as Partial<T>;
|
||||
};
|
||||
|
||||
// Older sun and calendar triggers set a positive offset with an offset type,
|
||||
// which the backend folds into the sign of the offset.
|
||||
const OFFSET_TYPE_TRIGGER_DOMAINS = ["sun", "calendar"];
|
||||
|
||||
const foldOffsetType = (
|
||||
options: Record<string, unknown>
|
||||
): Record<string, unknown> => {
|
||||
const { offset_type: offsetType, ...folded } = options;
|
||||
const offset = createDurationData(
|
||||
folded.offset as string | number | ForDict | undefined
|
||||
);
|
||||
if (offset && !Object.values(offset).every(Number.isFinite)) {
|
||||
return options;
|
||||
}
|
||||
if (offsetType === "before" && offset) {
|
||||
folded.offset = applyDurationSign(offset, true);
|
||||
}
|
||||
return folded;
|
||||
};
|
||||
|
||||
export const getRowNote = (note: unknown): string | undefined =>
|
||||
typeof note === "string" ? note.trim() || undefined : undefined;
|
||||
|
||||
@@ -574,12 +551,12 @@ export const migrateAutomationTrigger = (
|
||||
delete trigger.platform;
|
||||
}
|
||||
|
||||
if (
|
||||
"options" in trigger &&
|
||||
typeof trigger.options === "object" &&
|
||||
trigger.options !== null
|
||||
) {
|
||||
if ("behavior" in trigger.options) {
|
||||
if ("options" in trigger) {
|
||||
if (
|
||||
typeof trigger.options === "object" &&
|
||||
trigger.options !== null &&
|
||||
"behavior" in trigger.options
|
||||
) {
|
||||
// Deprecated behavior values renamed in 2026; the backend raises a repair
|
||||
// when they are still used, so flag the migration to offer saving.
|
||||
if (trigger.options.behavior === "any") {
|
||||
@@ -594,12 +571,6 @@ export const migrateAutomationTrigger = (
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
"offset_type" in trigger.options &&
|
||||
OFFSET_TYPE_TRIGGER_DOMAINS.includes(computeDomain(trigger.trigger))
|
||||
) {
|
||||
trigger.options = foldOffsetType(trigger.options);
|
||||
}
|
||||
}
|
||||
|
||||
return trigger;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import type { HassConfig, HassEntity } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { createDurationData } from "../common/datetime/create_duration_data";
|
||||
import { durationDataToSeconds } from "../common/datetime/duration_to_seconds";
|
||||
import {
|
||||
formatDurationDigital,
|
||||
formatDurationLong,
|
||||
@@ -12,7 +10,6 @@ import {
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
} from "../common/datetime/format_time";
|
||||
import { normalizeDuration } from "../common/datetime/normalize_duration";
|
||||
import secondsToDuration from "../common/datetime/seconds_to_duration";
|
||||
import { sortWeekdays } from "../common/datetime/sort_weekdays";
|
||||
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
|
||||
@@ -425,7 +422,16 @@ const describeLegacyTrigger = (
|
||||
|
||||
// Sun Trigger
|
||||
if (trigger.trigger === "sun" && trigger.event) {
|
||||
const duration = formatSunOffset(hass, trigger.offset);
|
||||
let duration = "";
|
||||
if (trigger.offset) {
|
||||
if (typeof trigger.offset === "number") {
|
||||
duration = secondsToDuration(trigger.offset)!;
|
||||
} else if (typeof trigger.offset === "string") {
|
||||
duration = trigger.offset;
|
||||
} else {
|
||||
duration = JSON.stringify(trigger.offset);
|
||||
}
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
trigger.event === "sunset"
|
||||
@@ -835,16 +841,19 @@ const describeLegacyTrigger = (
|
||||
|
||||
let offsetChoice = "other";
|
||||
let offset = "";
|
||||
const duration = createDurationData(trigger.offset);
|
||||
if (duration) {
|
||||
const normalized = normalizeDuration(duration, {
|
||||
enableDay: true,
|
||||
enableSecond: true,
|
||||
enableMillisecond: true,
|
||||
});
|
||||
offset = formatDurationLong(hass.locale, normalized.duration);
|
||||
if (offset) {
|
||||
offsetChoice = normalized.negative ? "before" : "after";
|
||||
if (typeof trigger.offset === "string" && trigger.offset) {
|
||||
offsetChoice = trigger.offset.startsWith("-") ? "before" : "after";
|
||||
const parts = trigger.offset.startsWith("-")
|
||||
? trigger.offset.substring(1).split(":")
|
||||
: trigger.offset.split(":");
|
||||
const duration = {
|
||||
hours: parts.length > 0 ? +parts[0] : 0,
|
||||
minutes: parts.length > 1 ? +parts[1] : 0,
|
||||
seconds: parts.length > 2 ? +parts[2] : 0,
|
||||
};
|
||||
offset = formatDurationLong(hass.locale, duration);
|
||||
if (offset === "") {
|
||||
offsetChoice = "other";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,17 +884,9 @@ const formatSunOffset = (
|
||||
if (typeof offset === "string") {
|
||||
return offset;
|
||||
}
|
||||
if (!durationDataToSeconds(offset)) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const normalized = normalizeDuration(offset, {
|
||||
enableDay: false,
|
||||
enableSecond: true,
|
||||
enableMillisecond: true,
|
||||
});
|
||||
const formatted = formatDurationDigital(hass.locale, normalized.duration);
|
||||
return `${normalized.negative ? "-" : "+"}${formatted}`;
|
||||
const formatted = formatDurationDigital(hass.locale, offset);
|
||||
return formatted.startsWith("-") ? formatted : `+${formatted}`;
|
||||
} catch (_e) {
|
||||
return JSON.stringify(offset);
|
||||
}
|
||||
|
||||
@@ -111,7 +111,6 @@ export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
|
||||
"wind_direction",
|
||||
"wind_speed",
|
||||
],
|
||||
radio_frequency: ["receiver", "transmitter"],
|
||||
sensor: [
|
||||
"absolute_humidity",
|
||||
"apparent_power",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { mdiAccessPoint, mdiBluetooth, mdiSwapHorizontal } from "@mdi/js";
|
||||
import {
|
||||
mdiAccessPoint,
|
||||
mdiBluetooth,
|
||||
mdiMusic,
|
||||
mdiSwapHorizontal,
|
||||
} from "@mdi/js";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import { mdiSendspin } from "../resources/sendspin-logo-svg";
|
||||
import type { ConfigEntry } from "./config_entries";
|
||||
import type { DataEntryFlowProgress } from "./data_entry_flow";
|
||||
import type {
|
||||
@@ -51,19 +55,17 @@ export const CAPABILITY_ORDER: ESPHomeCapabilityId[] = [
|
||||
"serial",
|
||||
];
|
||||
|
||||
const SENDSPIN_RED = "#e25c4c";
|
||||
|
||||
/** Colors for each setup capability. */
|
||||
/** Theme colors for each setup capability. */
|
||||
export const ESPHOME_CAPABILITY_ACCENTS: Record<ESPHomeCapabilityId, string> = {
|
||||
bluetooth: "var(--blue-color)",
|
||||
audio: SENDSPIN_RED,
|
||||
audio: "var(--light-green-color)",
|
||||
connectivity: "var(--cyan-color)",
|
||||
serial: "var(--deep-purple-color)",
|
||||
};
|
||||
|
||||
export const ESPHOME_CAPABILITY_ICONS: Record<ESPHomeCapabilityId, string> = {
|
||||
bluetooth: mdiBluetooth,
|
||||
audio: mdiSendspin,
|
||||
audio: mdiMusic,
|
||||
connectivity: mdiAccessPoint,
|
||||
serial: mdiSwapHorizontal,
|
||||
};
|
||||
|
||||
@@ -398,16 +398,14 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
if (status !== "completed") {
|
||||
return nothing;
|
||||
}
|
||||
return this._checkBadge(
|
||||
this._i18n!.localize(
|
||||
"ui.panel.config.devices.esphome.setup_status_completed"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _checkBadge(label: string) {
|
||||
return html`
|
||||
<span class="status-badge completed" role="img" aria-label=${label}>
|
||||
<span
|
||||
class="status-badge completed"
|
||||
role="img"
|
||||
aria-label=${this._i18n!.localize(
|
||||
"ui.panel.config.devices.esphome.setup_status_completed"
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
|
||||
</span>
|
||||
`;
|
||||
@@ -509,7 +507,7 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
return html`
|
||||
<div
|
||||
class="protect"
|
||||
style="--capability-accent: var(--light-green-color)"
|
||||
style="--capability-accent: ${ESPHOME_CAPABILITY_ACCENTS.audio}"
|
||||
>
|
||||
<span class="icon-chip">
|
||||
<ha-svg-icon .path=${mdiShieldLock}></ha-svg-icon>
|
||||
@@ -626,9 +624,11 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
<span class="port-text">
|
||||
<span class="port-name">${entry.title}</span>
|
||||
</span>
|
||||
${this._checkBadge(
|
||||
localize("ui.panel.config.devices.esphome.setup_zwave_configured")
|
||||
)}
|
||||
<span class="chip active">
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_zwave_configured"
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
@@ -748,11 +748,13 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
</span>
|
||||
`
|
||||
: usage.consumers.length
|
||||
? this._checkBadge(
|
||||
localize(
|
||||
"ui.panel.config.devices.esphome.setup_serial_configured"
|
||||
)
|
||||
)
|
||||
? html`
|
||||
<span class="chip active">
|
||||
${localize(
|
||||
"ui.panel.config.devices.esphome.setup_serial_configured"
|
||||
)}
|
||||
</span>
|
||||
`
|
||||
: html`
|
||||
<ha-button size="s" @click=${this._setupSerialPort}>
|
||||
${localize(
|
||||
@@ -1402,6 +1404,10 @@ class DialogESPHomeDeviceSetup extends DialogMixin<ESPHomeDeviceSetupDialogParam
|
||||
background: var(--ha-color-fill-neutral-normal-resting);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.chip.active {
|
||||
background: var(--success-color);
|
||||
color: var(--ha-color-on-success-loud);
|
||||
}
|
||||
.ma-offer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
dataTableQueryStateFromUrl,
|
||||
} from "../common/url/data-table-query-params";
|
||||
import "../components/chips/ha-assist-chip";
|
||||
import "../components/data-table/ha-data-table";
|
||||
import { GROUP_ACTIONS_SLOT_PREFIX } from "../components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableRowData,
|
||||
@@ -631,6 +631,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
.columnOrder=${this.columnOrder}
|
||||
.hiddenColumns=${this.hiddenColumns}
|
||||
>
|
||||
${this._groupActionsSlots.map(
|
||||
(slot) => html`<slot name=${slot} slot=${slot}></slot>`
|
||||
)}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
@@ -846,6 +849,46 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
this._setGroupColumn(group);
|
||||
}
|
||||
|
||||
// Slots of the group actions the page supplies, passed through to the table
|
||||
@state() private _groupActionsSlots: string[] = [];
|
||||
|
||||
private _groupActionsObserver = new MutationObserver(() =>
|
||||
this._updateGroupActionsSlots()
|
||||
);
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._updateGroupActionsSlots();
|
||||
// Subtree, so slot changes on the page's children are seen too
|
||||
this._groupActionsObserver.observe(this, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributeFilter: ["slot"],
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._groupActionsObserver.disconnect();
|
||||
}
|
||||
|
||||
private _updateGroupActionsSlots() {
|
||||
const slots = [
|
||||
...new Set(
|
||||
Array.from(this.children, (child) => child.slot).filter((slot) =>
|
||||
slot.startsWith(GROUP_ACTIONS_SLOT_PREFIX)
|
||||
)
|
||||
),
|
||||
];
|
||||
|
||||
if (
|
||||
slots.length !== this._groupActionsSlots.length ||
|
||||
slots.some((slot, index) => slot !== this._groupActionsSlots[index])
|
||||
) {
|
||||
this._groupActionsSlots = slots;
|
||||
}
|
||||
}
|
||||
|
||||
private _setGroupColumn(columnId: string, link = false) {
|
||||
this._groupColumn = columnId;
|
||||
fireEvent(this, "grouping-changed", { value: columnId, link });
|
||||
|
||||
@@ -65,7 +65,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
|
||||
name: "after_offset",
|
||||
selector: {
|
||||
duration: {
|
||||
mode: "offset",
|
||||
allow_negative: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -96,7 +96,7 @@ export class HaSunCondition extends LitElement implements ConditionElement {
|
||||
name: "before_offset",
|
||||
selector: {
|
||||
duration: {
|
||||
mode: "offset",
|
||||
allow_negative: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -980,17 +980,13 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
// the filters below only expose the selected options (as filter.value);
|
||||
// applying the filter must be done here
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-categories")) {
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) => {
|
||||
const category = this._entityReg.find(
|
||||
(reg) => reg.entity_id === entityId
|
||||
)?.categories.automation;
|
||||
|
||||
return (
|
||||
category !== undefined &&
|
||||
Array.isArray(filter.value) &&
|
||||
filter.value.includes(category)
|
||||
);
|
||||
});
|
||||
// category filter only allows a single selected option
|
||||
filteredEntityIds = filteredEntityIds.filter(
|
||||
(entityId) =>
|
||||
filter.value![0] ===
|
||||
this._entityReg.find((reg) => reg.entity_id === entityId)
|
||||
?.categories.automation
|
||||
);
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-labels")) {
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) =>
|
||||
this._entityReg
|
||||
|
||||
@@ -5,22 +5,16 @@ import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { createDurationData } from "../../../common/datetime/create_duration_data";
|
||||
import { formatDurationNarrow } from "../../../common/datetime/format_duration";
|
||||
import { normalizeDuration } from "../../../common/datetime/normalize_duration";
|
||||
import { hasTemplate } from "../../../common/string/has-template";
|
||||
import type { ForDict } from "../../../data/automation";
|
||||
import { internationalizationContext } from "../../../data/context";
|
||||
import { rowSummaryStyles } from "./styles";
|
||||
|
||||
const SUMMARY_UNITS = {
|
||||
enableDay: true,
|
||||
enableSecond: true,
|
||||
enableMillisecond: true,
|
||||
};
|
||||
|
||||
interface HaAutomationRowOptionsConfig {
|
||||
options?: {
|
||||
for?: string | number | ForDict;
|
||||
offset?: string | number | ForDict;
|
||||
offset_type?: "before" | "after";
|
||||
};
|
||||
timeout?: string | number | ForDict;
|
||||
}
|
||||
@@ -69,18 +63,20 @@ export class HaAutomationRowOptions extends LitElement {
|
||||
this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.row_options.for",
|
||||
{
|
||||
duration: forDuration.text,
|
||||
duration: forDuration,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const offset = this._duration(options.offset);
|
||||
if (offset) {
|
||||
const offsetDuration = this._duration(options.offset);
|
||||
if (offsetDuration) {
|
||||
const offsetType =
|
||||
options.offset_type === "before" ? "before" : "after";
|
||||
parts.push(
|
||||
this._i18n.localize(
|
||||
`ui.panel.config.automation.editor.row_options.offset_${offset.negative ? "before" : "after"}`,
|
||||
{ offset: offset.text }
|
||||
`ui.panel.config.automation.editor.row_options.offset_${offsetType}`,
|
||||
{ offset: offsetDuration }
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -92,7 +88,7 @@ export class HaAutomationRowOptions extends LitElement {
|
||||
parts.push(
|
||||
this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.row_options.timeout",
|
||||
{ duration: timeoutDuration.text }
|
||||
{ duration: timeoutDuration }
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -102,9 +98,7 @@ export class HaAutomationRowOptions extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _duration(
|
||||
value: unknown
|
||||
): { negative: boolean; text: string } | undefined {
|
||||
private _duration(value: unknown): string | undefined {
|
||||
if (value === undefined || hasTemplate(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -119,9 +113,7 @@ export class HaAutomationRowOptions extends LitElement {
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeDuration(duration, SUMMARY_UNITS);
|
||||
const text = formatDurationNarrow(this._i18n.locale, normalized.duration);
|
||||
return text ? { negative: normalized.negative, text } : undefined;
|
||||
return formatDurationNarrow(this._i18n.locale, duration);
|
||||
}
|
||||
|
||||
static styles = rowSummaryStyles;
|
||||
|
||||
@@ -5,7 +5,10 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import type { CalendarTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { TriggerElement } from "../ha-automation-trigger-row";
|
||||
import type { HaDurationData } from "../../../../../components/ha-duration-input";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
@@ -44,10 +47,25 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
],
|
||||
],
|
||||
},
|
||||
{ name: "offset", required: true, selector: { duration: {} } },
|
||||
{
|
||||
name: "offset",
|
||||
name: "offset_type",
|
||||
type: "select",
|
||||
required: true,
|
||||
selector: { duration: { enable_day: true, mode: "offset" } },
|
||||
options: [
|
||||
[
|
||||
"before",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.before"
|
||||
),
|
||||
],
|
||||
[
|
||||
"after",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.after"
|
||||
),
|
||||
],
|
||||
],
|
||||
},
|
||||
] as const
|
||||
);
|
||||
@@ -63,10 +81,34 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
protected render() {
|
||||
const schema = this._schema(this.hass.localize);
|
||||
// Convert from string representation to ha form duration representation
|
||||
const trigger_offset = this.trigger.offset;
|
||||
// Copy, `createDurationData` returns the input object as-is for dict values.
|
||||
const duration: HaDurationData = { ...createDurationData(trigger_offset)! };
|
||||
let offset_type = "after";
|
||||
if (durationDataToSeconds(duration) < 0) {
|
||||
// A negative offset negates the whole period, and the sign is shown by
|
||||
// the separate before/after select instead.
|
||||
if (duration.days) {
|
||||
duration.days = Math.abs(duration.days);
|
||||
}
|
||||
duration.hours = Math.abs(duration.hours ?? 0);
|
||||
duration.minutes = Math.abs(duration.minutes ?? 0);
|
||||
duration.seconds = Math.abs(duration.seconds ?? 0);
|
||||
if (duration.milliseconds) {
|
||||
duration.milliseconds = Math.abs(duration.milliseconds);
|
||||
}
|
||||
offset_type = "before";
|
||||
}
|
||||
const data = {
|
||||
...this.trigger,
|
||||
offset: duration,
|
||||
offset_type: offset_type,
|
||||
};
|
||||
return html`
|
||||
<ha-form
|
||||
.schema=${schema}
|
||||
.data=${this.trigger}
|
||||
.data=${data}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@@ -77,7 +119,18 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value });
|
||||
// Convert back to duration string representation
|
||||
const duration = ev.detail.value.offset;
|
||||
const offsetType = ev.detail.value.offset_type === "before" ? "-" : "";
|
||||
const h = (duration.days ?? 0) * 24 + (duration.hours ?? 0);
|
||||
const m = duration.minutes ?? 0;
|
||||
const s = (duration.seconds ?? 0) + (duration.milliseconds ?? 0) / 1000;
|
||||
const newTrigger = {
|
||||
...ev.detail.value,
|
||||
offset: `${offsetType}${h}:${m}:${s}`,
|
||||
};
|
||||
delete newTrigger.offset_type;
|
||||
fireEvent(this, "value-changed", { value: newTrigger });
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
@@ -90,10 +143,6 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.event"
|
||||
);
|
||||
case "offset":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.calendar.offset"
|
||||
);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -39,7 +39,7 @@ export class HaSunTrigger extends LitElement implements TriggerElement {
|
||||
],
|
||||
],
|
||||
},
|
||||
{ name: "offset", selector: { duration: { mode: "offset" } } },
|
||||
{ name: "offset", selector: { text: {} } },
|
||||
] as const
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { customElement, property, queryAll, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
|
||||
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import {
|
||||
@@ -27,10 +26,6 @@ import {
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
integrationQueryParamConfig,
|
||||
integrationSearchFromUrl,
|
||||
} from "../../../common/url/integrations-query-params";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
@@ -181,15 +176,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
@queryAll("ha-config-entry-row")
|
||||
private _configEntryRows!: NodeListOf<HaConfigEntryRow>;
|
||||
|
||||
private _queryParams = new QueryParamsController(
|
||||
this,
|
||||
integrationQueryParamConfig,
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._applyUrl(),
|
||||
}
|
||||
);
|
||||
|
||||
private _handleHashChange = () => {
|
||||
this._searchParms = new URLSearchParams(
|
||||
__DEMO__ ? window.location.search : window.location.hash.substring(1)
|
||||
@@ -251,10 +237,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
this._fetchDiagnostics();
|
||||
this._fetchEntitySources();
|
||||
}
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._applyUrl();
|
||||
}
|
||||
if (
|
||||
changedProperties.has("configEntries") ||
|
||||
changedProperties.has("_extraConfigEntries")
|
||||
@@ -738,12 +720,11 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
</div>
|
||||
|
||||
${
|
||||
normalData.length + attentionData.length > 0 || this._filter
|
||||
normalData.length + attentionData.length > 0
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@blur=${this._flushUrl}
|
||||
></ha-input-search>`
|
||||
: nothing
|
||||
}
|
||||
@@ -1275,32 +1256,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
|
||||
this._filter = ev.target.value ?? "";
|
||||
|
||||
this._writeUrl(true);
|
||||
}
|
||||
|
||||
private _flushUrl() {
|
||||
this._queryParams.flush();
|
||||
}
|
||||
|
||||
private _writeUrl(debounced = false) {
|
||||
this._queryParams.write({ search: this._filter }, debounced);
|
||||
}
|
||||
|
||||
private _applyUrl() {
|
||||
const searchParams = this._queryParams.searchParams();
|
||||
|
||||
if (!searchParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const search = integrationSearchFromUrl(searchParams);
|
||||
|
||||
if (search !== undefined) {
|
||||
this._filter = search;
|
||||
}
|
||||
|
||||
this._writeUrl();
|
||||
}
|
||||
|
||||
private async _handleEnableDebugLogging() {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { QueryParamsController } from "../../../common/controllers/query-params-controller";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { deepActiveElement } from "../../../common/dom/deep-active-element";
|
||||
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
@@ -17,13 +16,12 @@ import {
|
||||
PROTOCOL_INTEGRATIONS,
|
||||
protocolIntegrationPicked,
|
||||
} from "../../../common/integrations/protocolIntegrationPicked";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import {
|
||||
integrationsLinkFromUrl,
|
||||
integrationsQueryParamConfig,
|
||||
integrationsQueryParams,
|
||||
} from "../../../common/url/integrations-query-params";
|
||||
getHistoryState,
|
||||
navigate,
|
||||
updateHistoryState,
|
||||
} from "../../../common/navigate";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-button";
|
||||
@@ -171,7 +169,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
window.location.hash.substring(1)
|
||||
);
|
||||
|
||||
@state() private _filter = "";
|
||||
@state() private _filter: string = getHistoryState()?.filter || "";
|
||||
|
||||
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
|
||||
|
||||
@@ -187,17 +185,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
|
||||
private _childReadyRegistered = false;
|
||||
|
||||
// The page can first be opened on /add, which sends itself on to the page
|
||||
private _queryParams = new QueryParamsController(
|
||||
this,
|
||||
integrationsQueryParamConfig,
|
||||
{
|
||||
enabled: () => true,
|
||||
navigated: () => this._applyUrl(),
|
||||
path: "/config/integrations/dashboard",
|
||||
}
|
||||
);
|
||||
|
||||
@consume({ context: childPanelReadyContext, subscribe: true })
|
||||
private _registerChildPanelReady?: RegisterChildPanelReady;
|
||||
|
||||
@@ -411,14 +398,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
}
|
||||
);
|
||||
|
||||
protected willUpdate(changed: PropertyValues<this>) {
|
||||
super.willUpdate(changed);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._applyUrl();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changed: PropertyValues<this>) {
|
||||
super.firstUpdated(changed);
|
||||
this._fetchManifests();
|
||||
@@ -577,7 +556,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@blur=${this._flushUrl}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.panel.config.integrations.search"
|
||||
)}
|
||||
@@ -599,7 +577,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@blur=${this._flushUrl}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.panel.config.integrations.search"
|
||||
)}
|
||||
@@ -909,8 +886,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
}
|
||||
|
||||
private _createFlow() {
|
||||
// Keep the search typed so far on the entry the dialog's Back returns to
|
||||
this._queryParams.flush();
|
||||
showAddIntegrationDialog(this, {
|
||||
initialFilter: this._filter,
|
||||
navigateToResult: true,
|
||||
@@ -922,7 +897,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
switch (action) {
|
||||
case "show-ignored":
|
||||
this._showIgnored = !this._showIgnored;
|
||||
this._writeUrl();
|
||||
break;
|
||||
case "show-disabled":
|
||||
this._toggleShowDisabled();
|
||||
@@ -932,46 +906,11 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
|
||||
private _toggleShowDisabled() {
|
||||
this._showDisabled = !this._showDisabled;
|
||||
this._writeUrl();
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: HASSDomTargetEvent<HaInputSearch>) {
|
||||
this._filter = ev.target.value ?? "";
|
||||
|
||||
this._writeUrl(true);
|
||||
}
|
||||
|
||||
private _flushUrl() {
|
||||
this._queryParams.flush();
|
||||
}
|
||||
|
||||
private _writeUrl(debounced = false) {
|
||||
this._queryParams.write(
|
||||
integrationsQueryParams({
|
||||
search: this._filter,
|
||||
showIgnored: this._showIgnored,
|
||||
showDisabled: this._showDisabled,
|
||||
}),
|
||||
debounced
|
||||
);
|
||||
}
|
||||
|
||||
private _applyUrl() {
|
||||
const searchParams = this._queryParams.searchParams();
|
||||
|
||||
if (!searchParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = integrationsLinkFromUrl(searchParams);
|
||||
|
||||
if (link) {
|
||||
this._filter = link.search;
|
||||
this._showIgnored = link.showIgnored;
|
||||
this._showDisabled = link.showDisabled;
|
||||
}
|
||||
|
||||
this._writeUrl();
|
||||
updateHistoryState({ filter: this._filter });
|
||||
}
|
||||
|
||||
private async _highlightEntry() {
|
||||
@@ -1007,7 +946,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
||||
const brand = extractSearchParam("brand");
|
||||
const domain = extractSearchParam("domain");
|
||||
// Navigating closes open dialogs, so open them once it has finished
|
||||
await navigate("/config/integrations/dashboard", { replace: true });
|
||||
await navigate("/config/integrations/dashboard/", { replace: true });
|
||||
|
||||
if (brand) {
|
||||
showAddIntegrationDialog(this, {
|
||||
|
||||
@@ -60,7 +60,6 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
|
||||
},
|
||||
integration: {
|
||||
tag: "ha-config-integration-page",
|
||||
itemId: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -202,12 +201,7 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
|
||||
if (urlParams.has("domain")) {
|
||||
const domain = urlParams.get("domain");
|
||||
pageEl.domain = domain;
|
||||
urlParams.delete("domain");
|
||||
const search = urlParams.toString();
|
||||
navigate(
|
||||
`/config/integrations/integration/${domain}${search ? `?${search}` : ""}`,
|
||||
{ replace: true }
|
||||
);
|
||||
navigate(`/config/integrations/integration/${domain}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
|
||||
import type {
|
||||
NetworkData,
|
||||
NetworkLink,
|
||||
NetworkNode,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyNode,
|
||||
@@ -20,8 +18,6 @@ const CATEGORY_END_DEVICE = 3;
|
||||
const CATEGORY_WIFI_AP = 4;
|
||||
const CATEGORY_OFFLINE = 5;
|
||||
const CATEGORY_UNKNOWN = 6;
|
||||
const CATEGORY_BRIDGE = 7;
|
||||
const CATEGORY_BRIDGED = 8;
|
||||
|
||||
const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
|
||||
|
||||
@@ -29,41 +25,6 @@ const ROUTER_ROLES = new Set(["leader", "router", "reed"]);
|
||||
export const HOME_ASSISTANT_NODE_ID = "ha";
|
||||
const HOME_ASSISTANT_LABEL = "Home Assistant";
|
||||
|
||||
// bridged devices are not Matter nodes, so they are synthesized from the
|
||||
// device registry under an id that cannot collide with a topology node
|
||||
const BRIDGED_DEVICE_NODE_PREFIX = "bridged_";
|
||||
|
||||
export const getBridgedDeviceId = (nodeId: string): string | undefined =>
|
||||
nodeId.startsWith(BRIDGED_DEVICE_NODE_PREFIX)
|
||||
? nodeId.slice(BRIDGED_DEVICE_NODE_PREFIX.length)
|
||||
: undefined;
|
||||
|
||||
// a bridge exposes the devices behind it as endpoints of its own node, so
|
||||
// the topology never lists them; core registers each as a device whose
|
||||
// via_device is the bridge
|
||||
export const getBridgedDevices = (
|
||||
bridgeDeviceId: string,
|
||||
devices: HomeAssistant["devices"]
|
||||
): DeviceRegistryEntry[] =>
|
||||
Object.values(devices).filter(
|
||||
(device) => device.via_device_id === bridgeDeviceId && !device.disabled_by
|
||||
);
|
||||
|
||||
// ha-network-graph labels a node beside its symbol only, so the bridged
|
||||
// device count is drawn into the symbol itself
|
||||
const bridgeSymbol = (
|
||||
color: string,
|
||||
textColor: string,
|
||||
count?: number
|
||||
): string => {
|
||||
const text =
|
||||
count === undefined
|
||||
? ""
|
||||
: `<text x="16" y="16" dy="0.35em" text-anchor="middle" font-family="sans-serif" font-weight="bold" font-size="${count > 99 ? 10 : 13}" fill="${textColor}">${count}</text>`;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="16" fill="${color}" fill-opacity="0.35"/><circle cx="16" cy="16" r="12" fill="${color}"/>${text}</svg>`;
|
||||
return `image://data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
||||
};
|
||||
|
||||
// 0 is never returned: a falsy link value re-enables the direction arrow
|
||||
// in ha-network-graph
|
||||
export const strengthToScale = (
|
||||
@@ -119,9 +80,6 @@ export const getTopologyNodeCategory = (
|
||||
if (node.available === false) {
|
||||
return CATEGORY_OFFLINE;
|
||||
}
|
||||
if (node.is_bridge) {
|
||||
return CATEGORY_BRIDGE;
|
||||
}
|
||||
return node.role && ROUTER_ROLES.has(node.role)
|
||||
? CATEGORY_ROUTER
|
||||
: CATEGORY_END_DEVICE;
|
||||
@@ -170,10 +128,7 @@ const isHub = (category: number): boolean =>
|
||||
export function createMatterNetworkChartData(
|
||||
topology: MatterNetworkTopology,
|
||||
hass: HomeAssistant,
|
||||
element: Element,
|
||||
// a bridge can expose many devices, so they stay behind their bridge until
|
||||
// it is expanded
|
||||
expandedBridges: ReadonlySet<string> = new Set()
|
||||
element: Element
|
||||
): NetworkData {
|
||||
const style = getComputedStyle(element);
|
||||
|
||||
@@ -186,13 +141,7 @@ export function createMatterNetworkChartData(
|
||||
style.getPropertyValue(networkToColorVar("wifi")),
|
||||
style.getPropertyValue("--error-color"),
|
||||
style.getPropertyValue("--disabled-color"),
|
||||
// a bridge is an end device that aggregates others behind it
|
||||
style.getPropertyValue("--teal-color"),
|
||||
style.getPropertyValue("--blue-grey-color"),
|
||||
].map((color) => color.trim());
|
||||
const textPrimaryColor = style
|
||||
.getPropertyValue("--text-primary-color")
|
||||
.trim();
|
||||
];
|
||||
const categories = [
|
||||
{
|
||||
name: HOME_ASSISTANT_LABEL,
|
||||
@@ -231,18 +180,6 @@ export function createMatterNetworkChartData(
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_UNKNOWN] },
|
||||
},
|
||||
{
|
||||
name: hass.localize("ui.panel.config.matter.node_type.bridge"),
|
||||
symbol: bridgeSymbol(categoryColors[CATEGORY_BRIDGE], textPrimaryColor),
|
||||
itemStyle: { color: categoryColors[CATEGORY_BRIDGE] },
|
||||
},
|
||||
{
|
||||
name: hass.localize(
|
||||
"ui.panel.config.matter.visualization.bridged_device"
|
||||
),
|
||||
symbol: "circle",
|
||||
itemStyle: { color: categoryColors[CATEGORY_BRIDGED] },
|
||||
},
|
||||
];
|
||||
|
||||
const threadNetworks = new Set(
|
||||
@@ -264,7 +201,6 @@ export function createMatterNetworkChartData(
|
||||
},
|
||||
];
|
||||
const nodeCategories = new Map<string, number>();
|
||||
const bridgedDevices = new Map<string, DeviceRegistryEntry[]>();
|
||||
topology.nodes.forEach((node) => {
|
||||
const category = getTopologyNodeCategory(node);
|
||||
nodeCategories.set(node.id, category);
|
||||
@@ -289,31 +225,6 @@ export function createMatterNetworkChartData(
|
||||
if ((multiNetwork || !area) && networkLabel && networkLabel !== name) {
|
||||
contextParts.push(networkLabel);
|
||||
}
|
||||
if (node.is_bridge) {
|
||||
// without a device the bridged devices are unknown, not zero
|
||||
const bridged = node.ha_device_id
|
||||
? getBridgedDevices(node.ha_device_id, hass.devices)
|
||||
: undefined;
|
||||
if (bridged && expandedBridges.has(node.id)) {
|
||||
bridgedDevices.set(node.id, bridged);
|
||||
}
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
name,
|
||||
context: contextParts.join(" • ") || undefined,
|
||||
category,
|
||||
value: 2,
|
||||
symbol: bridgeSymbol(
|
||||
categoryColors[category],
|
||||
textPrimaryColor,
|
||||
bridged?.length
|
||||
),
|
||||
symbolSize: 30,
|
||||
itemStyle: { color: categoryColors[category] },
|
||||
polarDistance: 0.8,
|
||||
});
|
||||
return;
|
||||
}
|
||||
nodes.push({
|
||||
id: node.id,
|
||||
name,
|
||||
@@ -455,39 +366,5 @@ export function createMatterNetworkChartData(
|
||||
}
|
||||
});
|
||||
|
||||
bridgedDevices.forEach((devices, bridgeId) => {
|
||||
devices.forEach((device) => {
|
||||
const id = `${BRIDGED_DEVICE_NODE_PREFIX}${device.id}`;
|
||||
const area = getDeviceArea(device, hass.areas, hass.devices);
|
||||
nodes.push({
|
||||
id,
|
||||
name: computeDeviceName(device) || id,
|
||||
context: area?.name,
|
||||
category: CATEGORY_BRIDGED,
|
||||
value: 1,
|
||||
symbol: "circle",
|
||||
symbolSize: 15,
|
||||
itemStyle: { color: categoryColors[CATEGORY_BRIDGED] },
|
||||
polarDistance: 0.9,
|
||||
});
|
||||
// not a radio link, so no strength and no arrow; it stays in the force
|
||||
// layout so the devices gather around their bridge
|
||||
links.push({
|
||||
source: bridgeId,
|
||||
target: id,
|
||||
value: 0,
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: categoryColors[CATEGORY_BRIDGED],
|
||||
type:
|
||||
nodeCategories.get(bridgeId) === CATEGORY_OFFLINE
|
||||
? "dashed"
|
||||
: "solid",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { relativeTime } from "../../../../../common/datetime/relative_time";
|
||||
import type { HASSDomTargetEvent } from "../../../../../common/dom/fire_event";
|
||||
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../../../../common/entity/context/get_device_context";
|
||||
import { navigate } from "../../../../../common/navigate";
|
||||
import type { LocalizeKeys } from "../../../../../common/translations/localize";
|
||||
@@ -21,7 +20,6 @@ import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
|
||||
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
|
||||
import type {
|
||||
MatterNetworkTopology,
|
||||
MatterNetworkTopologyConnection,
|
||||
@@ -36,8 +34,6 @@ import "../../../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import {
|
||||
createMatterNetworkChartData,
|
||||
getBridgedDeviceId,
|
||||
getBridgedDevices,
|
||||
getTopologyNodeName,
|
||||
HOME_ASSISTANT_NODE_ID,
|
||||
} from "./matter-network-data";
|
||||
@@ -64,9 +60,6 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
|
||||
@state() private _searchFilter = "";
|
||||
|
||||
// topology node ids of the bridges whose bridged devices are shown
|
||||
@state() private _expandedBridges: ReadonlySet<string> = new Set();
|
||||
|
||||
private _unsub?: Promise<UnsubscribeFunc>;
|
||||
|
||||
private _throttledUpdateTopology = throttle(
|
||||
@@ -166,8 +159,7 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.themes,
|
||||
this.hass.language,
|
||||
this._expandedBridges
|
||||
this.hass.language
|
||||
)}
|
||||
.searchableAttributes=${this._getSearchableAttributes}
|
||||
.tooltipFormatter=${this._tooltipFormatter}
|
||||
@@ -223,21 +215,14 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
// node/link colors and labels also depend on the theme and language,
|
||||
// so both take part in the cache key even though they are read via hass
|
||||
_themes: HomeAssistant["themes"],
|
||||
_language: HomeAssistant["language"],
|
||||
expandedBridges: ReadonlySet<string>
|
||||
) =>
|
||||
createMatterNetworkChartData(topology, this.hass, this, expandedBridges)
|
||||
_language: HomeAssistant["language"]
|
||||
) => createMatterNetworkChartData(topology, this.hass, this)
|
||||
);
|
||||
|
||||
private _getTopologyNode(id: string): MatterNetworkTopologyNode | undefined {
|
||||
return this._topology?.nodes.find((node) => node.id === id);
|
||||
}
|
||||
|
||||
private _getBridgedDevice(id: string): DeviceRegistryEntry | undefined {
|
||||
const deviceId = getBridgedDeviceId(id);
|
||||
return deviceId ? this.hass.devices[deviceId] : undefined;
|
||||
}
|
||||
|
||||
private _getConnection(
|
||||
source: string,
|
||||
target: string
|
||||
@@ -250,35 +235,11 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
}
|
||||
|
||||
private _getNodeName(id: string): string {
|
||||
const bridgedDevice = this._getBridgedDevice(id);
|
||||
if (bridgedDevice) {
|
||||
return computeDeviceName(bridgedDevice) || id;
|
||||
}
|
||||
const node = this._getTopologyNode(id);
|
||||
return node ? getTopologyNodeName(node, this.hass) : id;
|
||||
}
|
||||
|
||||
private _getDeviceSearchableAttributes(
|
||||
device: DeviceRegistryEntry | undefined
|
||||
): string[] {
|
||||
const attributes: string[] = [];
|
||||
if (device?.manufacturer) {
|
||||
attributes.push(device.manufacturer);
|
||||
}
|
||||
if (device?.model) {
|
||||
attributes.push(device.model);
|
||||
}
|
||||
device?.connections.forEach((connection) => {
|
||||
attributes.push(connection[1]);
|
||||
});
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private _getSearchableAttributes = (nodeId: string): string[] => {
|
||||
const bridgedDevice = this._getBridgedDevice(nodeId);
|
||||
if (bridgedDevice) {
|
||||
return this._getDeviceSearchableAttributes(bridgedDevice);
|
||||
}
|
||||
const node = this._getTopologyNode(nodeId);
|
||||
if (!node) {
|
||||
return [];
|
||||
@@ -305,17 +266,15 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
const device = node.ha_device_id
|
||||
? this.hass.devices[node.ha_device_id]
|
||||
: undefined;
|
||||
attributes.push(...this._getDeviceSearchableAttributes(device));
|
||||
if (node.is_bridge && node.ha_device_id) {
|
||||
getBridgedDevices(node.ha_device_id, this.hass.devices).forEach(
|
||||
(bridged) => {
|
||||
const name = computeDeviceName(bridged);
|
||||
if (name) {
|
||||
attributes.push(name);
|
||||
}
|
||||
}
|
||||
);
|
||||
if (device?.manufacturer) {
|
||||
attributes.push(device.manufacturer);
|
||||
}
|
||||
if (device?.model) {
|
||||
attributes.push(device.model);
|
||||
}
|
||||
device?.connections.forEach((connection) => {
|
||||
attributes.push(connection[1]);
|
||||
});
|
||||
return attributes;
|
||||
};
|
||||
|
||||
@@ -345,18 +304,6 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
const { dataType, data } = params as CallbackDataParams;
|
||||
if (dataType === "edge") {
|
||||
const { source, target } = data as { source: string; target: string };
|
||||
if (this._getBridgedDevice(target)) {
|
||||
return html`<b
|
||||
>${this._getNodeName(source)} ↔ ${this._getNodeName(target)}</b
|
||||
><br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.network"
|
||||
)}:</b
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.bridged"
|
||||
)}`;
|
||||
}
|
||||
const conn = this._getConnection(source, target);
|
||||
if (!conn) {
|
||||
return nothing;
|
||||
@@ -410,10 +357,6 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
if (id === HOME_ASSISTANT_NODE_ID) {
|
||||
return html`<b>Home Assistant</b>`;
|
||||
}
|
||||
const bridgedDevice = this._getBridgedDevice(id);
|
||||
if (bridgedDevice) {
|
||||
return this._bridgedDeviceTooltip(id, bridgedDevice);
|
||||
}
|
||||
const node = this._getTopologyNode(id);
|
||||
if (!node) {
|
||||
return nothing;
|
||||
@@ -445,16 +388,6 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
node.network_name ? html` (${node.network_name})` : nothing
|
||||
}`
|
||||
);
|
||||
if (node.is_bridge && node.ha_device_id) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.bridged_devices"
|
||||
)}:</b
|
||||
>
|
||||
${getBridgedDevices(node.ha_device_id, this.hass.devices).length}`
|
||||
);
|
||||
}
|
||||
if (node.role) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
@@ -519,101 +452,20 @@ export class MatterNetworkVisualization extends LitElement {
|
||||
${relativeTime(new Date(node.last_seen), this.hass.locale)}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
node.is_bridge &&
|
||||
node.ha_device_id &&
|
||||
getBridgedDevices(node.ha_device_id, this.hass.devices).length
|
||||
) {
|
||||
lines.push(
|
||||
html`<br /><i
|
||||
>${this.hass.localize(
|
||||
this._expandedBridges.has(id)
|
||||
? "ui.panel.config.matter.visualization.hide_bridged_devices"
|
||||
: "ui.panel.config.matter.visualization.show_bridged_devices"
|
||||
)}</i
|
||||
>`
|
||||
);
|
||||
}
|
||||
return html`<b>${this._getNodeName(id)}</b>${lines}`;
|
||||
};
|
||||
|
||||
private _bridgedDeviceTooltip(
|
||||
id: string,
|
||||
device: DeviceRegistryEntry
|
||||
): TemplateResult {
|
||||
const bridge = device.via_device_id
|
||||
? this.hass.devices[device.via_device_id]
|
||||
: undefined;
|
||||
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
|
||||
const lines: TemplateResult[] = [];
|
||||
if (bridge) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.bridged_by"
|
||||
)}:</b
|
||||
>
|
||||
${computeDeviceName(bridge) || bridge.id}`
|
||||
);
|
||||
}
|
||||
if (device.manufacturer) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.manufacturer"
|
||||
)}:</b
|
||||
>
|
||||
${device.manufacturer}`
|
||||
);
|
||||
}
|
||||
if (device.model) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.model"
|
||||
)}:</b
|
||||
>
|
||||
${device.model}`
|
||||
);
|
||||
}
|
||||
if (area) {
|
||||
lines.push(
|
||||
html`<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.matter.visualization.area"
|
||||
)}:</b
|
||||
>
|
||||
${area.name}`
|
||||
);
|
||||
}
|
||||
return html`<b>${this._getNodeName(id)}</b>${lines}`;
|
||||
}
|
||||
|
||||
private _handleChartClick(e: CustomEvent): void {
|
||||
if (e.detail.dataType !== "node") {
|
||||
return;
|
||||
}
|
||||
const { id } = e.detail.data;
|
||||
const node = this._getTopologyNode(id);
|
||||
// the label has a pointer cursor, the draggable symbol a move cursor
|
||||
if (e.detail.event.target.cursor === "pointer") {
|
||||
const deviceId = getBridgedDeviceId(id) ?? node?.ha_device_id;
|
||||
if (deviceId) {
|
||||
navigate(`/config/devices/device/${deviceId}`);
|
||||
if (
|
||||
e.detail.dataType === "node" &&
|
||||
e.detail.event.target.cursor === "pointer"
|
||||
) {
|
||||
const { id } = e.detail.data;
|
||||
const node = this._getTopologyNode(id);
|
||||
if (node?.ha_device_id) {
|
||||
navigate(`/config/devices/device/${node.ha_device_id}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node?.is_bridge) {
|
||||
this._toggleBridge(id);
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleBridge(id: string): void {
|
||||
const expanded = new Set(this._expandedBridges);
|
||||
if (!expanded.delete(id)) {
|
||||
expanded.add(id);
|
||||
}
|
||||
this._expandedBridges = expanded;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -830,17 +830,13 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
// the filters below only expose the selected options (as filter.value);
|
||||
// applying the filter must be done here
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-categories")) {
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) => {
|
||||
const category = this._entityReg.find(
|
||||
(reg) => reg.entity_id === entityId
|
||||
)?.categories.scene;
|
||||
|
||||
return (
|
||||
category !== undefined &&
|
||||
Array.isArray(filter.value) &&
|
||||
filter.value.includes(category)
|
||||
);
|
||||
});
|
||||
// category filter only allows a single selected option
|
||||
filteredEntityIds = filteredEntityIds.filter(
|
||||
(entityId) =>
|
||||
filter.value![0] ===
|
||||
this._entityReg.find((reg) => reg.entity_id === entityId)
|
||||
?.categories.scene
|
||||
);
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-labels")) {
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) =>
|
||||
this._entityReg
|
||||
|
||||
@@ -783,17 +783,13 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
// the filters below only expose the selected options (as filter.value);
|
||||
// applying the filter must be done here
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-categories")) {
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) => {
|
||||
const category = this._entityReg.find(
|
||||
(reg) => reg.entity_id === entityId
|
||||
)?.categories.script;
|
||||
|
||||
return (
|
||||
category !== undefined &&
|
||||
Array.isArray(filter.value) &&
|
||||
filter.value.includes(category)
|
||||
);
|
||||
});
|
||||
// category filter only allows a single selected option
|
||||
filteredEntityIds = filteredEntityIds.filter(
|
||||
(entityId) =>
|
||||
filter.value![0] ===
|
||||
this._entityReg.find((reg) => reg.entity_id === entityId)
|
||||
?.categories.script
|
||||
);
|
||||
} else if (isFilterUsed(key, filter, "ha-filter-labels")) {
|
||||
filteredEntityIds = filteredEntityIds.filter((entityId) =>
|
||||
this._entityReg
|
||||
|
||||
@@ -1,38 +1,26 @@
|
||||
import {
|
||||
mdiCellphone,
|
||||
mdiClose,
|
||||
mdiDotsVertical,
|
||||
mdiFileMoveOutline,
|
||||
mdiMonitor,
|
||||
} from "@mdi/js";
|
||||
import { mdiClose, mdiDotsVertical, mdiFileMoveOutline } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import { withViewTransition } from "../../../../common/util/view-transition";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dropdown";
|
||||
import "../../../../components/ha-dropdown-item";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/ha-tab-group";
|
||||
import "../../../../components/ha-tab-group-tab";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
|
||||
import {
|
||||
DEFAULT_SECTION_COLUMN_SPAN,
|
||||
isStrategySection,
|
||||
} from "../../../../data/lovelace/config/section";
|
||||
import { isStrategySection } from "../../../../data/lovelace/config/section";
|
||||
import type { LovelaceStrategyConfig } from "../../../../data/lovelace/config/strategy";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import { saveConfig } from "../../../../data/lovelace/config/types";
|
||||
@@ -44,7 +32,6 @@ import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
haStyleScrollbar,
|
||||
} from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import { cleanLegacyStrategyConfig } from "../../strategies/legacy-strategy";
|
||||
@@ -58,8 +45,6 @@ import {
|
||||
setAtPath,
|
||||
} from "../lovelace-path";
|
||||
import { showSelectViewDialog } from "../select-view/show-select-view-dialog";
|
||||
import "../../sections/hui-section";
|
||||
import "../../sections/hui-section-background";
|
||||
import "./hui-section-settings-editor";
|
||||
import "./hui-section-visibility-editor";
|
||||
import "./hui-section-strategy-element-editor";
|
||||
@@ -68,20 +53,11 @@ import type { ConfigChangedEvent } from "../hui-element-editor";
|
||||
import type { EditSectionDialogParams } from "./show-edit-section-dialog";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
import { getViewType } from "../../views/get-view-type";
|
||||
import { DEFAULT_MAX_COLUMNS, SECTIONS_VIEW_LAYOUT } from "../../views/const";
|
||||
import { SECTIONS_VIEW_LAYOUT } from "../../views/const";
|
||||
import { LOVELACE_EDIT_TOAST_ID } from "../../types";
|
||||
|
||||
const TABS = ["tab-strategy", "tab-appearance", "tab-visibility"] as const;
|
||||
|
||||
type PreviewMode = "desktop" | "mobile";
|
||||
|
||||
const PREVIEW_MODES: { mode: PreviewMode; iconPath: string }[] = [
|
||||
{ mode: "mobile", iconPath: mdiCellphone },
|
||||
{ mode: "desktop", iconPath: mdiMonitor },
|
||||
];
|
||||
|
||||
const TWO_COLUMNS_MIN_WIDTH = 2 * 320 + 3 * 32;
|
||||
|
||||
@customElement("hui-dialog-edit-section")
|
||||
export class HuiDialogEditSection
|
||||
extends DirtyStateProviderMixin<LovelaceSectionRawConfig>()(LitElement)
|
||||
@@ -91,8 +67,6 @@ export class HuiDialogEditSection
|
||||
|
||||
@property({ attribute: false }) public lovelace?: Lovelace;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public large = false;
|
||||
|
||||
@state() private _params?: EditSectionDialogParams;
|
||||
|
||||
@state() private _config?: LovelaceSectionRawConfig;
|
||||
@@ -109,15 +83,6 @@ export class HuiDialogEditSection
|
||||
|
||||
@state() private _yamlError = false;
|
||||
|
||||
@storage({
|
||||
key: "dashboardSectionPreviewMode",
|
||||
state: true,
|
||||
subscribe: false,
|
||||
})
|
||||
private _storedPreviewMode?: PreviewMode;
|
||||
|
||||
@state() private _defaultPreviewMode: PreviewMode = "mobile";
|
||||
|
||||
@query("ha-yaml-editor") private _editor?: HaYamlEditor;
|
||||
|
||||
@query("hui-section-strategy-element-editor")
|
||||
@@ -153,12 +118,6 @@ export class HuiDialogEditSection
|
||||
viewPath
|
||||
);
|
||||
this._initDirtyTracking({ type: "deep" }, this._config);
|
||||
this._defaultPreviewMode =
|
||||
window.innerWidth >= TWO_COLUMNS_MIN_WIDTH ? "desktop" : "mobile";
|
||||
}
|
||||
|
||||
private get _previewMode(): PreviewMode {
|
||||
return this._storedPreviewMode ?? this._defaultPreviewMode;
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
@@ -168,7 +127,6 @@ export class HuiDialogEditSection
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this.large = false;
|
||||
this._yamlMode = false;
|
||||
this._config = undefined;
|
||||
this._currTab = TABS[0];
|
||||
@@ -182,23 +140,19 @@ export class HuiDialogEditSection
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const heading = this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.header"
|
||||
);
|
||||
|
||||
const strategy = isStrategySection(this._config)
|
||||
? cleanLegacyStrategyConfig(this._config.strategy)
|
||||
: undefined;
|
||||
const heading = this._computeHeading(strategy);
|
||||
const tabs = strategy ? TABS : TABS.filter((tab) => tab !== "tab-strategy");
|
||||
const currentTab =
|
||||
!strategy && this._currTab === "tab-strategy"
|
||||
? "tab-appearance"
|
||||
: this._currTab;
|
||||
|
||||
const columnSpan = Math.min(
|
||||
this._config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
|
||||
this._viewConfig?.max_columns || DEFAULT_MAX_COLUMNS
|
||||
);
|
||||
const singleColumn = columnSpan === 1;
|
||||
const previewColumnSpan = this._previewMode === "desktop" ? columnSpan : 1;
|
||||
|
||||
let content: TemplateResult<1> | typeof nothing = nothing;
|
||||
|
||||
if (this._yamlMode) {
|
||||
@@ -248,151 +202,65 @@ export class HuiDialogEditSection
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
.width=${this.large ? "full" : "large"}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@keydown=${this._ignoreKeydown}
|
||||
@closed=${this._dialogClosed}
|
||||
class=${classMap({
|
||||
"yaml-mode": this._yamlMode,
|
||||
})}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="headerNavigationIcon"
|
||||
@click=${this._cancel}
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
<span
|
||||
slot="headerTitle"
|
||||
class="title-enlargeable"
|
||||
@click=${this._enlarge}
|
||||
>${heading}</span
|
||||
>
|
||||
<ha-dropdown
|
||||
slot="headerActionItems"
|
||||
placement="bottom-end"
|
||||
@closed=${stopPropagation}
|
||||
@wa-select=${this._handleAction}
|
||||
>
|
||||
<ha-dialog-header show-border slot="header">
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass!.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
slot="navigationIcon"
|
||||
@click=${this._cancel}
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item value="move-to-view">
|
||||
<ha-svg-icon slot="icon" .path=${mdiFileMoveOutline}></ha-svg-icon>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.move_to_view"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
<div class="content">
|
||||
<div class="element-editor ha-scrollbar">
|
||||
${
|
||||
!this._yamlMode
|
||||
? html`
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
${tabs.map(
|
||||
(tab) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.panel=${tab}
|
||||
.active=${currentTab === tab}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
)}
|
||||
</ha-tab-group>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this._yamlMode ? content : cache(content)}
|
||||
</div>
|
||||
<div class="element-preview ha-scrollbar">
|
||||
<div class="preview-header">
|
||||
<span id="preview-label">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.preview"
|
||||
)}
|
||||
</span>
|
||||
${
|
||||
singleColumn
|
||||
? nothing
|
||||
: html`
|
||||
<div
|
||||
class="preview-modes"
|
||||
role="group"
|
||||
aria-labelledby="preview-label"
|
||||
>
|
||||
${PREVIEW_MODES.map(
|
||||
({ mode, iconPath }) => html`
|
||||
<button
|
||||
id="preview-mode-${mode}"
|
||||
class=${classMap({
|
||||
"preview-mode": true,
|
||||
active: this._previewMode === mode,
|
||||
})}
|
||||
aria-pressed=${this._previewMode === mode}
|
||||
aria-label=${this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
|
||||
)}
|
||||
.mode=${mode}
|
||||
@click=${this._previewModeClicked}
|
||||
>
|
||||
<ha-svg-icon .path=${iconPath}></ha-svg-icon>
|
||||
</button>
|
||||
<ha-tooltip
|
||||
for="preview-mode-${mode}"
|
||||
placement="bottom"
|
||||
>
|
||||
${this.hass.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.preview_${mode}`
|
||||
)}
|
||||
</ha-tooltip>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<div class="preview-frame">
|
||||
<div
|
||||
class=${classMap({
|
||||
"section-preview": true,
|
||||
wide: previewColumnSpan > 1,
|
||||
})}
|
||||
style=${styleMap({
|
||||
"--column-span": previewColumnSpan,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class=${classMap({
|
||||
"section-container": true,
|
||||
"has-background": this._config.background !== undefined,
|
||||
blur: this._yamlError || this._strategyError,
|
||||
})}
|
||||
>
|
||||
${
|
||||
this._config.background !== undefined
|
||||
? html`
|
||||
<hui-section-background
|
||||
.hass=${this.hass}
|
||||
.background=${this._config.background}
|
||||
.theme=${this._config.theme}
|
||||
></hui-section-background>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<hui-section
|
||||
.hass=${this.hass}
|
||||
.config=${this._config}
|
||||
preview
|
||||
></hui-section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span slot="title">${heading}</span>
|
||||
<ha-dropdown
|
||||
slot="actionItems"
|
||||
placement="bottom-end"
|
||||
@closed=${stopPropagation}
|
||||
@wa-select=${this._handleAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass!.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item value="move-to-view">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFileMoveOutline}
|
||||
></ha-svg-icon>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.move_to_view"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
${
|
||||
!this._yamlMode
|
||||
? html`
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
${tabs.map(
|
||||
(tab) => html`
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.panel=${tab}
|
||||
.active=${currentTab === tab}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
`ui.panel.lovelace.editor.edit_section.${tab.replace("-", "_")}`
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
`
|
||||
)}
|
||||
</ha-tab-group>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-dialog-header>
|
||||
${this._yamlMode ? content : cache(content)}
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
@@ -451,37 +319,6 @@ export class HuiDialogEditSection
|
||||
this._updateDirtyState(this._config);
|
||||
}
|
||||
|
||||
private _computeHeading(strategy?: LovelaceStrategyConfig) {
|
||||
const header = this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.header"
|
||||
);
|
||||
if (!strategy) {
|
||||
return header;
|
||||
}
|
||||
const strategyName = this.hass.localize(
|
||||
`ui.panel.lovelace.editor.section_strategy.${strategy.type}.name`
|
||||
);
|
||||
if (!strategyName) {
|
||||
return header;
|
||||
}
|
||||
return this.hass.localize(
|
||||
"ui.panel.lovelace.editor.edit_section.typed_header",
|
||||
{ type: strategyName }
|
||||
);
|
||||
}
|
||||
|
||||
private _previewModeClicked(ev: Event) {
|
||||
this._storedPreviewMode = (
|
||||
ev.currentTarget as HTMLElement & { mode: PreviewMode }
|
||||
).mode;
|
||||
}
|
||||
|
||||
private _enlarge() {
|
||||
withViewTransition(() => {
|
||||
this.large = !this.large;
|
||||
});
|
||||
}
|
||||
|
||||
private _handleTabChanged(ev: CustomEvent): void {
|
||||
const newTab = ev.detail.name;
|
||||
if (newTab === this._currTab) {
|
||||
@@ -694,153 +531,15 @@ export class HuiDialogEditSection
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
--code-mirror-max-height: calc(100vh - 209px);
|
||||
}
|
||||
|
||||
ha-dialog {
|
||||
--dialog-content-padding: var(--ha-space-2);
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
ha-dialog.yaml-mode {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
.content {
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 451px) and (min-height: 501px) {
|
||||
:host([large]) .content {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content .preview-frame {
|
||||
padding: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.content .section-preview {
|
||||
--preview-column-width: 390px;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: var(--preview-column-width);
|
||||
}
|
||||
.content .section-preview.wide {
|
||||
max-width: none;
|
||||
width: calc(
|
||||
var(--column-span) * var(--preview-column-width) +
|
||||
(var(--column-span) - 1) *
|
||||
var(--ha-view-sections-column-gap, 32px)
|
||||
);
|
||||
}
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: 32px;
|
||||
padding: var(--ha-space-1) var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.preview-modes {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
background: var(--ha-color-fill-neutral-normal-resting);
|
||||
}
|
||||
.preview-mode {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
background: none;
|
||||
color: var(--secondary-text-color);
|
||||
cursor: pointer;
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.preview-mode:hover {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.preview-mode.active {
|
||||
color: var(--primary-text-color);
|
||||
background: var(--card-background-color);
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.2);
|
||||
}
|
||||
.preview-mode:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.content .element-editor {
|
||||
padding-inline-start: var(--ha-space-1);
|
||||
padding-inline-end: var(--ha-space-2);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
.content {
|
||||
flex-direction: row;
|
||||
max-height: var(--code-mirror-max-height);
|
||||
}
|
||||
.content > .element-editor {
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
max-width: 500px;
|
||||
}
|
||||
.content > .element-editor,
|
||||
.content > .element-preview {
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
height: auto;
|
||||
}
|
||||
.content .preview-frame {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
}
|
||||
.element-editor {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.blur {
|
||||
filter: blur(2px) grayscale(100%);
|
||||
}
|
||||
.element-preview {
|
||||
position: relative;
|
||||
height: max-content;
|
||||
background: var(--primary-background-color);
|
||||
padding: 4px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
}
|
||||
.section-container {
|
||||
position: relative;
|
||||
}
|
||||
.section-container.has-background {
|
||||
padding: var(--ha-space-2);
|
||||
border-radius: var(
|
||||
--ha-section-border-radius,
|
||||
var(--ha-border-radius-xl)
|
||||
);
|
||||
}
|
||||
.section-container hui-section {
|
||||
display: block;
|
||||
}
|
||||
ha-tab-group {
|
||||
margin-bottom: 16px;
|
||||
.gui-mode-button {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
ha-tab-group-tab {
|
||||
flex: 1;
|
||||
@@ -849,14 +548,6 @@ export class HuiDialogEditSection
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.gui-mode-button {
|
||||
margin-right: auto;
|
||||
margin-inline-end: auto;
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
.title-enlargeable {
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -19,71 +19,8 @@ import {
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import { DEFAULT_MAX_COLUMNS } from "../../views/const";
|
||||
|
||||
const COLUMN_SPAN_NAMES = ["single", "double", "triple"] as const;
|
||||
|
||||
const MAX_NAMED_COLUMN_SPAN = COLUMN_SPAN_NAMES.length + 1;
|
||||
|
||||
const computeColumnSpanLabel = (
|
||||
span: number,
|
||||
maxColumns: number,
|
||||
localize: LocalizeFunc
|
||||
) => {
|
||||
if (span >= maxColumns) {
|
||||
return localize(
|
||||
"ui.panel.lovelace.editor.edit_section.settings.column_span_options.full"
|
||||
);
|
||||
}
|
||||
return localize(
|
||||
`ui.panel.lovelace.editor.edit_section.settings.column_span_options.${COLUMN_SPAN_NAMES[span - 1]}`
|
||||
);
|
||||
};
|
||||
|
||||
const computeColumnSpanSchemas = (
|
||||
maxColumns: number,
|
||||
localize: LocalizeFunc
|
||||
) => {
|
||||
if (maxColumns <= 1) {
|
||||
return [];
|
||||
}
|
||||
if (maxColumns > MAX_NAMED_COLUMN_SPAN) {
|
||||
return [
|
||||
{
|
||||
name: "column_span",
|
||||
selector: {
|
||||
number: { min: 1, max: maxColumns, slider_ticks: true },
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
const options = Array.from({ length: maxColumns }, (_, i) => {
|
||||
const span = i + 1;
|
||||
return {
|
||||
value: String(span),
|
||||
label: computeColumnSpanLabel(span, maxColumns, localize),
|
||||
image: {
|
||||
src: `/static/images/form/section_column_span_${span}_of_${maxColumns}.svg`,
|
||||
src_dark: `/static/images/form/section_column_span_${span}_of_${maxColumns}_dark.svg`,
|
||||
flip_rtl: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
return [
|
||||
{
|
||||
name: "column_span",
|
||||
selector: {
|
||||
select: {
|
||||
mode: "box",
|
||||
box_max_columns:
|
||||
maxColumns === MAX_NAMED_COLUMN_SPAN ? 2 : maxColumns,
|
||||
options,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
};
|
||||
|
||||
interface SettingsData {
|
||||
column_span?: string | number;
|
||||
column_span?: number;
|
||||
background_enabled?: boolean;
|
||||
background_color?: string;
|
||||
background_opacity?: number;
|
||||
@@ -99,30 +36,20 @@ export class HuiDialogEditSection extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public viewConfig!: LovelaceViewConfig;
|
||||
|
||||
private get _maxColumns() {
|
||||
return this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS;
|
||||
}
|
||||
|
||||
private get _displayedColumnSpan() {
|
||||
return String(
|
||||
Math.min(
|
||||
this.config.column_span ?? DEFAULT_SECTION_COLUMN_SPAN,
|
||||
this._maxColumns
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private get _columnSpanFormValue() {
|
||||
if (this._maxColumns > MAX_NAMED_COLUMN_SPAN) {
|
||||
return Number(this._displayedColumnSpan);
|
||||
}
|
||||
return this._displayedColumnSpan;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(localize: LocalizeFunc, maxColumns: number) =>
|
||||
(maxColumns: number, localize: LocalizeFunc) =>
|
||||
[
|
||||
...computeColumnSpanSchemas(maxColumns, localize),
|
||||
{
|
||||
name: "column_span",
|
||||
default: DEFAULT_SECTION_COLUMN_SPAN,
|
||||
selector: {
|
||||
number: {
|
||||
min: 1,
|
||||
max: maxColumns,
|
||||
slider_ticks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "background_enabled",
|
||||
selector: { boolean: {} },
|
||||
@@ -182,14 +109,17 @@ export class HuiDialogEditSection extends LitElement {
|
||||
const backgroundEnabled = background !== undefined;
|
||||
|
||||
const data: SettingsData = {
|
||||
column_span: this._columnSpanFormValue,
|
||||
column_span: this.config.column_span,
|
||||
background_enabled: backgroundEnabled,
|
||||
background_color: background?.color,
|
||||
background_opacity: background?.opacity,
|
||||
theme: this.config.theme,
|
||||
};
|
||||
|
||||
const schema = this._schema(this._localize, this._maxColumns);
|
||||
const schema = this._schema(
|
||||
this.viewConfig.max_columns || DEFAULT_MAX_COLUMNS,
|
||||
this._localize
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
@@ -224,15 +154,10 @@ export class HuiDialogEditSection extends LitElement {
|
||||
...this.config,
|
||||
};
|
||||
|
||||
if (newData.column_span === undefined) {
|
||||
if (newData.column_span) {
|
||||
newConfig.column_span = newData.column_span;
|
||||
} else {
|
||||
delete newConfig.column_span;
|
||||
} else if (String(newData.column_span) !== this._displayedColumnSpan) {
|
||||
const columnSpan = Number(newData.column_span);
|
||||
if (columnSpan === DEFAULT_SECTION_COLUMN_SPAN) {
|
||||
delete newConfig.column_span;
|
||||
} else {
|
||||
newConfig.column_span = columnSpan;
|
||||
}
|
||||
}
|
||||
|
||||
if (newData.background_enabled) {
|
||||
|
||||
@@ -279,8 +279,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
}
|
||||
|
||||
private async _initializeConfig() {
|
||||
const config = this.config;
|
||||
let sectionConfig = { ...config };
|
||||
let sectionConfig = { ...this.config };
|
||||
let isStrategy = false;
|
||||
|
||||
if (isStrategySection(sectionConfig)) {
|
||||
@@ -289,9 +288,6 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
sectionConfig,
|
||||
this.hass!
|
||||
);
|
||||
if (config !== this.config) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sectionConfig = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import {
|
||||
mdiCheckAll,
|
||||
mdiCheckCircleOutline,
|
||||
mdiCompassOutline,
|
||||
mdiDotsVertical,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
sortedMarketplaceFilterQueryString,
|
||||
} from "../../../common/url/marketplace-query-params";
|
||||
import { shallowEqual } from "../../../common/util/shallow-equal";
|
||||
import { groupActionsSlot } from "../../../components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
SelectionChangedEvent,
|
||||
@@ -405,6 +407,14 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
@sorting-changed=${this._tableSettings.sortingChanged}
|
||||
>
|
||||
${this._renderToolbar()}
|
||||
<ha-icon-button
|
||||
slot=${groupActionsSlot(
|
||||
this._i18n.localize("ui.panel.marketplace.repository_status.new")
|
||||
)}
|
||||
.label=${this._i18n.localize("ui.panel.marketplace.menu.dismiss")}
|
||||
.path=${mdiCheckAll}
|
||||
@click=${this._dismissNew}
|
||||
></ha-icon-button>
|
||||
${
|
||||
this.tab === "installed"
|
||||
? html`<ha-assist-chip
|
||||
@@ -942,18 +952,43 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
showMarketplaceAddFromLink(this, this._i18n.localize, this.marketplace);
|
||||
}
|
||||
|
||||
private async _dismissNew() {
|
||||
try {
|
||||
await dismissNewMarketplaceRepositories(
|
||||
this._api,
|
||||
this.marketplace.info.categories
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
showAlertDialog(this, {
|
||||
title: this._i18n.localize("ui.panel.marketplace.dialog.error.title"),
|
||||
text: marketplaceErrorMessage(err, this._i18n.localize),
|
||||
});
|
||||
}
|
||||
private _dismissNew() {
|
||||
showConfirmationDialog(this, {
|
||||
title: this._i18n.localize(
|
||||
"ui.panel.marketplace.dialog.dismiss_new.title"
|
||||
),
|
||||
// Dismissing clears all of them, not only the ones the filters show
|
||||
text: this._i18n.localize(
|
||||
"ui.panel.marketplace.dialog.dismiss_new.text",
|
||||
{
|
||||
count: this.marketplace.repositories.filter(
|
||||
(repository) => repository.new
|
||||
).length,
|
||||
}
|
||||
),
|
||||
confirmText: this._i18n.localize(
|
||||
"ui.panel.marketplace.dialog.dismiss_new.confirm"
|
||||
),
|
||||
dismissText: this._i18n.localize("ui.common.cancel"),
|
||||
action: async () => {
|
||||
try {
|
||||
await dismissNewMarketplaceRepositories(
|
||||
this._api,
|
||||
this.marketplace.info.categories
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
showAlertDialog(this, {
|
||||
title: this._i18n.localize(
|
||||
"ui.panel.marketplace.dialog.error.title"
|
||||
),
|
||||
text: marketplaceErrorMessage(err, this._i18n.localize),
|
||||
});
|
||||
// The alert reuses the confirmation's dialog, which only stays open
|
||||
// when its action fails
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _statusStates = memoize(
|
||||
|
||||
@@ -292,15 +292,10 @@ export const getMyRedirects = (): Redirects => ({
|
||||
redirect: "/config/integrations/integration",
|
||||
params: {
|
||||
domain: "string",
|
||||
search: "string?",
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
redirect: "/config/integrations/dashboard",
|
||||
params: {
|
||||
search: "string?",
|
||||
status: "string?",
|
||||
},
|
||||
redirect: "/config/integrations",
|
||||
},
|
||||
config_mqtt: {
|
||||
component: "mqtt",
|
||||
@@ -888,13 +883,7 @@ class HaPanelMy extends LitElement {
|
||||
this._redirect!.redirect === "/config/integrations/integration" &&
|
||||
params.domain === "hacs"
|
||||
) {
|
||||
const marketplaceParams = new URLSearchParams(
|
||||
this._createRedirectParams()
|
||||
);
|
||||
|
||||
marketplaceParams.set("domain", "marketplace");
|
||||
|
||||
return `${this._redirect!.redirect}?${marketplaceParams}`;
|
||||
return `${this._redirect!.redirect}?${createSearchParam({ domain: "marketplace" })}`;
|
||||
}
|
||||
|
||||
// Special case for supervisor_app/supervisor_addon: use path-based URL
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export const mdiSendspin =
|
||||
"M9.44 13.751C9.323 13.868 9.133 13.86 9.022 13.737C8.038 12.647 7.5 11.251 7.5 9.772C7.5 8.192 8.115 6.704 9.234 5.587C11.469 3.352 15.061 3.282 17.382 5.376C17.505 5.487 17.513 5.678 17.396 5.795L16.855 6.336C16.748 6.443 16.576 6.45 16.466 6.35C15.2 5.221 13.408 4.862 11.668 5.514C11.659 5.517 11.648 5.522 11.639 5.526C9.863 6.336 8.835 7.976 8.835 9.772C8.835 10.907 9.246 11.98 9.995 12.819C10.095 12.932 10.088 13.102 9.981 13.208L9.44 13.749V13.751ZM10.911 11.836C10.185 10.956 9.96 9.748 10.388 8.572C10.393 8.561 10.398 8.549 10.402 8.538C11.363 6.386 13.899 5.962 15.482 7.266C15.613 7.374 15.621 7.572 15.502 7.69L14.958 8.234C14.856 8.336 14.695 8.344 14.58 8.256C13.832 7.683 12.753 7.739 12.069 8.423C11.709 8.783 11.51 9.263 11.51 9.774C11.51 10.198 11.648 10.602 11.902 10.933C11.99 11.049 11.984 11.21 11.881 11.313L11.338 11.856C11.218 11.976 11.021 11.967 10.913 11.836H10.911ZM3.939 19.252C3.822 19.369 3.631 19.361 3.52 19.238C2.506 18.115 2 16.694 2 15.275C2 13.855 2.506 12.435 3.52 11.312C3.631 11.189 3.822 11.181 3.939 11.298L4.203 11.562L4.48 11.839C4.586 11.945 4.594 12.116 4.494 12.228C3.722 13.094 3.336 14.185 3.336 15.276C3.336 16.367 3.722 17.459 4.494 18.325C4.594 18.437 4.585 18.607 4.48 18.714L4.203 18.99L3.939 19.255V19.252ZM5.834 17.357C5.716 17.475 5.518 17.469 5.412 17.34C4.931 16.76 4.67 16.038 4.67 15.275C4.67 14.512 4.931 13.79 5.412 13.21C5.518 13.08 5.716 13.074 5.834 13.193L6.378 13.737C6.481 13.84 6.488 14.001 6.399 14.117C6.146 14.448 6.007 14.852 6.007 15.276C6.007 15.7 6.146 16.105 6.399 16.436C6.488 16.551 6.481 16.712 6.378 16.816L5.834 17.36V17.357ZM20.48 12.69C20.369 12.813 20.178 12.821 20.061 12.704L19.797 12.439L19.52 12.163C19.414 12.056 19.406 11.886 19.506 11.774C20.278 10.907 20.664 9.816 20.664 8.725C20.664 7.634 20.278 6.543 19.506 5.677C19.406 5.564 19.415 5.394 19.52 5.288L19.797 5.011L20.061 4.747C20.178 4.63 20.369 4.637 20.48 4.76C21.494 5.883 22 7.303 22 8.724C22 10.145 21.494 11.564 20.48 12.687V12.69ZM18.168 10.809L17.623 10.265C17.52 10.161 17.514 10 17.602 9.885C17.856 9.553 17.994 9.149 17.994 8.725C17.994 8.301 17.856 7.897 17.602 7.566C17.514 7.45 17.52 7.289 17.623 7.186L18.168 6.642C18.286 6.523 18.484 6.529 18.59 6.658C19.07 7.239 19.332 7.961 19.332 8.724C19.332 9.487 19.07 10.208 18.59 10.789C18.484 10.918 18.286 10.924 18.168 10.806V10.809ZM10.583 20.144C9.162 20.144 7.743 19.638 6.62 18.624C6.497 18.513 6.489 18.322 6.606 18.205L7.147 17.664C7.253 17.557 7.424 17.55 7.536 17.65C9.334 19.252 12.101 19.192 13.824 17.468C15.548 15.744 15.608 12.977 14.007 11.179C13.906 11.067 13.916 10.897 14.02 10.79L14.561 10.249C14.678 10.132 14.87 10.14 14.981 10.263C17.075 12.584 17.005 16.177 14.77 18.413C13.616 19.566 12.1 20.144 10.585 20.144H10.583ZM10.583 17.472C9.849 17.472 9.117 17.226 8.519 16.734C8.39 16.627 8.383 16.43 8.501 16.311L9.045 15.767C9.147 15.665 9.308 15.658 9.423 15.746C10.171 16.319 11.25 16.264 11.934 15.579C12.618 14.895 12.674 13.816 12.101 13.068C12.013 12.953 12.021 12.792 12.122 12.69L12.666 12.146C12.785 12.027 12.983 12.033 13.089 12.164C14.14 13.438 14.071 15.333 12.879 16.524C12.247 17.156 11.414 17.474 10.583 17.474V17.472Z";
|
||||
@@ -3482,6 +3482,9 @@
|
||||
"unknown_error": "An unknown error happened when saving the category",
|
||||
"confirm_delete": "Delete category?",
|
||||
"confirm_delete_text": "This will delete the category and unassign everything that is currently assigned to it."
|
||||
},
|
||||
"filter": {
|
||||
"show_all": "Show all"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
@@ -5756,7 +5759,9 @@
|
||||
"event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]",
|
||||
"start": "Event start",
|
||||
"end": "Event end",
|
||||
"offset": "Offset",
|
||||
"offset": "Offset (optional)",
|
||||
"before": "Before",
|
||||
"after": "After",
|
||||
"description": {
|
||||
"picker": "Triggers when a calendar event starts or ends.",
|
||||
"full": "When{offsetChoice, select, \n before { it's {offset} before}\n after { it's {offset} after}\n other {}\n} a calendar event{eventChoice, select, \n start { starts}\n end { ends}\n other { starts or ends}\n}{hasCalendar, select, \n true { in {calendar}}\n other {}\n}"
|
||||
@@ -5885,7 +5890,7 @@
|
||||
"event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]",
|
||||
"sunrise": "Sunrise",
|
||||
"sunset": "Sunset",
|
||||
"offset": "Offset",
|
||||
"offset": "Offset in seconds or HH:MM:SS (optional)",
|
||||
"description": {
|
||||
"picker": "Triggers when the sun sets or rises.",
|
||||
"sets": "When the sun sets{hasDuration, select, \n true { offset by {duration}} \n other {}\n }",
|
||||
@@ -7427,7 +7432,7 @@
|
||||
"setup_capability_audio_title": "Stream audio via Sendspin",
|
||||
"setup_capability_audio_short": "Synchronized multi-room audio",
|
||||
"setup_capability_audio_description": "Set up Sendspin and Music Assistant to turn this device into a synchronized music player. Music Assistant manages your audio library and uses the Sendspin streaming protocol to keep audio in sync across smart speakers in your home. Without this setup, Home Assistant can still play audio on this device, but not in sync with other speakers.",
|
||||
"setup_capability_connectivity_title": "Connect a Z-Wave device",
|
||||
"setup_capability_connectivity_title": "Connect a Z-Wave adapter",
|
||||
"setup_capability_connectivity_short": "Network connectivity for ZWA-2",
|
||||
"setup_capability_connectivity_description": "Plug a Connect ZWA-2 into the USB port to enable Z-Wave connectivity over Ethernet or Wi-Fi. Get your antenna in an optimal location for better device responsiveness.",
|
||||
"setup_capability_serial_title": "Control a device over serial",
|
||||
@@ -7460,7 +7465,7 @@
|
||||
"setup_status_completed": "Completed",
|
||||
"setup_zwave": "Set up Z-Wave",
|
||||
"setup_zwave_configured": "Configured",
|
||||
"setup_what_are_adapters": "What is this device?",
|
||||
"setup_what_are_adapters": "What is this adapter?",
|
||||
"setup_serial_configured": "Configured",
|
||||
"setup_serial_offline": "Offline",
|
||||
"setup_serial_port_ttl": "TTL",
|
||||
@@ -9116,12 +9121,6 @@
|
||||
"online": "Online",
|
||||
"unknown_device": "Unknown device",
|
||||
"unknown_devices": "Unknown devices",
|
||||
"bridged": "Bridged",
|
||||
"bridged_device": "Bridged device",
|
||||
"bridged_devices": "Bridged devices",
|
||||
"show_bridged_devices": "Select to show the bridged devices",
|
||||
"hide_bridged_devices": "Select to hide the bridged devices",
|
||||
"bridged_by": "[%key:ui::panel::config::integrations::config_entry::via%]",
|
||||
"node": "Node {node_id}",
|
||||
"node_id": "Node ID",
|
||||
"network": "Network",
|
||||
@@ -10430,17 +10429,8 @@
|
||||
"error_section": {
|
||||
"title": "Error"
|
||||
},
|
||||
"section_strategy": {
|
||||
"common-controls": {
|
||||
"name": "Common controls"
|
||||
}
|
||||
},
|
||||
"edit_section": {
|
||||
"header": "Section configuration",
|
||||
"typed_header": "{type} section configuration",
|
||||
"preview": "Preview",
|
||||
"preview_desktop": "Desktop preview",
|
||||
"preview_mobile": "Mobile preview",
|
||||
"header": "Edit section",
|
||||
"tab_strategy": "Configuration",
|
||||
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
|
||||
"tab_appearance": "Appearance",
|
||||
@@ -10448,13 +10438,7 @@
|
||||
"edit_yaml": "[%key:ui::panel::lovelace::editor::edit_view::edit_yaml%]",
|
||||
"settings": {
|
||||
"column_span": "Width",
|
||||
"column_span_helper": "Smaller screens may show it narrower, and phones stack sections one under the other.",
|
||||
"column_span_options": {
|
||||
"single": "Single",
|
||||
"double": "Double",
|
||||
"triple": "Triple",
|
||||
"full": "Full width"
|
||||
},
|
||||
"column_span_helper": "Larger sections will be made smaller to fit the display. (for example, on mobile devices)",
|
||||
"background": "Background options",
|
||||
"background_enabled": "Background",
|
||||
"background_enabled_helper": "Display a colored background behind the section",
|
||||
@@ -12966,6 +12950,11 @@
|
||||
"error": {
|
||||
"title": "Error"
|
||||
},
|
||||
"dismiss_new": {
|
||||
"title": "Dismiss new repositories?",
|
||||
"text": "{count, plural,\n one {The new repository will no longer be marked as new.}\n other {The {count} new repositories will no longer be marked as new.}\n}",
|
||||
"confirm": "Dismiss"
|
||||
},
|
||||
"reload": {
|
||||
"description": "You need to reload your browser for the updated resources to be used.",
|
||||
"confirm": "Do you want to do that now?"
|
||||
|
||||
@@ -65,9 +65,9 @@ describe("automations query params", () => {
|
||||
).toBe(automationsFilterQueryString({}));
|
||||
});
|
||||
|
||||
it("keeps every category", () => {
|
||||
it("keeps one category, as the category filter picks one", () => {
|
||||
expect(
|
||||
automationsLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
|
||||
).toEqual(filter(["a", "b"]));
|
||||
).toEqual(filter(["a"]));
|
||||
});
|
||||
});
|
||||
@@ -61,9 +61,9 @@ describe("scenes query params", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps every category", () => {
|
||||
it("keeps one category, as the category filter picks one", () => {
|
||||
expect(
|
||||
scenesLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
|
||||
).toEqual(filter(["a", "b"]));
|
||||
).toEqual(filter(["a"]));
|
||||
});
|
||||
});
|
||||
@@ -65,9 +65,9 @@ describe("scripts query params", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps every category", () => {
|
||||
it("keeps one category, as the category filter picks one", () => {
|
||||
expect(
|
||||
scriptsLinkFromUrl("?category=a,b")?.filters["ha-filter-categories"]
|
||||
).toEqual(filter(["a", "b"]));
|
||||
).toEqual(filter(["a"]));
|
||||
});
|
||||
});
|
||||
@@ -108,83 +108,3 @@ describe("normalizeAutomationConfig deprecated option reporting", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateAutomationConfig offset type", () => {
|
||||
const migrateTrigger = (trigger: Record<string, unknown>) =>
|
||||
(
|
||||
migrateAutomationConfig({ triggers: [trigger as any] }).triggers as any
|
||||
)[0];
|
||||
|
||||
it.each([
|
||||
["sun.sunrise", { hours: 1 }, { hours: -1 }],
|
||||
[
|
||||
"calendar.event_started",
|
||||
{ hours: 1, minutes: 30 },
|
||||
{ hours: -1, minutes: -30 },
|
||||
],
|
||||
["sun.sunset", { hours: -1 }, { hours: 1 }],
|
||||
[
|
||||
"sun.dawn",
|
||||
"00:30:00",
|
||||
{ hours: 0, minutes: -30, seconds: 0, milliseconds: 0 },
|
||||
],
|
||||
])(
|
||||
"folds a `before` offset type into the sign for %s",
|
||||
(trigger, offset, expected) => {
|
||||
expect(
|
||||
migrateTrigger({ trigger, options: { offset, offset_type: "before" } })
|
||||
).toEqual({ trigger, options: { offset: expected } });
|
||||
}
|
||||
);
|
||||
|
||||
it("drops an `after` offset type and keeps the offset", () => {
|
||||
expect(
|
||||
migrateTrigger({
|
||||
trigger: "calendar.event_ended",
|
||||
options: { offset: { hours: 1 }, offset_type: "after" },
|
||||
})
|
||||
).toEqual({
|
||||
trigger: "calendar.event_ended",
|
||||
options: { offset: { hours: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("drops the offset type when there is no offset", () => {
|
||||
expect(
|
||||
migrateTrigger({
|
||||
trigger: "sun.sunrise",
|
||||
options: { offset_type: "before" },
|
||||
})
|
||||
).toEqual({ trigger: "sun.sunrise", options: {} });
|
||||
});
|
||||
|
||||
it("leaves an offset it cannot read untouched", () => {
|
||||
const options = { offset: "{{ offset }}", offset_type: "before" };
|
||||
expect(
|
||||
migrateTrigger({ trigger: "sun.sunrise", options: { ...options } })
|
||||
).toEqual({ trigger: "sun.sunrise", options });
|
||||
});
|
||||
|
||||
it("leaves other integrations untouched", () => {
|
||||
const options = { offset: { hours: 1 }, offset_type: "before" };
|
||||
expect(
|
||||
migrateTrigger({ trigger: "custom.event", options: { ...options } })
|
||||
).toEqual({ trigger: "custom.event", options });
|
||||
});
|
||||
|
||||
it("does not flag the migration as deprecated", () => {
|
||||
const report: AutomationMigrationReport = { deprecated: false };
|
||||
migrateAutomationConfig(
|
||||
{
|
||||
triggers: [
|
||||
{
|
||||
trigger: "sun.sunrise",
|
||||
options: { offset: { hours: 1 }, offset_type: "before" },
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
report
|
||||
);
|
||||
expect(report.deprecated).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
import { IntlMessageFormat } from "intl-messageformat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ForDict } from "../../src/data/automation";
|
||||
import { describeTrigger } from "../../src/data/automation_i18n";
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
TimeZone,
|
||||
} from "../../src/data/translation";
|
||||
import en from "../../src/translations/en.json";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
type TranslationNode = string | { [key: string]: TranslationNode };
|
||||
|
||||
const localize = (key: string, values?: Record<string, unknown>) => {
|
||||
const message = key
|
||||
.split(".")
|
||||
.reduce<TranslationNode | undefined>(
|
||||
(translations, part) =>
|
||||
typeof translations === "object" ? translations[part] : undefined,
|
||||
en as TranslationNode
|
||||
);
|
||||
return typeof message === "string"
|
||||
? (new IntlMessageFormat(message, "en").format(values) as string)
|
||||
: "";
|
||||
};
|
||||
|
||||
const hass = {
|
||||
localize,
|
||||
locale: {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.twenty_four,
|
||||
date_format: DateFormat.language,
|
||||
first_weekday: FirstWeekday.language,
|
||||
time_zone: TimeZone.local,
|
||||
},
|
||||
config: { time_zone: "Etc/UTC" },
|
||||
states: {},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
describe("legacy calendar trigger offset description", () => {
|
||||
it.each([
|
||||
[
|
||||
"-01:30:00",
|
||||
"When it's 1 hour, 30 minutes before a calendar event starts",
|
||||
],
|
||||
[
|
||||
{ hours: 1, minutes: -30 },
|
||||
"When it's 30 minutes after a calendar event starts",
|
||||
],
|
||||
[
|
||||
{ seconds: 1.5 },
|
||||
"When it's 1 second, 500 milliseconds after a calendar event starts",
|
||||
],
|
||||
[{ hours: 0, minutes: 0, seconds: 0 }, "When a calendar event starts"],
|
||||
])("describes %j", (offset, expected) => {
|
||||
expect(
|
||||
describeTrigger(
|
||||
{
|
||||
trigger: "calendar",
|
||||
event: "start",
|
||||
entity_id: "",
|
||||
offset: offset as string | ForDict,
|
||||
},
|
||||
hass,
|
||||
[]
|
||||
)
|
||||
).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy sun trigger offset description", () => {
|
||||
it.each([
|
||||
[{ minutes: -30 }, "When the sun sets offset by -30:00"],
|
||||
[{ hours: 1, minutes: -30 }, "When the sun sets offset by +30:00"],
|
||||
[{ hours: 0, minutes: 0, seconds: 0 }, "When the sun sets"],
|
||||
])("describes %j", (offset, expected) => {
|
||||
expect(
|
||||
describeTrigger({ trigger: "sun", event: "sunset", offset }, hass, [])
|
||||
).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
} from "../../../../../src/data/matter";
|
||||
import {
|
||||
createMatterNetworkChartData,
|
||||
getBridgedDeviceId,
|
||||
getTopologyNodeCategory,
|
||||
getTopologyNodeName,
|
||||
networkToColorVar,
|
||||
@@ -130,17 +129,6 @@ describe("getTopologyNodeCategory", () => {
|
||||
expect(
|
||||
getTopologyNodeCategory(node({ id: "unknown_1", kind: "thread_unknown" }))
|
||||
).toBe(6);
|
||||
expect(
|
||||
getTopologyNodeCategory(
|
||||
node({ id: "8", network_type: "ethernet", is_bridge: true })
|
||||
)
|
||||
).toBe(7);
|
||||
// an offline bridge reads as offline like any other node
|
||||
expect(
|
||||
getTopologyNodeCategory(
|
||||
node({ id: "9", is_bridge: true, available: false })
|
||||
)
|
||||
).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,7 +283,7 @@ describe("createMatterNetworkChartData", () => {
|
||||
element
|
||||
);
|
||||
|
||||
expect(data.categories).toHaveLength(9);
|
||||
expect(data.categories).toHaveLength(7);
|
||||
// Home Assistant root + the 3 topology nodes
|
||||
expect(data.nodes).toHaveLength(4);
|
||||
|
||||
@@ -811,150 +799,4 @@ describe("createMatterNetworkChartData", () => {
|
||||
expect(data.nodes.find((n) => n.id === "1")!.context).toBe("NetA");
|
||||
expect(data.nodes.find((n) => n.id === "2")!.context).toBe("NetB");
|
||||
});
|
||||
|
||||
it("hangs the devices behind a bridge off the bridge node", () => {
|
||||
const hass = mockHass(
|
||||
{
|
||||
bridge: { id: "bridge", name: "Gateway" },
|
||||
couch: {
|
||||
id: "couch",
|
||||
name: "Couch",
|
||||
via_device_id: "bridge",
|
||||
area_id: "living_room",
|
||||
},
|
||||
window: {
|
||||
id: "window",
|
||||
name: "Window",
|
||||
name_by_user: "Bedroom window",
|
||||
via_device_id: "bridge",
|
||||
},
|
||||
disabled: {
|
||||
id: "disabled",
|
||||
name: "Disabled",
|
||||
via_device_id: "bridge",
|
||||
disabled_by: "user",
|
||||
},
|
||||
other: { id: "other", name: "Other", via_device_id: "plug" },
|
||||
plug: { id: "plug", name: "Plug" },
|
||||
},
|
||||
{ living_room: { area_id: "living_room", name: "Living room" } }
|
||||
);
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({
|
||||
id: "3",
|
||||
node_id: 3,
|
||||
network_type: "ethernet",
|
||||
is_bridge: true,
|
||||
ha_device_id: "bridge",
|
||||
}),
|
||||
// only a node flagged as a bridge exposes bridged devices
|
||||
node({
|
||||
id: "4",
|
||||
node_id: 4,
|
||||
network_type: "wifi",
|
||||
ha_device_id: "plug",
|
||||
}),
|
||||
]),
|
||||
hass,
|
||||
element,
|
||||
new Set(["3"])
|
||||
);
|
||||
|
||||
// the bridge is drawn as an aggregator carrying its bridged device count
|
||||
const bridge = data.nodes.find((n) => n.id === "3")!;
|
||||
expect(bridge.category).toBe(7);
|
||||
expect(bridge.symbol).toMatch(/^image:\/\/data:image\/svg\+xml/);
|
||||
expect(decodeURIComponent(bridge.symbol!)).toContain(">2</text>");
|
||||
expect(data.nodes.find((n) => n.id === "4")!.symbol).toBe("circle");
|
||||
|
||||
const bridged = data.nodes.filter((n) => n.category === 8);
|
||||
expect(bridged.map((n) => [n.id, n.name, n.context])).toEqual([
|
||||
["bridged_couch", "Couch", "Living room"],
|
||||
["bridged_window", "Bedroom window", undefined],
|
||||
]);
|
||||
expect(getBridgedDeviceId("bridged_couch")).toBe("couch");
|
||||
expect(getBridgedDeviceId("3")).toBeUndefined();
|
||||
expect(data.categories).toHaveLength(9);
|
||||
|
||||
const bridgedLinks = data.links.filter((l) => l.source === "3");
|
||||
expect(bridgedLinks.map((l) => l.target)).toEqual([
|
||||
"bridged_couch",
|
||||
"bridged_window",
|
||||
]);
|
||||
// not a directional radio link, and it pulls the device to its bridge
|
||||
expect(bridgedLinks[0].symbol).toBe("none");
|
||||
expect(bridgedLinks[0].lineStyle?.type).toBe("solid");
|
||||
expect(bridgedLinks[0].ignoreForceLayout).toBeFalsy();
|
||||
});
|
||||
|
||||
it("dashes the links to the devices behind an offline bridge", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({
|
||||
id: "3",
|
||||
node_id: 3,
|
||||
network_type: "ethernet",
|
||||
is_bridge: true,
|
||||
available: false,
|
||||
ha_device_id: "bridge",
|
||||
}),
|
||||
]),
|
||||
mockHass({
|
||||
bridge: { id: "bridge", name: "Gateway" },
|
||||
couch: { id: "couch", name: "Couch", via_device_id: "bridge" },
|
||||
}),
|
||||
element,
|
||||
new Set(["3"])
|
||||
);
|
||||
|
||||
const link = data.links.find((l) => l.target === "bridged_couch")!;
|
||||
expect(link.source).toBe("3");
|
||||
expect(link.lineStyle?.type).toBe("dashed");
|
||||
});
|
||||
|
||||
it("keeps the devices behind a collapsed bridge out of the graph", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({
|
||||
id: "3",
|
||||
node_id: 3,
|
||||
network_type: "ethernet",
|
||||
is_bridge: true,
|
||||
ha_device_id: "bridge",
|
||||
}),
|
||||
]),
|
||||
mockHass({
|
||||
bridge: { id: "bridge", name: "Gateway" },
|
||||
couch: { id: "couch", name: "Couch", via_device_id: "bridge" },
|
||||
}),
|
||||
element
|
||||
);
|
||||
|
||||
expect(data.nodes.filter((n) => n.category === 8)).toHaveLength(0);
|
||||
expect(data.links.filter((l) => l.source === "3")).toHaveLength(0);
|
||||
// the count still tells what the bridge would expand to
|
||||
const bridge = data.nodes.find((n) => n.id === "3")!;
|
||||
expect(decodeURIComponent(bridge.symbol!)).toContain(">1</text>");
|
||||
});
|
||||
|
||||
it("draws a bridge without a device without a count", () => {
|
||||
const data = createMatterNetworkChartData(
|
||||
topology([
|
||||
node({
|
||||
id: "3",
|
||||
node_id: 3,
|
||||
network_type: "ethernet",
|
||||
is_bridge: true,
|
||||
}),
|
||||
]),
|
||||
mockHass(),
|
||||
element
|
||||
);
|
||||
|
||||
const bridge = data.nodes.find((n) => n.id === "3")!;
|
||||
expect(bridge.category).toBe(7);
|
||||
expect(decodeURIComponent(bridge.symbol!)).not.toContain("<text");
|
||||
expect(data.nodes.filter((n) => n.category === 8)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||