Compare commits

...
17 changed files with 2004 additions and 1581 deletions

No files matched your search

@@ -0,0 +1,35 @@
import {
createQueryString,
decodeQueryParams,
type QueryParamConfig,
type QueryParamValues,
} from "./query-params";
export const voiceAssistantQueryParamConfig = {
list: ["assistants"],
} as const satisfies QueryParamConfig;
export type VoiceAssistantQueryParams = QueryParamValues<
typeof voiceAssistantQueryParamConfig
>;
export const decodeVoiceAssistantQueryParams = (
searchParams: Record<string, string>
): VoiceAssistantQueryParams => {
const params = decodeQueryParams(
searchParams,
voiceAssistantQueryParamConfig
);
// An explicit empty list must not fall back to all available assistants.
if (searchParams.assistants === "") {
params.assistants = [];
}
return params;
};
export const createVoiceAssistantQueryString = (
values: VoiceAssistantQueryParams
): string =>
values.assistants?.length === 0
? "assistants="
: createQueryString(values, voiceAssistantQueryParamConfig);
+100 -20
View File
@@ -1,3 +1,4 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { consume, type ContextType } from "@lit/context";
import { mdiArrowDown, mdiArrowUp, mdiChevronUp } from "@mdi/js";
import deepClone from "deep-clone-simple";
@@ -125,6 +126,11 @@ export class HaDataTable extends LitElement {
@property({ type: Boolean }) public selectable = false;
@property({ attribute: false }) public selectionScope?: ReadonlySet<string>;
@property({ type: Boolean, attribute: "select-on-row-click" })
public selectOnRowClick = false;
@property({ type: Boolean }) public clickable = false;
/**
@@ -205,12 +211,15 @@ export class HaDataTable extends LitElement {
}
public selectAll(extraFilter?: (row: DataTableRowData) => boolean): void {
this._checkedRows = (this._filteredData || [])
const selectedRows = (this._filteredData || [])
.filter(
(data) =>
data.selectable !== false && (!extraFilter || extraFilter(data))
this._isRowSelectable(data) && (!extraFilter || extraFilter(data))
)
.map((data) => data[this.id]);
this._checkedRows = this.selectionScope
? [...new Set([...this._checkedRows, ...selectedRows])]
: selectedRows;
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
@@ -224,6 +233,10 @@ export class HaDataTable extends LitElement {
}
protected firstUpdated() {
new ResizeController(this, {
target: this._header,
callback: () => this._calcTableHeight(),
});
this.updateComplete.then(() => this._calcTableHeight());
}
@@ -299,10 +312,11 @@ export class HaDataTable extends LitElement {
this._lastSelectedRowId = null;
}
if (properties.has("data")) {
// Clean up checked rows that no longer exist in the data
if (properties.has("data") || properties.has("selectionScope")) {
if (this._checkedRows.length) {
const validIds = new Set(this.data.map((row) => String(row[this.id])));
const validIds =
this.selectionScope ??
new Set(this.data.map((row) => String(row[this.id])));
const validCheckedRows = this._checkedRows.filter((id) =>
validIds.has(id)
);
@@ -347,7 +361,10 @@ export class HaDataTable extends LitElement {
if (
this._filteredData &&
(properties.has("selectable") || properties.has("hiddenColumns"))
(properties.has("selectable") ||
properties.has("selectionScope") ||
properties.has("selectOnRowClick") ||
properties.has("hiddenColumns"))
) {
this._filteredData = [...this._filteredData];
}
@@ -392,6 +409,16 @@ export class HaDataTable extends LitElement {
this._renderRow(columns, this.narrow, row, index);
const filteredDataLength = this._filteredData?.length || 0;
const matchingSelectableRows = this.selectionScope
? (this._filteredData || []).filter((row) => this._isRowSelectable(row))
: undefined;
const checkedRowsCount = matchingSelectableRows
? matchingSelectableRows.filter((row) =>
this._checkedRows.includes(String(row[this.id]))
).length
: this._checkedRows.length;
const checkableRowsCount =
matchingSelectableRows?.length ?? this._checkableRowsCount;
return html`
<div class="mdc-data-table">
@@ -440,14 +467,12 @@ export class HaDataTable extends LitElement {
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${
!!this._checkedRows.length &&
this._checkedRows.length !==
this._checkableRowsCount
!!checkedRowsCount &&
checkedRowsCount !== checkableRowsCount
}
.checked=${
!!this._checkedRows.length &&
this._checkedRows.length ===
this._checkableRowsCount
!!checkedRowsCount &&
checkedRowsCount === checkableRowsCount
}
>
</ha-checkbox>
@@ -595,12 +620,16 @@ export class HaDataTable extends LitElement {
"mdc-data-table__row--selected": this._checkedRows.includes(
String(row[this.id])
),
clickable: this.clickable,
clickable:
this.clickable ||
(this.selectable &&
this.selectOnRowClick &&
this._isRowSelectable(row)),
})}"
aria-selected=${ifDefined(
this._checkedRows.includes(String(row[this.id])) ? true : undefined
)}
.selectable=${row.selectable !== false}
.selectable=${this._isRowSelectable(row)}
>
${
this.selectable
@@ -613,7 +642,7 @@ export class HaDataTable extends LitElement {
class="mdc-data-table__row-checkbox"
@click=${this._handleRowCheckboxClicked}
.rowId=${row[this.id]}
.disabled=${row.selectable === false}
.disabled=${!this._isRowSelectable(row)}
.checked=${this._checkedRows.includes(String(row[this.id]))}
>
</ha-checkbox>
@@ -911,6 +940,16 @@ export class HaDataTable extends LitElement {
private _handleHeaderRowCheckboxClick(ev: HASSDomTargetEvent<HaCheckbox>) {
if (ev.target.checked) {
this.selectAll();
} else if (this.selectionScope) {
const matchingIds = new Set(
(this._filteredData || [])
.filter((row) => this._isRowSelectable(row))
.map((row) => String(row[this.id]))
);
this._checkedRows = this._checkedRows.filter(
(id) => !matchingIds.has(id)
);
this._checkedRowsChanged();
} else {
this._checkedRows = [];
this._checkedRowsChanged();
@@ -945,9 +984,8 @@ export class HaDataTable extends LitElement {
this.sortDirection
);
if (
groupedData.find((data) => data[this.id] === rowId)?.selectable === false
) {
const row = groupedData.find((data) => data[this.id] === rowId);
if (!row || !this._isRowSelectable(row)) {
return;
}
@@ -973,7 +1011,7 @@ export class HaDataTable extends LitElement {
this._checkedRows = [...this._checkedRows, rowId];
}
} else {
this._checkedRows = this._checkedRows.filter((row) => row !== rowId);
this._checkedRows = this._checkedRows.filter((id) => id !== rowId);
}
if (rowIndex > -1) {
@@ -996,7 +1034,7 @@ export class HaDataTable extends LitElement {
const row = groupedData[i];
if (
row &&
row.selectable !== false &&
this._isRowSelectable(row) &&
!this._checkedRows.includes(row[this.id])
) {
checkedRows.push(row[this.id]);
@@ -1024,10 +1062,51 @@ export class HaDataTable extends LitElement {
) {
return;
}
if (
this.selectOnRowClick &&
ev
.composedPath()
.some(
(element) =>
element instanceof HTMLElement &&
element !== ev.currentTarget &&
element.matches(
"a, button, input, select, textarea, summary, [role='button'], [role='checkbox'], [role='link'], [contenteditable='true']"
)
)
) {
return;
}
const rowId = ev.currentTarget.rowId;
if (this.selectOnRowClick) {
const row = this.data.find((data) => data[this.id] === rowId);
if (
!this.selectable ||
rowId == null ||
!row ||
!this._isRowSelectable(row)
) {
return;
}
this._checkedRows = this._checkedRows.includes(rowId)
? this._checkedRows.filter((id) => id !== rowId)
: [...this._checkedRows, rowId];
this._lastSelectedRowId = rowId;
this._checkedRowsChanged();
return;
}
fireEvent(this, "row-click", { id: rowId }, { bubbles: false });
};
private _isRowSelectable(row: DataTableRowData) {
return (
row.selectable !== false &&
!row.append &&
!row.empty &&
(!this.selectionScope || this.selectionScope.has(String(row[this.id])))
);
}
private _setTitle(ev: HASSDomCurrentTargetEvent<HTMLElement>) {
if (ev.currentTarget.scrollWidth > ev.currentTarget.offsetWidth) {
ev.currentTarget.setAttribute("title", ev.currentTarget.innerText);
@@ -1035,6 +1114,7 @@ export class HaDataTable extends LitElement {
}
private _checkedRowsChanged() {
this.requestUpdate();
// force scroller to update, change it's items
if (this._filteredData?.length) {
this._filteredData = [...this._filteredData];
+29 -24
View File
@@ -77,8 +77,9 @@ export class HaFilterDevices extends LitElement {
super.willUpdate(properties);
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
properties.has("type") ||
(properties.has("value") &&
!deepEqual(this.value, properties.get("value")))
) {
this._findRelated();
}
@@ -124,7 +125,7 @@ export class HaFilterDevices extends LitElement {
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
.rowRenderer=${this._renderItem(this.value)}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>
@@ -134,18 +135,20 @@ export class HaFilterDevices extends LitElement {
`;
}
private _renderItem = (item?: HaFilterDevicesItem) =>
!item
? nothing
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${this.value?.includes(item.id) ?? false}
>
<span slot="headline">${item.name}</span>
</ha-list-item-option>`;
private _renderItem = memoizeOne(
(value: string[] | undefined) => (item?: HaFilterDevicesItem) =>
!item
? nothing
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${value?.includes(item.id) ?? false}
>
<span slot="headline">${item.name}</span>
</ha-list-item-option>`
);
private _handleAdded(ev: CustomEvent<number>) {
this.value = [
@@ -211,9 +214,11 @@ export class HaFilterDevices extends LitElement {
);
private async _findRelated() {
const value = this.value;
const type = this.type;
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
if (!value?.length) {
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: [],
@@ -222,25 +227,25 @@ export class HaFilterDevices extends LitElement {
return;
}
const value: string[] = [];
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
for (const deviceId of value) {
if (type) {
relatedPromises.push(findRelated(this._api, "device", deviceId));
}
}
const results = await Promise.all(relatedPromises);
if (!deepEqual(value, this.value) || type !== this.type) {
return;
}
const items = new Set<string>();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
if (type && result[type]) {
result[type].forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
items: type ? items : undefined,
});
}
+19 -16
View File
@@ -79,8 +79,9 @@ export class HaFilterFloorAreas extends LitElement {
super.willUpdate(properties);
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
properties.has("type") ||
(properties.has("value") &&
!deepEqual(this.value, properties.get("value")))
) {
this._findRelated();
}
@@ -278,12 +279,11 @@ export class HaFilterFloorAreas extends LitElement {
);
private async _findRelated() {
const value = this.value;
const type = this.type;
const relatedPromises: Promise<RelatedResult>[] = [];
if (
!this.value ||
(!this.value.areas?.length && !this.value.floors?.length)
) {
if (!value || (!value.areas?.length && !value.floors?.length)) {
this.value = {};
fireEvent(this, "data-table-filter-changed", {
value: {},
@@ -292,33 +292,36 @@ export class HaFilterFloorAreas extends LitElement {
return;
}
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
if (value.areas) {
for (const areaId of value.areas) {
if (type) {
relatedPromises.push(findRelated(this._api, "area", areaId));
}
}
}
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
if (value.floors) {
for (const floorId of value.floors) {
if (type) {
relatedPromises.push(findRelated(this._api, "floor", floorId));
}
}
}
const results = await Promise.all(relatedPromises);
if (!deepEqual(value, this.value) || type !== this.type) {
return;
}
const items = new Set<string>();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
if (type && result[type]) {
result[type].forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: this.type ? items : undefined,
value,
items: type ? items : undefined,
});
}
+8 -1
View File
@@ -192,7 +192,14 @@ export class HaListVirtualized extends HaListBase {
this.rangeStart = ev.first;
this.rangeEnd = ev.last;
await this.virtualizerElement?.layoutComplete;
try {
await this.virtualizerElement?.layoutComplete;
} catch (err) {
if (err === "disconnected") {
return;
}
throw err;
}
this._applySetSize();
if (!this.virtualizerElement) {
+102 -57
View File
@@ -76,6 +76,14 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
*/
@property({ type: Boolean }) public selectable = false;
@property({ attribute: false }) public selectionMode: "toggle" | "always" =
"toggle";
@property({ attribute: false }) public selectionScope?: ReadonlySet<string>;
@property({ type: Boolean, attribute: "select-on-row-click" })
public selectOnRowClick = false;
/**
* Should rows be clickable.
* @type {Boolean}
@@ -248,7 +256,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
: nothing;
const selectModeBtn =
this.selectable && !this._selectMode
this.selectable && this.selectionMode === "toggle" && !this._selectMode
? html`<ha-assist-chip
class="has-dropdown select-mode-chip"
.active=${this._selectMode}
@@ -401,61 +409,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
@sorting-changed=${this._sortingChanged}
>
${
this._selectMode
this.selectionMode === "toggle" && this._selectMode
? html`<div class="selection-bar" slot="toolbar">
<div class="selection-controls">
<ha-icon-button
.path=${mdiClose}
@click=${this._disableSelectMode}
.label=${localize(
"ui.components.subpage-data-table.exit_selection_mode"
)}
></ha-icon-button>
<ha-dropdown @wa-select=${this._handleSelect}>
<ha-assist-chip
.label=${localize(
"ui.components.subpage-data-table.select"
)}
slot="trigger"
>
<ha-svg-icon
slot="icon"
.path=${mdiFormatListChecks}
></ha-svg-icon>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon
></ha-assist-chip>
<ha-dropdown-item value="all">
${localize("ui.components.subpage-data-table.select_all")}
</ha-dropdown-item>
<ha-dropdown-item value="none">
${localize("ui.components.subpage-data-table.select_none")}
</ha-dropdown-item>
<wa-divider></wa-divider>
<ha-dropdown-item value="disable_select_mode">
${localize(
"ui.components.subpage-data-table.exit_selection_mode"
)}
</ha-dropdown-item>
</ha-dropdown>
${
this.selected !== undefined
? html`<p>
${localize(
"ui.components.subpage-data-table.selected",
{
selected: this.selected || "0",
}
)}
</p>`
: nothing
}
</div>
<div class="center-vertical">
<slot name="selection-bar"></slot>
</div>
${this._renderSelectionControls(localize)}
</div>`
: nothing
}
@@ -500,7 +456,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
<slot name="toolbar-icon"></slot>
</div>
${
this.narrow
this.narrow && this.selectionMode === "toggle"
? html`
<div slot="header">
<slot name="header">
@@ -516,7 +472,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
.data=${this.data}
.noDataText=${this.noDataText}
.filter=${this.filter}
.selectable=${this._selectMode}
.selectable=${this.selectionMode === "always" || this._selectMode}
.selectionScope=${this.selectionScope}
.selectOnRowClick=${this.selectOnRowClick}
.id=${this.id}
.clickable=${this.clickable}
.appendRow=${this.appendRow}
@@ -533,6 +491,15 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
? html`
<div slot="header">
<slot name="top-header"></slot>
${
this.selectionMode === "always"
? html`<div
class="selection-bar persistent-selection-bar"
>
${this._renderSelectionControls(localize)}
</div>`
: nothing
}
<slot name="header">
<div class="table-header">
${
@@ -547,6 +514,18 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
: html`
<div slot="header">
<slot name="top-header"></slot>
${
this.selectionMode === "always"
? html`<div
class="selection-bar persistent-selection-bar"
>
${this._renderSelectionControls(localize)}
</div>
<div class="table-header">
${searchBar}
</div>`
: nothing
}
</div>
<div slot="header-row" class="narrow-header-row">
${
@@ -613,6 +592,62 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
fireEvent(this, "clear-filter");
}
private _renderSelectionControls(localize: LocalizeFunc) {
return html`
<div class="selection-controls">
${
this.selectionMode === "toggle"
? html`<ha-icon-button
.path=${mdiClose}
@click=${this._disableSelectMode}
.label=${localize(
"ui.components.subpage-data-table.exit_selection_mode"
)}
></ha-icon-button>`
: nothing
}
<ha-dropdown @wa-select=${this._handleSelect}>
<ha-assist-chip
.label=${localize("ui.components.subpage-data-table.select")}
slot="trigger"
>
<ha-svg-icon slot="icon" .path=${mdiFormatListChecks}></ha-svg-icon>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
<ha-dropdown-item value="all">
${localize("ui.components.subpage-data-table.select_all")}
</ha-dropdown-item>
<ha-dropdown-item value="none">
${localize("ui.components.subpage-data-table.select_none")}
</ha-dropdown-item>
${
this.selectionMode === "toggle"
? html`<wa-divider></wa-divider>
<ha-dropdown-item value="disable_select_mode">
${localize(
"ui.components.subpage-data-table.exit_selection_mode"
)}
</ha-dropdown-item>`
: nothing
}
</ha-dropdown>
${
this.selected !== undefined
? html`<p>
${localize("ui.components.subpage-data-table.selected", {
selected: this.selected || "0",
})}
</p>`
: nothing
}
</div>
<div class="center-vertical"><slot name="selection-bar"></slot></div>
`;
}
private _toggleFilters() {
this.showFilters = !this.showFilters;
}
@@ -719,6 +754,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
}
private _disableSelectMode = () => {
if (this.selectionMode === "always") {
return;
}
this._selectMode = false;
this._dataTable.clearSelection();
};
@@ -911,6 +949,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
gap: var(--ha-space-2);
}
.persistent-selection-bar {
height: auto;
min-height: var(--ha-space-14);
flex-wrap: wrap;
gap: var(--ha-space-2);
}
.selection-controls p {
margin-left: 8px;
margin-inline-start: 8px;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,938 @@
import { consume } from "@lit/context";
import {
mdiAlertCircle,
mdiCancel,
mdiEyeOff,
mdiPencilOff,
mdiRestoreAlert,
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { guard } from "lit/directives/guard";
import { styleMap } from "lit/directives/style-map";
import memoize from "memoize-one";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
type HASSDomEvent,
} from "../../../common/dom/fire_event";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import {
computeDeviceName,
getDuplicatedDeviceNames,
} from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { slugify } from "../../../common/string/slugify";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { deepEqual } from "../../../common/util/deep-equal";
import type {
DataTableColumnContainer,
RowClickedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-alert";
import "../../../components/ha-entity-id-icon";
import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-domains";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-integrations";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-states";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { CloudStatus } from "../../../data/cloud";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { getSubEntries } from "../../../data/config_entries";
import {
areasContext,
configEntriesContext,
devicesContext,
fullEntitiesContext,
internationalizationContext,
labelsContext,
statesContext,
} from "../../../data/context";
import type {
DataTableFilter,
DataTableFiltersItems,
DataTableFiltersValues,
} from "../../../data/data_table_filters";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import type { ExposeEntitySettings } from "../../../data/expose";
import { listExposedEntities, voiceAssistants } from "../../../data/expose";
import { domainToName } from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
import "../../../layouts/hass-loading-screen";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import type {
HomeAssistant,
HomeAssistantInternationalization,
Route,
} from "../../../types";
import {
getAreaTableColumn,
getCreatedAtTableColumn,
getDomainTableColumn,
getEntityIdTableColumn,
getLabelsTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import {
getAssistantsSortableKey,
getAssistantsTableColumn,
} from "../voice-assistants/expose/assistants-table-column";
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
export interface StateEntity extends Omit<
EntityRegistryEntry,
"id" | "unique_id"
> {
readonly?: boolean;
selectable?: boolean;
id?: string;
unique_id?: string;
}
export interface EntityRow extends StateEntity {
unavailable: boolean;
restored: boolean;
status: string | undefined;
area?: string;
device?: string;
device_full?: string;
localized_platform: string;
domain: string;
label_entries: LabelRegistryEntry[];
assistants: string[];
assistants_sortable_key: string | undefined;
enabled: string;
visible: string;
available: string;
}
export interface EntityTableFilterContext {
filteredDomains: ReadonlySet<string>;
filteredConfigEntry: ConfigEntry | undefined;
}
@customElement("ha-entity-table")
export class HaEntityTable extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public route!: Route;
@property({ attribute: false }) public tabs: PageNavigation[] = [];
@property({ attribute: "back-path" }) public backPath?: string;
@property({ attribute: false }) public backCallback?: () => void;
@property({ attribute: false }) public entityIds?: ReadonlySet<string>;
@property({ attribute: false }) public noDataText?: string;
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@property() public filter = "";
@property({ attribute: false }) public filterValues: DataTableFiltersValues =
{};
@property({ type: Boolean }) public selectable = false;
@property({ attribute: false })
public selectionMode: HaTabsSubpageDataTable["selectionMode"] = "toggle";
@property({ type: Boolean }) public clickable = false;
@property({ type: Number }) public selected = 0;
@property({ attribute: "has-fab", type: Boolean }) public hasFab = false;
@property({ attribute: false }) public initialSorting?: SortingChangedEvent;
@property({ attribute: false }) public initialGroupColumn?: string;
@property({ attribute: false }) public initialCollapsedGroups?: string[];
@property({ attribute: false }) public columnOrder?: string[];
@property({ attribute: false }) public hiddenColumns?: string[];
@property({ attribute: false }) public exposedEntities?: Record<
string,
ExposeEntitySettings
>;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
private _entities?: EntityRegistryEntry[];
@state()
@consume({ context: statesContext, subscribe: true })
private _entityStates!: HomeAssistant["states"];
@state()
@consume({ context: devicesContext, subscribe: true })
private _devices!: HomeAssistant["devices"];
@state()
@consume({ context: areasContext, subscribe: true })
private _areas!: HomeAssistant["areas"];
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labels?: LabelRegistryEntry[];
@state()
@consume({ context: configEntriesContext, subscribe: true })
private _entries?: ConfigEntry[];
@state() private _subEntries?: SubEntry[];
@state() private _entitySources?: EntitySources;
@state() private _exposedEntities?: Record<string, ExposeEntitySettings>;
@state() private _filteredItems: DataTableFiltersItems = {};
@state() private _expandedFilter?: string;
private _itemFilterValues: DataTableFiltersValues = {};
private _subEntryConfigEntry?: string;
private _filterContext?: EntityTableFilterContext;
@query("hass-tabs-subpage-data-table")
private _dataTable?: HaTabsSubpageDataTable;
public connectedCallback() {
super.connectedCallback();
window.addEventListener(
"exposed-entities-changed",
this._fetchExposedEntities
);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener(
"exposed-entities-changed",
this._fetchExposedEntities
);
}
public clearSelection() {
this._dataTable?.clearSelection();
}
protected firstUpdated() {
this._fetchExposedEntities();
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
}
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("filterValues")) {
this._filteredItems = Object.fromEntries(
Object.entries(this._filteredItems).filter(
([key]) =>
this.filterValues[key] !== undefined &&
deepEqual(this.filterValues[key], this._itemFilterValues[key])
)
);
const configEntry =
Array.isArray(this.filterValues.config_entry) &&
this.filterValues.config_entry.length === 1
? this.filterValues.config_entry[0]
: undefined;
const subEntryConfigEntry =
Array.isArray(this.filterValues.sub_entry) &&
this.filterValues.sub_entry.length
? configEntry
: undefined;
if (subEntryConfigEntry !== this._subEntryConfigEntry) {
this._subEntryConfigEntry = subEntryConfigEntry;
this._subEntries = undefined;
if (subEntryConfigEntry) {
this._loadSubEntries(subEntryConfigEntry);
}
}
}
if (
this.hasUpdated &&
changedProps.has("exposedEntities") &&
this.exposedEntities === undefined
) {
this._fetchExposedEntities();
}
}
protected updated() {
if (!this._entities) {
return;
}
const { filteredDomains, filteredConfigEntry } = this._getRows();
if (
this._filterContext &&
this._filterContext.filteredConfigEntry === filteredConfigEntry &&
this._filterContext.filteredDomains.size === filteredDomains.size &&
[...filteredDomains].every((domain) =>
this._filterContext!.filteredDomains.has(domain)
)
) {
return;
}
this._filterContext = { filteredDomains, filteredConfigEntry };
fireEvent(this, "entity-table-filter-context-changed", this._filterContext);
}
private _states = memoize((localize: LocalizeFunc) => [
{
value: "available",
label: localize("ui.panel.config.entities.picker.status.available"),
},
{
value: "unavailable",
label: localize("ui.panel.config.entities.picker.status.unavailable"),
},
{
value: "enabled",
label: localize("ui.panel.config.entities.picker.status.enabled"),
},
{
value: "disabled",
label: localize("ui.panel.config.entities.picker.status.disabled"),
},
{
value: "visible",
label: localize("ui.panel.config.entities.picker.status.visible"),
},
{
value: "hidden",
label: localize("ui.panel.config.entities.picker.status.hidden"),
},
{
value: "readonly",
label: localize("ui.panel.config.entities.picker.status.unmanageable"),
},
{
value: "restored",
label: localize("ui.panel.config.entities.picker.status.not_provided"),
},
]);
private _columns = memoize(
(
localize: LocalizeFunc,
hass: HomeAssistant,
availableAssistants: string[],
entitiesToCheck: EntityRow[]
): DataTableColumnContainer<EntityRow> => ({
icon: {
title: "",
label: localize("ui.panel.config.entities.picker.headers.state_icon"),
type: "icon",
showNarrow: true,
moveable: false,
template: (entry) =>
entry.icon
? html`<ha-icon .icon=${entry.icon}></ha-icon>`
: html`<ha-entity-id-icon
state-title
slot="item-icon"
.entityId=${entry.entity_id}
></ha-entity-id-icon>`,
},
name: {
main: true,
title: localize("ui.panel.config.entities.picker.headers.entity"),
sortable: true,
filterable: true,
direction: "asc",
extraTemplate: (entry) =>
entry.label_entries.length
? html`<ha-data-table-labels
.labels=${entry.label_entries}
></ha-data-table-labels>`
: nothing,
},
device: {
title: localize("ui.panel.config.entities.picker.headers.device"),
sortable: true,
template: (entry) => entry.device || "\u2014",
},
device_full: {
title: localize("ui.panel.config.entities.picker.headers.device"),
filterable: true,
groupable: true,
hidden: true,
},
area: getAreaTableColumn(localize),
entity_id: getEntityIdTableColumn(localize, true),
localized_platform: {
title: localize("ui.panel.config.entities.picker.headers.integration"),
sortable: true,
groupable: true,
filterable: true,
},
domain: getDomainTableColumn(localize),
disabled_by: {
title: localize("ui.panel.config.entities.picker.headers.disabled_by"),
hidden: true,
filterable: true,
template: (entry) =>
entry.disabled_by === null
? ""
: localize(`config_entry.disabled_by.${entry.disabled_by}`),
},
status: {
title: localize("ui.panel.config.entities.picker.headers.status"),
type: "icon",
showNarrow: true,
sortable: true,
filterable: true,
minWidth: "80px",
maxWidth: "80px",
template: (entry) =>
entry.unavailable ||
entry.disabled_by ||
entry.hidden_by ||
entry.readonly
? html`<div
tabindex="0"
style="display:inline-block; position: relative;"
>
<ha-svg-icon
.id="status-icon-${slugify(entry.entity_id)}"
style=${styleMap({ color: entry.unavailable ? "var(--error-color)" : "" })}
.path=${entry.restored ? mdiRestoreAlert : entry.unavailable ? mdiAlertCircle : entry.disabled_by ? mdiCancel : entry.hidden_by ? mdiEyeOff : mdiPencilOff}
></ha-svg-icon>
<ha-tooltip
.for="status-icon-${slugify(entry.entity_id)}"
placement="left"
>
${
entry.restored
? localize(
"ui.panel.config.entities.picker.status.not_provided"
)
: entry.unavailable
? localize(
"ui.panel.config.entities.picker.status.unavailable"
)
: entry.disabled_by
? localize(
"ui.panel.config.entities.picker.status.disabled"
)
: entry.hidden_by
? localize(
"ui.panel.config.entities.picker.status.hidden"
)
: localize(
"ui.panel.config.entities.picker.status.unmanageable"
)
}
</ha-tooltip>
</div>`
: "\u2014",
},
created_at: getCreatedAtTableColumn(localize, hass),
modified_at: getModifiedAtTableColumn(localize, hass),
available: {
title: localize("ui.panel.config.entities.picker.headers.availability"),
sortable: true,
groupable: true,
hidden: true,
},
visible: {
title: localize("ui.panel.config.entities.picker.headers.visibility"),
sortable: true,
groupable: true,
hidden: true,
},
enabled: {
title: localize("ui.panel.config.entities.picker.headers.enabled"),
sortable: true,
groupable: true,
hidden: true,
},
labels: getLabelsTableColumn(),
assistants: getAssistantsTableColumn(
localize,
hass,
availableAssistants,
entitiesToCheck
),
})
);
private _stateEntities = memoize(
(
states: HomeAssistant["states"],
entities: EntityRegistryEntry[],
sources: EntitySources | undefined,
exposedEntities: Record<string, ExposeEntitySettings> | undefined,
selectionMode: HaTabsSubpageDataTable["selectionMode"]
): StateEntity[] => {
const regEntityIds = new Set(entities.map((entity) => entity.entity_id));
return Object.keys(states)
.filter((entityId) => !regEntityIds.has(entityId))
.map((entityId) => ({
name: computeStateName(states[entityId]),
entity_id: entityId,
platform: sources?.[entityId]?.domain || computeDomain(entityId),
disabled_by: null,
hidden_by: null,
area_id: null,
config_entry_id: null,
config_subentry_id: null,
device_id: null,
icon: null,
readonly: true,
selectable: selectionMode === "always",
entity_category: null,
has_entity_name: false,
options: Object.fromEntries(
Object.keys(voiceAssistants).map((vaId) => [
vaId,
{ should_expose: exposedEntities?.[entityId]?.[vaId] },
])
),
labels: [],
categories: {},
created_at: 0,
modified_at: 0,
}));
}
);
private _filteredEntitiesAndDomains = memoize(
(
localize: LocalizeFunc,
entities: StateEntity[],
states: HomeAssistant["states"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
entitiesWithoutUniqueId: StateEntity[],
filters: DataTableFiltersValues,
filteredItems: DataTableFiltersItems,
entries: ConfigEntry[] | undefined,
labelReg: LabelRegistryEntry[] | undefined,
entitySources: EntitySources | undefined,
exposedEntities: Record<string, ExposeEntitySettings> | undefined,
entityIds: ReadonlySet<string> | undefined
) => {
const result: EntityRow[] = [];
const stateFilters = filters["ha-filter-states"] as string[];
const showEnabled =
!stateFilters?.length || stateFilters.includes("enabled");
const showDisabled =
!stateFilters?.length || stateFilters.includes("disabled");
const showVisible =
!stateFilters?.length || stateFilters.includes("visible");
const showHidden =
!stateFilters?.length || stateFilters.includes("hidden");
const showAvailable =
!stateFilters?.length || stateFilters.includes("available");
const showUnavailable =
!stateFilters?.length || stateFilters.includes("unavailable");
const showRestored =
!stateFilters?.length || stateFilters.includes("restored");
const showReadOnly =
!stateFilters?.length || stateFilters.includes("readonly");
let filteredEntities = entities.concat(entitiesWithoutUniqueId);
if (entityIds) {
filteredEntities = filteredEntities.filter((entity) =>
entityIds.has(entity.entity_id)
);
}
let filteredConfigEntry: ConfigEntry | undefined;
const filteredDomains = new Set<string>();
Object.entries(filters).forEach(([key, filter]) => {
if (!Array.isArray(filter) || !filter.length) {
return;
}
if (key === "config_entry") {
filteredEntities = filteredEntities.filter(
(entity) =>
entity.config_entry_id && filter.includes(entity.config_entry_id)
);
const configEntries = entries?.filter((entry) =>
filter.includes(entry.entry_id)
);
configEntries?.forEach((entry) => filteredDomains.add(entry.domain));
if (configEntries?.length === 1) {
filteredConfigEntry = configEntries[0];
}
} else if (key === "sub_entry") {
if (
!Array.isArray(filters.config_entry) ||
filters.config_entry.length !== 1
) {
return;
}
filteredEntities = filteredEntities.filter(
(entity) =>
entity.config_subentry_id &&
filter.includes(entity.config_subentry_id)
);
} else if (key === "ha-filter-integrations") {
const entryIds =
entries
?.filter((entry) => filter.includes(entry.domain))
.map((entry) => entry.entry_id) || [];
filteredEntities = filteredEntities.filter(
(entity) =>
filter.includes(
entitySources?.[entity.entity_id]?.domain ?? ""
) ||
filter.includes(entity.platform) ||
(entity.config_entry_id &&
entryIds.includes(entity.config_entry_id))
);
filter.forEach((domain) => filteredDomains.add(domain));
} else if (key === "ha-filter-domains") {
filteredEntities = filteredEntities.filter((entity) =>
filter.includes(computeDomain(entity.entity_id))
);
} else if (key === "ha-filter-labels") {
filteredEntities = filteredEntities.filter((entity) =>
entity.labels.some((lbl) => filter.includes(lbl))
);
} else if (key === "ha-filter-voice-assistants") {
filteredEntities = filteredEntities.filter((entity) =>
filter.some((va) => exposedEntities?.[entity.entity_id]?.[va])
);
}
});
Object.values(filteredItems).forEach((items) => {
if (items) {
filteredEntities = filteredEntities.filter((entity) =>
items.has(entity.entity_id)
);
}
});
const duplicatedDevicesNames = getDuplicatedDeviceNames(devices);
for (const entry of filteredEntities) {
const entity = states[entry.entity_id];
const unavailable = entity?.state === UNAVAILABLE;
const restored = entity?.attributes.restored === true;
const device = entry.device_id ? devices[entry.device_id] : undefined;
const areaId = entry.area_id || device?.area_id;
const area = areaId ? areas[areaId] : undefined;
const hidden = !!entry.hidden_by;
const disabled = !!entry.disabled_by;
const readonly = entry.readonly;
const available = entity?.state && entity.state !== UNAVAILABLE;
if (!(
(showAvailable && available) ||
(showUnavailable && unavailable) ||
(showRestored && restored) ||
(showVisible && !hidden) ||
(showHidden && hidden) ||
(showDisabled && disabled) ||
(showEnabled && !disabled) ||
(showReadOnly && readonly)
)) {
continue;
}
const labelsEntries = (entry.labels || [])
.map((lbl) => labelReg?.find((label) => label.label_id === lbl))
.filter((lbl): lbl is LabelRegistryEntry => lbl !== undefined);
const entityName = computeEntityEntryName(
entry as EntityRegistryEntry,
devices,
entity
);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const deviceFullName = deviceName
? duplicatedDevicesNames.has(deviceName) && areaName
? `${deviceName} (${areaName})`
: deviceName
: undefined;
const assistants = Object.keys(voiceAssistants).filter(
(va) => exposedEntities?.[entry.entity_id]?.[va]
);
result.push({
...entry,
name: entityName || deviceName || entry.entity_id,
device: deviceName,
area: areaName,
device_full: deviceFullName,
unavailable,
restored,
localized_platform: domainToName(localize, entry.platform),
domain: domainToName(localize, computeDomain(entry.entity_id)),
assistants,
assistants_sortable_key: getAssistantsSortableKey(assistants),
status: restored
? localize("ui.panel.config.entities.picker.status.not_provided")
: unavailable
? localize("ui.panel.config.entities.picker.status.unavailable")
: disabled
? localize("ui.panel.config.entities.picker.status.disabled")
: hidden
? localize("ui.panel.config.entities.picker.status.hidden")
: readonly
? localize(
"ui.panel.config.entities.picker.status.unmanageable"
)
: localize(
"ui.panel.config.entities.picker.status.available"
),
label_entries: labelsEntries,
available: unavailable
? localize("ui.panel.config.entities.picker.status.unavailable")
: localize("ui.panel.config.entities.picker.status.available"),
enabled: disabled
? localize("ui.panel.config.entities.picker.status.disabled")
: localize("ui.panel.config.entities.picker.status.enabled"),
visible: hidden
? localize("ui.panel.config.entities.picker.status.hidden")
: localize("ui.panel.config.entities.picker.status.visible"),
});
}
return { filteredEntities: result, filteredConfigEntry, filteredDomains };
}
);
private _getRows() {
return this._filteredEntitiesAndDomains(
this._i18n.localize,
this._entities!,
this._entityStates,
this._devices,
this._areas,
this._stateEntities(
this._entityStates,
this._entities!,
this._entitySources,
this.exposedEntities ?? this._exposedEntities,
this.selectionMode
),
this.filterValues,
this._filteredItems,
this._entries,
this._labels,
this._entitySources,
this.exposedEntities ?? this._exposedEntities,
this.entityIds
);
}
protected render() {
if (!this.hass || !this._entities) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
const { filteredEntities } = this._getRows();
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
.backPath=${this.backPath}
.backCallback=${this.backCallback}
.noDataText=${this.noDataText}
.route=${this.route}
.tabs=${this.tabs}
.columns=${this._columns(this._i18n.localize, this.hass, getAvailableAssistants(this.cloudStatus, this.hass), filteredEntities)}
.data=${filteredEntities}
.searchLabel=${this._i18n.localize("ui.panel.config.entities.picker.search", { number: filteredEntities.length })}
has-filters
.filters=${Object.values(this.filterValues).filter((filter) => (Array.isArray(filter) ? filter.length : filter && Object.values(filter).some((val) => (Array.isArray(val) ? val.length : val)))).length}
.selectable=${this.selectable}
.selectionMode=${this.selectionMode}
.selectionScope=${this.selectionMode === "always" ? this.entityIds : undefined}
.selectOnRowClick=${this.selectionMode === "always"}
.selected=${this.selected}
.initialGroupColumn=${this.initialGroupColumn}
.initialCollapsedGroups=${this.initialCollapsedGroups}
.initialSorting=${this.initialSorting}
.columnOrder=${this.columnOrder}
.hiddenColumns=${this.hiddenColumns}
.clickable=${this.clickable}
@clear-filter=${this._clearFilter}
.filter=${this.filter}
@row-click=${this._rowClicked}
id="entity_id"
.hasFab=${this.hasFab}
class=${this.narrow ? "narrow" : ""}
>
<slot name="toolbar-icon" slot="toolbar-icon"></slot>
<slot name="selection-bar" slot="selection-bar"></slot>
<slot name="top-header" slot="top-header"></slot>
<slot name="fab" slot="fab"></slot>
${
Array.isArray(this.filterValues.config_entry) &&
this.filterValues.config_entry.length
? html`<ha-alert slot="filter-pane">
${this._i18n.localize("ui.panel.config.entities.picker.filtering_by_config_entry")}
${this._entries?.find((entry) => entry.entry_id === this.filterValues.config_entry![0])?.title || this.filterValues.config_entry[0]}${
this.filterValues.config_entry.length === 1 &&
Array.isArray(this.filterValues.sub_entry) &&
this.filterValues.sub_entry.length
? html` (${this._subEntries?.find((entry) => entry.subentry_id === this.filterValues.sub_entry![0])?.title || this.filterValues.sub_entry[0]})`
: nothing
}
</ha-alert>`
: nothing
}
<ha-filter-floor-areas
type="entity"
.value=${guard(
[this.filterValues["ha-filter-floor-areas"]],
() => this.filterValues["ha-filter-floor-areas"]
)}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-floor-areas"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-floor-areas>
<ha-filter-devices
type="entity"
.value=${guard(
[this.filterValues["ha-filter-devices"]],
() => this.filterValues["ha-filter-devices"]
)}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-devices"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-devices>
<ha-filter-domains
.value=${this.filterValues["ha-filter-domains"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-domains"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-domains>
<ha-filter-integrations
.value=${this.filterValues["ha-filter-integrations"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-integrations"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-integrations>
<ha-filter-states
.label=${this._i18n.localize("ui.panel.config.entities.picker.headers.status")}
.value=${this.filterValues["ha-filter-states"]}
.states=${this._states(this._i18n.localize)}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-states"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-states>
<ha-filter-labels
.value=${this.filterValues["ha-filter-labels"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-labels"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-labels>
<ha-filter-voice-assistants
.value=${this.filterValues["ha-filter-voice-assistants"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-voice-assistants"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-voice-assistants>
</hass-tabs-subpage-data-table>
`;
}
private _filterExpanded(
ev: HASSDomEvent<{ expanded: boolean }> &
HASSDomCurrentTargetEvent<HTMLElement>
) {
if (ev.detail.expanded) {
this._expandedFilter = ev.currentTarget.localName;
} else if (this._expandedFilter === ev.currentTarget.localName) {
this._expandedFilter = undefined;
}
}
private _filterChanged(
ev: HASSDomEvent<DataTableFilter> & HASSDomCurrentTargetEvent<HTMLElement>
) {
ev.stopPropagation();
const type = ev.currentTarget.localName;
this._itemFilterValues[type] = ev.detail.value;
this._filteredItems = {
...this._filteredItems,
[type]: (
Array.isArray(ev.detail.value)
? ev.detail.value.length
: ev.detail.value &&
Object.values(ev.detail.value).some((values) => values.length)
)
? ev.detail.items
: undefined,
};
this.filterValues = { ...this.filterValues, [type]: ev.detail.value };
fireEvent(this, "entity-table-filters-changed", {
value: this.filterValues,
});
}
private _clearFilter() {
this._filteredItems = {};
this._itemFilterValues = {};
this.filterValues = {};
fireEvent(this, "entity-table-filters-changed", {
value: this.filterValues,
});
}
private _rowClicked(ev: HASSDomEvent<RowClickedEvent>) {
ev.stopPropagation();
fireEvent(this, "row-click", ev.detail, { bubbles: false });
}
private async _loadSubEntries(entryId: string) {
const entries = await getSubEntries(this.hass, entryId);
if (this._subEntryConfigEntry === entryId) {
this._subEntries = entries;
}
}
private _fetchExposedEntities = async () => {
if (this.exposedEntities !== undefined) {
return;
}
try {
this._exposedEntities = (
await listExposedEntities(this.hass)
).exposed_entities;
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to fetch exposed entities", err);
this._exposedEntities = {};
}
};
static styles = css`
:host {
display: block;
height: 100%;
}
hass-tabs-subpage-data-table {
--data-table-row-height: 60px;
}
hass-tabs-subpage-data-table.narrow {
--data-table-row-height: 72px;
}
hass-loading-screen {
--app-header-background-color: var(--sidebar-background-color);
--app-header-text-color: var(--sidebar-text-color);
}
`;
}
declare global {
interface HASSDomEvents {
"entity-table-filter-context-changed": EntityTableFilterContext;
"entity-table-filters-changed": { value: DataTableFiltersValues };
}
interface HTMLElementTagNameMap {
"ha-entity-table": HaEntityTable;
}
}
@@ -1,366 +0,0 @@
import "@lit-labs/virtualizer";
import { consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-button";
import "../../../components/ha-check-list-item";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-list";
import "../../../components/ha-state-icon";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import {
configContext,
internationalizationContext,
registriesContext,
statesContext,
} from "../../../data/context";
import type { ExposeEntitySettings } from "../../../data/expose";
import { voiceAssistants } from "../../../data/expose";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleScrollbar } from "../../../resources/styles";
import { loadVirtualizer } from "../../../resources/virtualizer";
import "./entity-voice-settings";
import type { ExposeEntityDialogParams } from "./show-dialog-expose-entity";
interface FilteredEntity {
entity: HassEntity;
nameList: (string | undefined)[];
}
@customElement("dialog-expose-entity")
class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
DialogMixin<ExposeEntityDialogParams>(LitElement)
) {
@state() private _filter?: string;
@state() private _selected: string[] = [];
@state() private _dialogReady = false;
public connectedCallback(): void {
super.connectedCallback();
this._initDirtyTracking({ type: "deep" }, this._selected);
}
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
protected _config!: ContextType<typeof configContext>;
@consume({ context: statesContext, subscribe: true })
protected _states!: ContextType<typeof statesContext>;
@consume({ context: registriesContext, subscribe: true })
protected _registries!: ContextType<typeof registriesContext>;
protected render() {
if (!this.params) {
return nothing;
}
const header = this._i18n.localize(
"ui.panel.config.voice_assistants.expose.expose_dialog.header"
);
const subtitle = this._i18n.localize(
"ui.panel.config.voice_assistants.expose.expose_dialog.expose_to",
{
assistants: this.params.filterAssistants
.map((ass) => voiceAssistants[ass].name)
.join(", "),
}
);
const entities = this._filterEntities(
this.params.exposedEntities,
this._filter
);
return html`
<ha-dialog
open
header-title=${header}
header-subtitle=${subtitle}
.preventScrimClose=${this.isDirtyState}
@after-show=${this._loadVirtualizer}
>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._filterChanged}
></ha-input-search>
<ha-list multi>
${
this._dialogReady
? html` <lit-virtualizer
scroller
class="ha-scrollbar"
@click=${this._itemClicked}
@keydown=${this._handleItemKeydown}
.items=${entities}
.renderItem=${this._renderItem}
.keyFunction=${this._keyFunction}
>
</lit-virtualizer>`
: nothing
}
</ha-list>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this._i18n.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._expose}
.disabled=${this._selected.length === 0}
>
${this._i18n.localize(
"ui.panel.config.voice_assistants.expose.expose_dialog.expose_entities",
{ count: this._selected.length }
)}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private async _loadVirtualizer() {
await loadVirtualizer();
this._dialogReady = true;
}
private _keyFunction = (item: FilteredEntity) => item.entity.entity_id;
private _handleSelected = (ev) => {
const entityId = ev.target.value;
if (ev.detail.selected) {
if (this._selected.includes(entityId)) {
return;
}
this._selected = [...this._selected, entityId];
} else {
this._selected = this._selected.filter((item) => item !== entityId);
}
this._updateDirtyState(this._selected);
};
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._itemClicked(ev);
}
}
private _itemClicked(ev) {
const listItem = ev.target.closest("ha-check-list-item");
listItem.selected = !listItem.selected;
}
private _filterChanged(e: HASSDomTargetEvent<HaInputSearch>) {
this._filter = e.target.value;
}
private _filterEntities = memoizeOne(
(
exposedEntities: Record<string, ExposeEntitySettings>,
filter?: string
): FilteredEntity[] => {
const lowerFilter = filter?.toLowerCase();
const result: FilteredEntity[] = [];
for (const entity of Object.values(this._states)) {
if (
this.params!.filterAssistants.every(
(ass) => exposedEntities[entity.entity_id]?.[ass]
)
) {
continue;
}
const nameList = computeEntityNameList(
entity,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._registries.entities,
this._registries.devices,
this._registries.areas,
this._registries.floors
);
if (!lowerFilter) {
result.push({ entity, nameList });
continue;
}
if (entity.entity_id.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
const entityName = computeStateName(entity);
if (entityName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
const [, deviceName, areaName] = nameList;
if (deviceName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
if (areaName?.toLowerCase().includes(lowerFilter)) {
result.push({ entity, nameList });
continue;
}
}
return result;
}
);
private _renderItem = (item: FilteredEntity) => {
const { entity: entityState, nameList } = item;
const [entityName, deviceName, areaName] = nameList;
const isRTL = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
const primary = entityName || deviceName || entityState.entity_id;
const context = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const showEntityId = this._config?.userData?.showEntityIdPicker;
return html`
<ha-check-list-item
tabindex="0"
graphic="icon"
?twoLine=${context}
?threeLine=${showEntityId}
.value=${entityState.entity_id}
.selected=${this._selected.includes(entityState.entity_id)}
@request-selected=${this._handleSelected}
>
<ha-state-icon
title=${ifDefined(entityState?.state)}
slot="graphic"
.stateObj=${entityState}
></ha-state-icon>
${primary}
${
context || showEntityId
? html`<span slot="secondary">
${context}
${
showEntityId
? html`<br /><span class="entity-id"
>${entityState.entity_id}</span
>`
: nothing
}
</span>`
: nothing
}
</ha-check-list-item>
`;
};
private _expose() {
this.params!.exposeEntities(this._selected);
this._markDirtyStateClean();
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleScrollbar,
css`
ha-dialog {
--dialog-content-padding: 0;
}
ha-list {
position: relative;
}
lit-virtualizer {
height: 500px;
}
ha-input-search {
padding: 0 var(--ha-space-3);
}
lit-virtualizer {
width: 100%;
contain: size layout !important;
}
ha-check-list-item {
width: 100%;
height: 72px;
}
ha-check-list-item[threeLine] {
height: 88px;
}
ha-check-list-item .entity-id {
line-height: var(--ha-line-height-normal);
padding-left: var(--ha-space-1);
font-size: var(--ha-font-size-xs);
}
ha-check-list-item ha-state-icon {
margin-left: var(--ha-space-6);
margin-inline-start: var(--ha-space-6);
margin-inline-end: initial;
}
@media all and (max-height: 800px) {
lit-virtualizer {
height: 334px;
}
}
@media all and (max-height: 600px) {
lit-virtualizer {
height: 238px;
}
}
@media all and (max-width: 500px), all and (max-height: 500px) {
lit-virtualizer {
height: calc(
100vh -
210px - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
}
ha-check-list-item ha-state-icon {
margin-left: var(--ha-space-2);
margin-inline-start: var(--ha-space-2);
margin-inline-end: initial;
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-expose-entity": DialogExposeEntity;
}
}
@@ -0,0 +1,373 @@
import { consume, type ContextType } from "@lit/context";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import { goBack, replaceCurrentUrl } from "../../../common/navigate";
import { currentPath } from "../../../common/url/current-path";
import { extractSearchParamsObject } from "../../../common/url/search-params";
import {
createVoiceAssistantQueryString,
decodeVoiceAssistantQueryParams,
type VoiceAssistantQueryParams,
} from "../../../common/url/voice-assistant-query-params";
import { deepEqual } from "../../../common/util/deep-equal";
import type { SelectionChangedEvent } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import type { CloudStatus } from "../../../data/cloud";
import {
internationalizationContext,
statesContext,
} from "../../../data/context";
import type { DataTableFiltersValues } from "../../../data/data_table_filters";
import type { ExposeEntitySettings } from "../../../data/expose";
import { exposeEntities, voiceAssistants } from "../../../data/expose";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-loading-screen";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import type { HomeAssistant, Route, ValueChangedEvent } from "../../../types";
import "../entities/ha-entity-table";
import type { HaEntityTable } from "../entities/ha-entity-table";
import { getAvailableAssistants } from "./expose/available-assistants";
@customElement("ha-config-voice-assistants-expose-entity")
class HaConfigVoiceAssistantsExposeEntity extends DirtyStateProviderMixin<
string[]
>()(PreventUnsavedMixin(LitElement)) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public route!: Route;
@property({ attribute: false }) public exposedEntities?: Record<
string,
ExposeEntitySettings
>;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state() private _assistantQuery: VoiceAssistantQueryParams["assistants"];
@state() private _assistants: (keyof typeof voiceAssistants)[] = [];
@state() private _selected: string[] = [];
@state() private _filter = "";
@state() private _filters: DataTableFiltersValues = {};
@state() private _saving = false;
@state() private _error?: string;
@query("ha-entity-table") private _table?: HaEntityTable;
private _acceptedUrl = window.location.href;
private _queryChangePending = false;
private _discardChangesPromise?: Promise<boolean>;
public connectedCallback() {
super.connectedCallback();
this._initDirtyTracking({ type: "deep" }, []);
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._locationChanged);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._locationChanged);
}
protected willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("route")) {
this._locationChanged();
}
if (
changedProperties.has("route") ||
changedProperties.has("_assistantQuery") ||
changedProperties.has("cloudStatus") ||
changedProperties.has("hass")
) {
const requested = new Set(
this._assistantQuery ??
getAvailableAssistants(this.cloudStatus, this.hass)
);
const assistants = Object.keys(voiceAssistants).filter(
(assistant): assistant is keyof typeof voiceAssistants =>
requested.has(assistant)
);
if (!deepEqual(assistants, this._assistants)) {
this._assistants = assistants;
this._table?.clearSelection();
this._selected = [];
this._initDirtyTracking({ type: "deep" }, []);
this._error = undefined;
}
}
super.willUpdate(changedProperties);
}
private _locationChanged = async () => {
if (
!this.isConnected ||
!this.route ||
currentPath() !== `${this.route.prefix}${this.route.path}` ||
this._queryChangePending
) {
return;
}
const { assistants: assistantQuery } = decodeVoiceAssistantQueryParams(
extractSearchParamsObject()
);
if (!deepEqual(assistantQuery, this._assistantQuery) && this.isDirtyState) {
this._queryChangePending = true;
try {
const confirmed = await this.promptDiscardChanges();
if (
!this.isConnected ||
currentPath() !== `${this.route.prefix}${this.route.path}`
) {
return;
}
if (!confirmed) {
replaceCurrentUrl(this._acceptedUrl);
}
} finally {
this._queryChangePending = false;
}
// Read the latest URL if more query changes arrived during the prompt.
this._locationChanged();
return;
}
if (!deepEqual(assistantQuery, this._assistantQuery)) {
this._assistantQuery = assistantQuery;
}
this._acceptedUrl = window.location.href;
};
private _candidateIds = memoizeOne(
(
states: HomeAssistant["states"],
exposedEntities: Record<string, ExposeEntitySettings>,
assistants: (keyof typeof voiceAssistants)[]
): ReadonlySet<string> =>
new Set(
Object.keys(states).filter((entityId) =>
assistants.some(
(assistant) => !exposedEntities[entityId]?.[assistant]
)
)
)
);
private get _backPath() {
return `/config/voice-assistants/expose?${createVoiceAssistantQueryString({
assistants: this._assistants,
})}`;
}
protected render() {
if (!this.exposedEntities) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
return html`
<ha-entity-table
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
.narrow=${this.narrow}
.isWide=${this.isWide}
.route=${this.route}
.tabs=${[
{
path: "/config/voice-assistants/expose-entity",
name: this._assistants.length
? this._i18n.localize(
"ui.panel.config.voice_assistants.expose.expose_page.expose_to",
{
assistants: this._assistants
.map((assistant) => voiceAssistants[assistant].name)
.join(", "),
}
)
: this._i18n.localize(
"ui.panel.config.voice_assistants.expose.add"
),
},
]}
.backPath=${this._backPath}
.backCallback=${this._backTapped}
.entityIds=${this._candidateIds(
this._states,
this.exposedEntities,
this._assistants
)}
.exposedEntities=${this.exposedEntities}
.selectionMode=${"always"}
.selected=${this._selected.length}
.filter=${this._filter}
.filterValues=${this._filters}
.initialGroupColumn=${"device_full"}
.noDataText=${this._i18n.localize(
this._assistants.length
? "ui.panel.config.voice_assistants.expose.expose_page.no_entities"
: "ui.panel.config.voice_assistants.expose.expose_page.no_assistants"
)}
has-fab
@selection-changed=${this._selectionChanged}
@search-changed=${this._searchChanged}
@entity-table-filters-changed=${this._filtersChanged}
>
${
this._error
? html`<ha-alert slot="top-header" alert-type="error"
>${this._error}</ha-alert
>`
: nothing
}
<ha-button
slot="fab"
size="l"
.disabled=${!this._assistants.length || !this._selected.length || this._saving}
.loading=${this._saving}
@click=${this._expose}
>
${this._i18n.localize(
"ui.panel.config.voice_assistants.expose.expose_page.expose_entities",
{ count: this._selected.length }
)}
</ha-button>
</ha-entity-table>
`;
}
private _selectionChanged(ev: HASSDomEvent<SelectionChangedEvent>) {
this._selected = [...ev.detail.value];
this._updateDirtyState(this._selected);
}
private _searchChanged(ev: ValueChangedEvent<string>) {
this._filter = ev.detail.value;
}
private _filtersChanged(
ev: HASSDomEvent<HASSDomEvents["entity-table-filters-changed"]>
) {
this._filters = ev.detail.value;
}
protected async promptDiscardChanges() {
if (!this._discardChangesPromise) {
this._discardChangesPromise = showConfirmationDialog(this, {
addHistory: false,
title: this._i18n.localize(
"ui.panel.config.voice_assistants.expose.expose_page.discard_title"
),
text: this._i18n.localize(
"ui.panel.config.voice_assistants.expose.expose_page.discard_text"
),
confirmText: this._i18n.localize(
"ui.panel.config.voice_assistants.expose.expose_page.discard"
),
dismissText: this._i18n.localize("ui.common.cancel"),
})
.then((confirmed) => {
if (!this.isConnected) {
return false;
}
if (confirmed) {
this._table?.clearSelection();
this._selected = [];
this._updateDirtyState(this._selected);
this._markDirtyStateClean();
}
return confirmed;
})
.finally(() => {
this._discardChangesPromise = undefined;
});
}
return this._discardChangesPromise;
}
private _backTapped = async () => {
if (this.isDirtyState && !(await this.promptDiscardChanges())) {
return;
}
if (this.isConnected) {
goBack(this._backPath);
}
};
private async _expose() {
if (this._saving || !this._assistants.length || !this._selected.length) {
return;
}
const assistants = this._assistants;
const selected = this._selected;
this._saving = true;
this._error = undefined;
try {
await exposeEntities(this.hass, [...assistants], [...selected], true);
} catch (err: unknown) {
if (this.isConnected && assistants === this._assistants) {
this._error = this._i18n.localize(
"ui.panel.config.voice_assistants.expose.expose_page.expose_failed",
{
error:
err && typeof err === "object" && "message" in err
? String(err.message)
: String(err),
}
);
}
return;
} finally {
this._saving = false;
}
if (!this.isConnected) {
return;
}
const unchanged =
assistants === this._assistants && selected === this._selected;
if (unchanged) {
this._markDirtyStateClean();
}
fireEvent(this, "exposed-entities-changed");
if (unchanged) {
goBack(this._backPath);
}
}
static styles = css`
:host {
display: block;
height: 100%;
}
ha-alert {
margin: var(--ha-space-4);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-voice-assistants-expose-entity": HaConfigVoiceAssistantsExposeEntity;
}
}
@@ -22,6 +22,10 @@ import {
} from "../../../common/entity/entity_domain_filter";
import { navigate } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
createVoiceAssistantQueryString,
decodeVoiceAssistantQueryParams,
} from "../../../common/url/voice-assistant-query-params";
import type {
DataTableColumnContainer,
DataTableRowData,
@@ -60,7 +64,6 @@ import {
import { getAvailableAssistants } from "./expose/available-assistants";
import "./expose/expose-assistant-icon";
import { voiceAssistantTabs } from "./ha-config-voice-assistants";
import { showExposeEntityDialog } from "./show-dialog-expose-entity";
import { showVoiceSettingsDialog } from "./show-dialog-voice-settings";
@customElement("ha-config-voice-assistants-expose")
@@ -598,18 +601,16 @@ export class VoiceAssistantsExpose extends LitElement {
}
private _addEntry() {
const assistants = this._searchParms.has("assistants")
? this._searchParms.get("assistants")!.split(",")
: this._availableAssistants;
showExposeEntityDialog(this, {
filterAssistants: assistants,
exposedEntities: this.exposedEntities!,
exposeEntities: (entities) => {
exposeEntities(this.hass, assistants, entities, true).then(() =>
fireEvent(this, "exposed-entities-changed")
);
},
});
const { assistants } = decodeVoiceAssistantQueryParams(
Object.fromEntries(this._searchParms)
);
navigate(
`/config/voice-assistants/expose-entity?${createVoiceAssistantQueryString(
{
assistants: assistants ?? this._availableAssistants,
}
)}`
);
}
private _handleSearchChange(ev: CustomEvent) {
@@ -67,6 +67,10 @@ class HaConfigVoiceAssistants extends HassRouterPage {
tag: "ha-config-voice-assistants-expose",
load: () => import("./ha-config-voice-assistants-expose"),
},
"expose-entity": {
tag: "ha-config-voice-assistants-expose-entity",
load: () => import("./ha-config-voice-assistants-expose-entity"),
},
debug: {
tag: "assist-debug",
load: () => import("./debug/assist-debug"),
@@ -1,21 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { ExposeEntitySettings } from "../../../data/expose";
export interface ExposeEntityDialogParams {
filterAssistants: string[];
exposedEntities: Record<string, ExposeEntitySettings>;
exposeEntities: (entities: string[]) => void;
}
export const loadExposeEntityDialog = () => import("./dialog-expose-entity");
export const showExposeEntityDialog = (
element: HTMLElement,
dialogParams: ExposeEntityDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-expose-entity",
dialogImport: loadExposeEntityDialog,
dialogParams,
});
};
+9 -4
View File
@@ -5035,10 +5035,15 @@
"unexpose_confirm_text": "Do you want to stop exposing {entities} entities to {assistants}?",
"manually_configured": "Configured in YAML, not editable in UI",
"not_supported": "Not supported by this assistant",
"expose_dialog": {
"header": "Expose entities",
"expose_to": "to {assistants}",
"expose_entities": "Expose {count} {count, plural,\n one {entity}\n other {entities}\n}"
"expose_page": {
"expose_to": "Expose to {assistants}",
"expose_entities": "Expose {count} {count, plural,\n one {entity}\n other {entities}\n}",
"no_entities": "No entities to expose",
"no_assistants": "No voice assistants selected",
"expose_failed": "Failed to expose entities: {error}",
"discard_title": "Discard selection?",
"discard_text": "The selected entities have not been exposed.",
"discard": "Discard"
},
"expose_new_entities": "Expose new entities",
"expose_new_entities_info": "Should new entities be exposed? Exposes supported devices that are not classified as security devices."
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import {
createVoiceAssistantQueryString,
decodeVoiceAssistantQueryParams,
} from "../../../src/common/url/voice-assistant-query-params";
describe("voice assistant query params", () => {
it("preserves omitted and explicitly empty assistant targets on roundtrip", () => {
for (const params of [{}, { assistants: [] }]) {
expect(
decodeVoiceAssistantQueryParams(
Object.fromEntries(
new URLSearchParams(createVoiceAssistantQueryString(params))
)
)
).toEqual(params);
}
});
it("roundtrips an escaped comma-separated assistant list", () => {
const params = { assistants: ["conversation", "cloud.google_assistant"] };
const query = createVoiceAssistantQueryString(params);
expect(query).toBe("assistants=conversation%2Ccloud.google_assistant");
expect(
decodeVoiceAssistantQueryParams(
Object.fromEntries(new URLSearchParams(query))
)
).toEqual(params);
});
});
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "../../../src/resources/polyfills/resize-observer";
import "../../../src/components/data-table/ha-data-table";
import type { HaDataTable } from "../../../src/components/data-table/ha-data-table";
import type { HASSDomEvent } from "../../../src/common/dom/fire_event";
let table: HaDataTable;
let selected: string[];
const settle = async () => {
await table.updateComplete;
await vi.runOnlyPendingTimersAsync();
await table.updateComplete;
};
beforeEach(() => {
vi.useFakeTimers();
table = document.createElement("ha-data-table");
selected = [];
table.addEventListener("selection-changed", (event) => {
selected = [
...(event as HASSDomEvent<HASSDomEvents["selection-changed"]>).detail
.value,
];
});
document.body.append(table);
});
afterEach(() => {
table.remove();
vi.useRealTimers();
});
describe("ha-data-table selection", () => {
it("unions currently eligible selectable rows with hidden selections without duplicates", async () => {
table.selectionScope = new Set(["a", "b", "disabled"]);
table.data = [{ id: "a" }];
await settle();
table.selectAll();
expect(selected).toEqual(["a"]);
table.data = [
{ id: "b" },
{ id: "disabled", selectable: false },
{ id: "outside" },
];
await settle();
expect(selected).toEqual(["a"]);
table.selectAll();
table.selectAll();
expect(selected).toEqual(["a", "b"]);
});
it("prunes hidden IDs only when their scope eligibility ends and clears selection globally", async () => {
table.selectionScope = new Set(["a", "b"]);
table.data = [{ id: "a" }, { id: "b" }];
await settle();
table.selectAll();
expect(selected).toEqual(["a", "b"]);
table.data = [];
await settle();
expect(selected).toEqual(["a", "b"]);
table.selectionScope = new Set(["b"]);
await settle();
expect(selected).toEqual(["b"]);
table.clearSelection();
expect(selected).toEqual([]);
table.selectAll();
expect(selected).toEqual([]);
});
it("replaces selection and prunes rows removed from data when no scope is supplied", async () => {
table.data = [{ id: "a" }, { id: "b" }];
await settle();
table.selectAll();
expect(selected).toEqual(["a", "b"]);
table.selectAll((row) => row.id === "a");
expect(selected).toEqual(["a"]);
table.data = [{ id: "b" }];
await settle();
expect(selected).toEqual([]);
table.selectAll();
expect(selected).toEqual(["b"]);
});
});
+155
View File
@@ -0,0 +1,155 @@
import { ContextProvider } from "@lit/context";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { HaFilterDevices } from "../../src/components/ha-filter-devices";
import "../../src/components/ha-filter-floor-areas";
import {
apiContext,
areasContext,
devicesContext,
floorsContext,
internationalizationContext,
statesContext,
} from "../../src/data/context";
import type { RelatedResult } from "../../src/data/search";
import type { HomeAssistant } from "../../src/types";
import { createMockHass } from "../fixtures/hass";
const attachInternals = HTMLElement.prototype.attachInternals;
describe.each(["ha-filter-floor-areas", "ha-filter-devices"] as const)(
"%s related results",
(tag) => {
let host: HTMLDivElement;
let element: HTMLElementTagNameMap[typeof tag];
let changes: unknown[];
const callWS = vi.fn<HomeAssistant["callWS"]>();
const select = async (...ids: string[]) => {
if (element instanceof HaFilterDevices) {
element.value = ids;
} else {
element.value = { floors: ids };
}
await element.updateComplete;
};
const response = () => {
let resolve!: (result: RelatedResult) => void;
const promise = new Promise<RelatedResult>((res) => {
resolve = res;
});
callWS.mockReturnValueOnce(promise);
return async (result: RelatedResult) => {
resolve(result);
await Promise.all([promise]);
};
};
beforeEach(async () => {
// jsdom lacks the form validity methods used by the header's button.
vi.spyOn(HTMLElement.prototype, "attachInternals").mockImplementation(
function (this: HTMLElement) {
const internals = attachInternals.call(this);
internals.setValidity = vi.fn();
internals.setFormValue = vi.fn();
Object.defineProperty(internals, "validity", {
value: document.createElement("input").validity,
});
return internals;
}
);
callWS.mockReset();
changes = [];
host = document.createElement("div");
const hass = createMockHass();
new ContextProvider(host, {
context: internationalizationContext,
initialValue: hass,
});
new ContextProvider(host, {
context: apiContext,
// Vitest erases callWS's generic return type; these calls return RelatedResult.
initialValue: { ...hass, callWS: callWS as HomeAssistant["callWS"] },
});
new ContextProvider(host, { context: areasContext, initialValue: {} });
new ContextProvider(host, { context: floorsContext, initialValue: {} });
new ContextProvider(host, { context: devicesContext, initialValue: {} });
new ContextProvider(host, { context: statesContext, initialValue: {} });
document.body.append(host);
element = document.createElement(tag);
element.type = "entity";
host.append(element);
await element.updateComplete;
element.addEventListener("data-table-filter-changed", (event) => {
changes.push((event as HASSDomEvent<unknown>).detail);
});
});
afterEach(() => {
host.remove();
vi.restoreAllMocks();
});
it("keeps the latest selection's results when an older response arrives", async () => {
const oldResponse = response();
await select("a");
const newResponseA = response();
const newResponseB = response();
await select("a", "b");
const value = element.value;
await newResponseA({ entity: ["light.a"] });
await newResponseB({ entity: ["light.b"] });
expect(changes).toEqual([
{ value, items: new Set(["light.a", "light.b"]) },
]);
await oldResponse({ entity: ["light.a"] });
expect(changes).toHaveLength(1);
});
it("does not restore a cleared filter when a pending response arrives", async () => {
const pendingResponse = response();
await select("a");
element.value = undefined;
await element.updateComplete;
expect(changes).toEqual([
{
value: element instanceof HaFilterDevices ? [] : {},
items: undefined,
},
]);
await pendingResponse({ entity: ["light.a"] });
expect(changes).toHaveLength(1);
});
it("emits pending results after an equivalent value is reassigned", async () => {
const pendingResponse = response();
await select("a");
await select("a");
await pendingResponse({ entity: ["light.a"] });
expect(changes).toEqual([
{ value: element.value, items: new Set(["light.a"]) },
]);
});
it("uses the current result type when responses arrive out of order", async () => {
const oldResponse = response();
await select("a");
const newResponse = response();
element.type = "device";
await element.updateComplete;
await newResponse({ entity: ["light.a"], device: ["device.a"] });
expect(changes).toEqual([
{ value: element.value, items: new Set(["device.a"]) },
]);
await oldResponse({ entity: ["light.old"], device: ["device.old"] });
expect(changes).toHaveLength(1);
});
}
);