mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-14 00:58:26 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b4969d1f2 | ||
|
|
35a53193ed | ||
|
|
d95dea4911 | ||
|
|
4032ead018 |
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);
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
);
|
||||
Reference in new issue
Block a user