From 68935d46cefa00aa034239a0ca6b519aacfa6662 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 27 Mar 2024 15:26:01 +0100 Subject: [PATCH] Add categories, filtering, grouping to automation panel (#20197) * Add categories and filtering to automation panel * Update search-input-outlined.ts * Update ha-config-entities.ts * fix resetting area filter * fixes * Update ha-category-picker.ts * Update ha-filter-blueprints.ts * fix updating badge * fix overflow issue --- demo/src/ha-demo.ts | 2 + gallery/src/pages/misc/integration-card.ts | 1 + src/components/chips/ha-assist-chip.ts | 49 +- .../data-table/ha-data-table-labels.ts | 118 ++++ src/components/data-table/ha-data-table.ts | 266 +++++---- src/components/ha-area-floor-picker.ts | 29 +- .../ha-button-related-filter-menu.ts | 221 -------- src/components/ha-expansion-panel.ts | 6 +- src/components/ha-filter-blueprints.ts | 175 ++++++ src/components/ha-filter-categories.ts | 284 ++++++++++ src/components/ha-filter-devices.ts | 206 +++++++ src/components/ha-filter-entities.ts | 220 ++++++++ src/components/ha-filter-floor-areas.ts | 287 ++++++++++ src/components/ha-filter-integrations.ts | 183 +++++++ src/components/ha-filter-labels.ts | 190 +++++++ src/components/ha-filter-states.ts | 165 ++++++ src/components/search-input-outlined.ts | 112 ++++ src/data/category_registry.ts | 86 +++ src/data/entity_registry.ts | 2 + src/data/search.ts | 1 + src/layouts/hass-tabs-subpage-data-table.ts | 495 +++++++++++++---- src/layouts/hass-tabs-subpage.ts | 134 +++-- .../add-automation-element-dialog.ts | 1 - .../config/automation/ha-automation-picker.ts | 518 +++++++++++++----- .../config/blueprint/ha-blueprint-overview.ts | 2 +- .../config/category/dialog-assign-category.ts | 132 +++++ .../dialog-category-registry-detail.ts | 175 ++++++ .../config/category/ha-category-picker.ts | 281 ++++++++++ .../category/show-dialog-assign-category.ts | 21 + .../show-dialog-category-registry-detail.ts | 21 + .../config/entities/ha-config-entities.ts | 1 + .../config/helpers/ha-config-helpers.ts | 1 + .../ha-config-integrations-dashboard.ts | 83 ++- src/panels/config/scene/ha-scene-dashboard.ts | 23 - src/panels/config/script/ha-script-picker.ts | 23 - .../ha-config-voice-assistants-expose.ts | 7 +- src/translations/en.json | 66 ++- 37 files changed, 3849 insertions(+), 738 deletions(-) create mode 100644 src/components/data-table/ha-data-table-labels.ts delete mode 100644 src/components/ha-button-related-filter-menu.ts create mode 100644 src/components/ha-filter-blueprints.ts create mode 100644 src/components/ha-filter-categories.ts create mode 100644 src/components/ha-filter-devices.ts create mode 100644 src/components/ha-filter-entities.ts create mode 100644 src/components/ha-filter-floor-areas.ts create mode 100644 src/components/ha-filter-integrations.ts create mode 100644 src/components/ha-filter-labels.ts create mode 100644 src/components/ha-filter-states.ts create mode 100644 src/components/search-input-outlined.ts create mode 100644 src/data/category_registry.ts create mode 100644 src/panels/config/category/dialog-assign-category.ts create mode 100644 src/panels/config/category/dialog-category-registry-detail.ts create mode 100644 src/panels/config/category/ha-category-picker.ts create mode 100644 src/panels/config/category/show-dialog-assign-category.ts create mode 100644 src/panels/config/category/show-dialog-category-registry-detail.ts diff --git a/demo/src/ha-demo.ts b/demo/src/ha-demo.ts index 239f9b1c28..a3af6c30e0 100644 --- a/demo/src/ha-demo.ts +++ b/demo/src/ha-demo.ts @@ -73,6 +73,7 @@ export class HaDemo extends HomeAssistantAppEl { name: null, icon: null, labels: [], + categories: {}, platform: "co2signal", hidden_by: null, entity_category: null, @@ -90,6 +91,7 @@ export class HaDemo extends HomeAssistantAppEl { name: null, icon: null, labels: [], + categories: {}, platform: "co2signal", hidden_by: null, entity_category: null, diff --git a/gallery/src/pages/misc/integration-card.ts b/gallery/src/pages/misc/integration-card.ts index 94d79dc434..ca1e83c0b6 100644 --- a/gallery/src/pages/misc/integration-card.ts +++ b/gallery/src/pages/misc/integration-card.ts @@ -200,6 +200,7 @@ const createEntityRegistryEntries = ( unique_id: "updater", options: null, labels: [], + categories: {}, }, ]; diff --git a/src/components/chips/ha-assist-chip.ts b/src/components/chips/ha-assist-chip.ts index ba18e6b248..6e9e6bc7c9 100644 --- a/src/components/chips/ha-assist-chip.ts +++ b/src/components/chips/ha-assist-chip.ts @@ -4,22 +4,32 @@ import { css, html } from "lit"; import { customElement, property } from "lit/decorators"; @customElement("ha-assist-chip") +// @ts-ignore export class HaAssistChip extends MdAssistChip { @property({ type: Boolean, reflect: true }) filled = false; + @property({ type: Boolean }) active = false; + static override styles = [ ...super.styles, css` :host { --md-sys-color-primary: var(--primary-text-color); --md-sys-color-on-surface: var(--primary-text-color); - --md-assist-chip-container-shape: 16px; + --md-assist-chip-container-shape: var( + --ha-assist-chip-container-shape, + 16px + ); --md-assist-chip-outline-color: var(--outline-color); --md-assist-chip-label-text-weight: 400; --ha-assist-chip-filled-container-color: rgba( var(--rgb-primary-text-color), 0.15 ); + --ha-assist-chip-active-container-color: rgba( + var(--rgb-primary-color), + 0.15 + ); } /** Material 3 doesn't have a filled chip, so we have to make our own **/ .filled { @@ -31,10 +41,21 @@ export class HaAssistChip extends MdAssistChip { background-color: var(--ha-assist-chip-filled-container-color); } /** Set the size of mdc icons **/ - ::slotted([slot="icon"]) { + ::slotted([slot="icon"]), + ::slotted([slot="trailingIcon"]) { display: flex; --mdc-icon-size: var(--md-input-chip-icon-size, 18px); } + + .trailing.icon ::slotted(*), + .trailing.icon svg { + margin-inline-end: unset; + margin-inline-start: var(--_icon-label-space); + } + :where(.active)::before { + background: var(--ha-assist-chip-active-container-color); + opacity: var(--ha-assist-chip-active-container-opacity); + } `, ]; @@ -45,6 +66,30 @@ export class HaAssistChip extends MdAssistChip { return super.renderOutline(); } + + protected override getContainerClasses() { + return { + ...super.getContainerClasses(), + active: this.active, + }; + } + + protected override renderPrimaryContent() { + return html` + + ${this.label} + + + `; + } + + protected renderTrailingIcon() { + return html``; + } } declare global { diff --git a/src/components/data-table/ha-data-table-labels.ts b/src/components/data-table/ha-data-table-labels.ts new file mode 100644 index 0000000000..84ad9ca81a --- /dev/null +++ b/src/components/data-table/ha-data-table-labels.ts @@ -0,0 +1,118 @@ +import { css, html, LitElement, nothing, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../chips/ha-assist-chip"; +import { repeat } from "lit/directives/repeat"; +import { LabelRegistryEntry } from "../../data/label_registry"; +import { computeCssColor } from "../../common/color/compute-color"; +import { fireEvent } from "../../common/dom/fire_event"; + +@customElement("ha-data-table-labels") +class HaDataTableLabels extends LitElement { + @property({ attribute: false }) public labels!: LabelRegistryEntry[]; + + protected render(): TemplateResult { + return html` + + ${repeat( + this.labels.slice(0, 2), + (label) => label.label_id, + (label) => this._renderLabel(label, true) + )} + ${this.labels.length > 2 + ? html` + + ${repeat( + this.labels.slice(2), + (label) => label.label_id, + (label) => + html` + ${this._renderLabel(label, false)} + ` + )} + ` + : nothing} + + `; + } + + private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) { + const color = label?.color ? computeCssColor(label.color) : undefined; + return html` + ${label?.icon + ? html`` + : nothing} + `; + } + + private _labelClicked(ev: Event) { + const label = (ev.currentTarget as any).item as LabelRegistryEntry; + fireEvent(this, "label-clicked", { label }); + } + + protected _handleIconOverflowMenuOpened(e) { + e.stopPropagation(); + // If this component is used inside a data table, the z-index of the row + // needs to be increased. Otherwise the ha-button-menu would be displayed + // underneath the next row in the table. + const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null; + if (row) { + row.style.zIndex = "1"; + } + } + + protected _handleIconOverflowMenuClosed() { + const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null; + if (row) { + row.style.zIndex = ""; + } + } + + static get styles() { + return css` + :host { + display: block; + flex-grow: 1; + margin-top: 4px; + height: 22px; + } + ha-chip-set { + position: fixed; + flex-wrap: nowrap; + } + ha-assist-chip { + border: 1px solid var(--color); + --md-assist-chip-icon-size: 16px; + --md-assist-chip-container-height: 20px; + --md-assist-chip-leading-space: 12px; + --md-assist-chip-trailing-space: 12px; + --ha-assist-chip-active-container-color: var(--color); + --ha-assist-chip-active-container-opacity: 0.3; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-data-table-labels": HaDataTableLabels; + } + interface HASSDomEvents { + "label-clicked": { label: LabelRegistryEntry }; + } +} diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index 8c0cd60405..ea1e0c4924 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -32,6 +32,7 @@ import type { HaCheckbox } from "../ha-checkbox"; import "../ha-svg-icon"; import "../search-input"; import { filterData, sortData } from "./sort-filter"; +import { groupBy } from "../../common/util/group-by"; declare global { // for fire event @@ -67,13 +68,20 @@ export interface DataTableSortColumnData { filterKey?: string; valueColumn?: string; direction?: SortingDirection; + groupable?: boolean; } export interface DataTableColumnData extends DataTableSortColumnData { main?: boolean; title: TemplateResult | string; label?: TemplateResult | string; - type?: "numeric" | "icon" | "icon-button" | "overflow-menu" | "flex"; + type?: + | "numeric" + | "icon" + | "icon-button" + | "overflow" + | "overflow-menu" + | "flex"; template?: (row: T) => TemplateResult | string | typeof nothing; width?: string; maxWidth?: string; @@ -95,6 +103,8 @@ export interface SortableColumnContainer { [key: string]: ClonedDataTableColumnData; } +const UNDEFINED_GROUP_KEY = "zzzzz_undefined"; + @customElement("ha-data-table") export class HaDataTable extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -129,14 +139,16 @@ export class HaDataTable extends LitElement { @property({ type: String }) public filter = ""; + @property() public groupColumn?: string; + + @property() public sortColumn?: string; + + @property() public sortDirection: SortingDirection = null; + @state() private _filterable = false; @state() private _filter = ""; - @state() private _sortColumn?: string; - - @state() private _sortDirection: SortingDirection = null; - @state() private _filteredData: DataTableRowData[] = []; @state() private _headerHeight = 0; @@ -195,8 +207,14 @@ export class HaDataTable extends LitElement { for (const columnId in this.columns) { if (this.columns[columnId].direction) { - this._sortDirection = this.columns[columnId].direction!; - this._sortColumn = columnId; + this.sortDirection = this.columns[columnId].direction!; + this.sortColumn = columnId; + + fireEvent(this, "sorting-changed", { + column: columnId, + direction: this.sortDirection, + }); + break; } } @@ -226,11 +244,16 @@ export class HaDataTable extends LitElement { properties.has("data") || properties.has("columns") || properties.has("_filter") || - properties.has("_sortColumn") || - properties.has("_sortDirection") + properties.has("sortColumn") || + properties.has("sortDirection") || + properties.has("groupColumn") ) { this._sortFilterData(); } + + if (properties.has("selectable")) { + this._items = [...this._items]; + } } protected render() { @@ -263,75 +286,79 @@ export class HaDataTable extends LitElement { })} >
- ${this.selectable - ? html` -
- + ${this.selectable + ? html` +
- + + +
+ ` + : ""} + ${Object.entries(this.columns).map(([key, column]) => { + if (column.hidden) { + return ""; + } + const sorted = key === this.sortColumn; + const classes = { + "mdc-data-table__header-cell--numeric": + column.type === "numeric", + "mdc-data-table__header-cell--icon": column.type === "icon", + "mdc-data-table__header-cell--icon-button": + column.type === "icon-button", + "mdc-data-table__header-cell--overflow-menu": + column.type === "overflow-menu", + "mdc-data-table__header-cell--overflow": + column.type === "overflow", + sortable: Boolean(column.sortable), + "not-sorted": Boolean(column.sortable && !sorted), + grows: Boolean(column.grows), + }; + return html` +
+ ${column.sortable + ? html` + + ` + : ""} + ${column.title}
- ` - : ""} - ${Object.entries(this.columns).map(([key, column]) => { - if (column.hidden) { - return ""; - } - const sorted = key === this._sortColumn; - const classes = { - "mdc-data-table__header-cell--numeric": - column.type === "numeric", - "mdc-data-table__header-cell--icon": column.type === "icon", - "mdc-data-table__header-cell--icon-button": - column.type === "icon-button", - "mdc-data-table__header-cell--overflow-menu": - column.type === "overflow-menu", - sortable: Boolean(column.sortable), - "not-sorted": Boolean(column.sortable && !sorted), - grows: Boolean(column.grows), - }; - return html` -
- ${column.sortable - ? html` - - ` - : ""} - ${column.title} -
- `; - })} + `; + })} +
${!this._filteredData.length ? html` @@ -408,7 +435,7 @@ export class HaDataTable extends LitElement { : ""} ${Object.entries(this.columns).map(([key, column]) => { if (column.hidden) { - return ""; + return nothing; } return html`
item[this.groupColumn!]); + if (grouped.undefined) { + // make sure ungrouped items are at the bottom + grouped[UNDEFINED_GROUP_KEY] = grouped.undefined; + delete grouped.undefined; + } + const sorted: { + [key: string]: DataTableRowData[]; + } = Object.keys(grouped) + .sort() + .reduce((obj, key) => { + obj[key] = grouped[key]; + return obj; + }, {}); + const groupedItems: DataTableRowData[] = []; + Object.entries(sorted).forEach(([groupName, rows]) => { + groupedItems.push({ + append: true, + content: html`
+ ${groupName === UNDEFINED_GROUP_KEY ? "" : groupName || ""} +
`, + }); + + groupedItems.push(...rows); + }); + + this._items = groupedItems; + } else { + this._items = items; + } } else { this._items = data; } @@ -507,19 +569,19 @@ export class HaDataTable extends LitElement { if (!this.columns[columnId].sortable) { return; } - if (!this._sortDirection || this._sortColumn !== columnId) { - this._sortDirection = "asc"; - } else if (this._sortDirection === "asc") { - this._sortDirection = "desc"; + if (!this.sortDirection || this.sortColumn !== columnId) { + this.sortDirection = "asc"; + } else if (this.sortDirection === "asc") { + this.sortDirection = "desc"; } else { - this._sortDirection = null; + this.sortDirection = null; } - this._sortColumn = this._sortDirection === null ? undefined : columnId; + this.sortColumn = this.sortDirection === null ? undefined : columnId; fireEvent(this, "sorting-changed", { column: columnId, - direction: this._sortDirection, + direction: this.sortDirection, }); } @@ -552,8 +614,15 @@ export class HaDataTable extends LitElement { }; private _handleRowClick = (ev: Event) => { - const target = ev.target as HTMLElement; - if (["HA-CHECKBOX", "MWC-BUTTON"].includes(target.tagName)) { + if ( + ev + .composedPath() + .find((el) => + ["ha-checkbox", "mwc-button", "ha-button", "ha-assist-chip"].includes( + (el as HTMLElement).localName + ) + ) + ) { return; } const rowId = (ev.currentTarget as any).rowId; @@ -629,7 +698,7 @@ export class HaDataTable extends LitElement { .mdc-data-table__row { display: flex; width: 100%; - height: 52px; + height: var(--data-table-row-height, 52px); } .mdc-data-table__row ~ .mdc-data-table__row { @@ -655,7 +724,6 @@ export class HaDataTable extends LitElement { display: flex; width: 100%; border-bottom: 1px solid var(--divider-color); - overflow-x: auto; } .mdc-data-table__header-row::-webkit-scrollbar { @@ -809,7 +877,9 @@ export class HaDataTable extends LitElement { padding-inline-start: initial; } .mdc-data-table__cell--overflow-menu, - .mdc-data-table__header-cell--overflow-menu { + .mdc-data-table__cell--overflow, + .mdc-data-table__header-cell--overflow-menu, + .mdc-data-table__header-cell--overflow { overflow: initial; } .mdc-data-table__cell--icon-button a { @@ -839,6 +909,12 @@ export class HaDataTable extends LitElement { /* custom from here */ + .group-header { + padding-top: 12px; + width: 100%; + font-weight: 500; + } + :host { display: block; } diff --git a/src/components/ha-area-floor-picker.ts b/src/components/ha-area-floor-picker.ts index f5a7f59356..0938d1e365 100644 --- a/src/components/ha-area-floor-picker.ts +++ b/src/components/ha-area-floor-picker.ts @@ -39,25 +39,21 @@ interface FloorAreaEntry { icon: string | null; strings: string[]; type: "floor" | "area"; + hasFloor?: boolean; } const rowRenderer: ComboBoxLitRenderer = (item) => - item.type === "floor" - ? html` - ${item.icon - ? html`` - : nothing} - ${item.name} - ` - : html` - ${item.icon - ? html`` - : nothing} - ${item.name} - `; + html` + ${item.icon + ? html`` + : nothing} + ${item.name} + `; @customElement("ha-area-floor-picker") export class HaAreaFloorPicker extends SubscribeMixin(LitElement) { @@ -363,6 +359,7 @@ export class HaAreaFloorPicker extends SubscribeMixin(LitElement) { name: area.name, icon: area.icon, strings: [area.area_id, ...area.aliases, area.name], + hasFloor: true, })) ); }); diff --git a/src/components/ha-button-related-filter-menu.ts b/src/components/ha-button-related-filter-menu.ts deleted file mode 100644 index 77c7848baa..0000000000 --- a/src/components/ha-button-related-filter-menu.ts +++ /dev/null @@ -1,221 +0,0 @@ -import type { Corner } from "@material/mwc-menu"; -import "@material/mwc-menu/mwc-menu-surface"; -import { mdiFilterVariant } from "@mdi/js"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { fireEvent } from "../common/dom/fire_event"; -import { stopPropagation } from "../common/dom/stop_propagation"; -import { computeStateName } from "../common/entity/compute_state_name"; -import { computeDeviceName } from "../data/device_registry"; -import { findRelated, RelatedResult } from "../data/search"; -import type { HomeAssistant } from "../types"; -import "./device/ha-device-picker"; -import "./entity/ha-entity-picker"; -import "./ha-area-picker"; -import "./ha-icon-button"; - -declare global { - // for fire event - interface HASSDomEvents { - "related-changed": { - value?: FilterValue; - items?: RelatedResult; - filter?: string; - }; - } -} - -interface FilterValue { - area?: string; - device?: string; - entity?: string; -} - -@customElement("ha-button-related-filter-menu") -export class HaRelatedFilterButtonMenu extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property() public corner: Corner = "BOTTOM_START"; - - @property({ type: Boolean, reflect: true }) public narrow = false; - - @property({ type: Boolean }) public disabled = false; - - @property({ attribute: false }) public value?: FilterValue; - - /** - * Show no entities of these domains. - * @type {Array} - * @attr exclude-domains - */ - @property({ type: Array, attribute: "exclude-domains" }) - public excludeDomains?: string[]; - - @state() private _open = false; - - protected render(): TemplateResult { - return html` - - - - - - - `; - } - - private _handleClick(): void { - if (this.disabled) { - return; - } - this._open = true; - } - - private _onClosed(ev): void { - ev.stopPropagation(); - this._open = false; - } - - private _preventDefault(ev) { - ev.preventDefault(); - } - - private async _entityPicked(ev: CustomEvent) { - ev.stopPropagation(); - const entityId = ev.detail.value; - if (!entityId) { - fireEvent(this, "related-changed", { value: undefined }); - return; - } - const filter = this.hass.localize( - "ui.components.related-filter-menu.filtered_by_entity", - { - entity_name: computeStateName( - (ev.currentTarget as any).comboBox.selectedItem - ), - } - ); - const items = await findRelated(this.hass, "entity", entityId); - fireEvent(this, "related-changed", { - value: { entity: entityId }, - filter, - items, - }); - } - - private async _devicePicked(ev: CustomEvent) { - ev.stopPropagation(); - const deviceId = ev.detail.value; - if (!deviceId) { - fireEvent(this, "related-changed", { value: undefined }); - return; - } - const filter = this.hass.localize( - "ui.components.related-filter-menu.filtered_by_device", - { - device_name: computeDeviceName( - (ev.currentTarget as any).comboBox.selectedItem, - this.hass - ), - } - ); - const items = await findRelated(this.hass, "device", deviceId); - - fireEvent(this, "related-changed", { - value: { device: deviceId }, - filter, - items, - }); - } - - private async _areaPicked(ev: CustomEvent) { - ev.stopPropagation(); - const areaId = ev.detail.value; - if (!areaId) { - fireEvent(this, "related-changed", { value: undefined }); - return; - } - const filter = this.hass.localize( - "ui.components.related-filter-menu.filtered_by_area", - { area_name: (ev.currentTarget as any).comboBox.selectedItem.name } - ); - const items = await findRelated(this.hass, "area", areaId); - fireEvent(this, "related-changed", { - value: { area: areaId }, - filter, - items, - }); - } - - static get styles(): CSSResultGroup { - return css` - :host { - display: inline-block; - position: relative; - --mdc-menu-min-width: 250px; - } - ha-area-picker, - ha-device-picker, - ha-entity-picker { - display: block; - width: 300px; - padding: 4px 16px; - box-sizing: border-box; - } - ha-area-picker { - padding-top: 16px; - } - ha-entity-picker { - padding-bottom: 16px; - } - :host([narrow]) ha-area-picker, - :host([narrow]) ha-device-picker, - :host([narrow]) ha-entity-picker { - width: 100%; - } - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "ha-button-related-filter-menu": HaRelatedFilterButtonMenu; - } -} diff --git a/src/components/ha-expansion-panel.ts b/src/components/ha-expansion-panel.ts index a148c37f1b..5f82762fa3 100644 --- a/src/components/ha-expansion-panel.ts +++ b/src/components/ha-expansion-panel.ts @@ -83,13 +83,11 @@ export class HaExpansionPanel extends LitElement { protected willUpdate(changedProps: PropertyValues) { super.willUpdate(changedProps); - if (changedProps.has("expanded") && this.expanded) { + if (changedProps.has("expanded")) { this._showContent = this.expanded; setTimeout(() => { // Verify we're still expanded - if (this.expanded) { - this._container.style.overflow = "initial"; - } + this._container.style.overflow = this.expanded ? "initial" : "hidden"; }, 300); } } diff --git a/src/components/ha-filter-blueprints.ts b/src/components/ha-filter-blueprints.ts new file mode 100644 index 0000000000..ac11837c09 --- /dev/null +++ b/src/components/ha-filter-blueprints.ts @@ -0,0 +1,175 @@ +import { SelectedDetail } from "@material/mwc-list"; +import "@material/mwc-menu/mwc-menu-surface"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { findRelated, RelatedResult } from "../data/search"; +import type { HomeAssistant } from "../types"; +import { haStyleScrollbar } from "../resources/styles"; +import { Blueprints, fetchBlueprints } from "../data/blueprint"; + +@customElement("ha-filter-blueprints") +export class HaFilterBlueprints extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: string[]; + + @property() public type?: "automation" | "script"; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @state() private _shouldRender = false; + + @state() private _blueprints?: Blueprints; + + protected render() { + return html` + +
+ ${this.hass.localize("ui.panel.config.blueprint.caption")} + ${this.value?.length + ? html`
${this.value?.length}
` + : nothing} +
+ ${this._blueprints && this._shouldRender + ? html` + + ${Object.entries(this._blueprints).map(([id, blueprint]) => + "error" in blueprint + ? nothing + : html` + ${blueprint.metadata.name || id} + ` + )} + + ` + : nothing} +
+ `; + } + + protected async firstUpdated() { + if (!this.type) { + return; + } + this._blueprints = await fetchBlueprints(this.hass, this.type); + } + + protected updated(changed) { + if (changed.has("expanded") && this.expanded) { + setTimeout(() => { + if (this.narrow || !this.expanded) return; + this.renderRoot.querySelector("mwc-list")!.style.height = + `${this.clientHeight - 49}px`; + }, 300); + } + } + + private _expandedWillChange(ev) { + this._shouldRender = ev.detail.expanded; + } + + private _expandedChanged(ev) { + this.expanded = ev.detail.expanded; + } + + private async _blueprintsSelected( + ev: CustomEvent>> + ) { + const blueprints = this._blueprints!; + const relatedPromises: Promise[] = []; + + if (!ev.detail.index.size) { + fireEvent(this, "data-table-filter-changed", { + value: [], + items: undefined, + }); + this.value = []; + return; + } + + const value: string[] = []; + + for (const index of ev.detail.index) { + const blueprintId = Object.keys(blueprints)[index]; + value.push(blueprintId); + if (this.type) { + relatedPromises.push( + findRelated(this.hass, `${this.type}_blueprint`, blueprintId) + ); + } + } + this.value = value; + const results = await Promise.all(relatedPromises); + const items: Set = new Set(); + for (const result of results) { + if (result[this.type!]) { + result[this.type!]!.forEach((item) => items.add(item)); + } + } + + fireEvent(this, "data-table-filter-changed", { + value, + items: this.type ? items : undefined, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyleScrollbar, + css` + :host { + border-bottom: 1px solid var(--divider-color); + } + :host([expanded]) { + flex: 1; + height: 0; + } + ha-expansion-panel { + --ha-card-border-radius: 0; + --expansion-panel-content-padding: 0; + } + .header { + display: flex; + align-items: center; + } + .badge { + display: inline-block; + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: 0; + min-width: 16px; + box-sizing: border-box; + border-radius: 50%; + font-weight: 400; + font-size: 11px; + background-color: var(--accent-color); + line-height: 16px; + text-align: center; + padding: 0px 2px; + color: var(--text-accent-color, var(--text-primary-color)); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-filter-blueprints": HaFilterBlueprints; + } +} diff --git a/src/components/ha-filter-categories.ts b/src/components/ha-filter-categories.ts new file mode 100644 index 0000000000..4824305fa9 --- /dev/null +++ b/src/components/ha-filter-categories.ts @@ -0,0 +1,284 @@ +import { ActionDetail, SelectedDetail } from "@material/mwc-list"; +import { mdiDelete, mdiDotsVertical, mdiPencil, mdiPlus } from "@mdi/js"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { + CategoryRegistryEntry, + deleteCategoryRegistryEntry, + subscribeCategoryRegistry, +} from "../data/category_registry"; +import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { showCategoryRegistryDetailDialog } from "../panels/config/category/show-dialog-category-registry-detail"; +import { haStyleScrollbar } from "../resources/styles"; +import type { HomeAssistant } from "../types"; +import "./ha-expansion-panel"; +import "./ha-icon"; +import "./ha-list-item"; + +@customElement("ha-filter-categories") +export class HaFilterCategories extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: string[]; + + @property() public scope?: string; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @state() private _categories: CategoryRegistryEntry[] = []; + + @state() private _shouldRender = false; + + protected hassSubscribeRequiredHostProps = ["scope"]; + + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeCategoryRegistry( + this.hass.connection, + this.scope!, + (categories) => { + this._categories = categories; + } + ), + ]; + } + + protected render() { + return html` + +
+ ${this.hass.localize("ui.panel.config.category.caption")} + ${this.value?.length + ? html`
${this.value?.length}
` + : nothing} +
+ ${this._shouldRender + ? html` + + ${this.hass.localize( + "ui.panel.config.category.filter.show_all" + )} + ${this._categories.map( + (category) => + html` + ${category.icon + ? html`` + : nothing} + ${category.name} + + + ${this.hass.localize( + "ui.panel.config.category.editor.edit" + )} + ${this.hass.localize( + "ui.panel.config.category.editor.delete" + )} + + ` + )} + + ` + : nothing} +
+ ${this.expanded + ? html` + + ${this.hass.localize("ui.panel.config.category.editor.add")} + ` + : nothing} + `; + } + + protected updated(changed) { + if (changed.has("expanded") && this.expanded) { + setTimeout(() => { + if (!this.expanded) return; + this.renderRoot.querySelector("mwc-list")!.style.height = + `${this.clientHeight - (49 + 48)}px`; + }, 300); + } + } + + private _handleAction(ev: CustomEvent) { + const categoryId = (ev.currentTarget as any).categoryId; + switch (ev.detail.index) { + case 0: + this._editCategory(categoryId); + break; + case 1: + this._deleteCategory(categoryId); + break; + } + } + + private _editCategory(id: string) { + showCategoryRegistryDetailDialog(this, { + scope: this.scope!, + entry: this._categories.find((cat) => cat.category_id === id), + }); + } + + private async _deleteCategory(id: string) { + const confirm = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.category.editor.confirm_delete" + ), + text: this.hass.localize( + "ui.panel.config.category.editor.confirm_delete_text" + ), + confirmText: this.hass.localize("ui.common.delete"), + destructive: true, + }); + if (!confirm) { + return; + } + try { + await deleteCategoryRegistryEntry(this.hass, this.scope!, id); + fireEvent(this, "data-table-filter-changed", { + value: [], + items: undefined, + }); + } catch (err: any) { + alert(`Failed to delete: ${err.message}`); + } + } + + private _addCategory() { + if (!this.scope) { + return; + } + showCategoryRegistryDetailDialog(this, { scope: this.scope }); + } + + private _expandedWillChange(ev) { + this._shouldRender = ev.detail.expanded; + } + + private _expandedChanged(ev) { + this.expanded = ev.detail.expanded; + } + + private async _categorySelected(ev: CustomEvent>) { + if (!ev.detail.index) { + fireEvent(this, "data-table-filter-changed", { + value: [], + items: undefined, + }); + this.value = []; + return; + } + const index = ev.detail.index - 1; + + const val = this._categories![index]?.category_id; + if (!val) { + return; + } + this.value = [val]; + + fireEvent(this, "data-table-filter-changed", { + value: this.value, + items: undefined, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyleScrollbar, + css` + :host { + border-bottom: 1px solid var(--divider-color); + } + :host([expanded]) { + flex: 1; + height: 0; + } + ha-expansion-panel { + --ha-card-border-radius: 0; + --expansion-panel-content-padding: 0; + } + .header { + display: flex; + align-items: center; + } + .badge { + display: inline-block; + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: 0; + min-width: 16px; + box-sizing: border-box; + border-radius: 50%; + font-weight: 400; + font-size: 11px; + background-color: var(--accent-color); + line-height: 16px; + text-align: center; + padding: 0px 2px; + color: var(--text-accent-color, var(--text-primary-color)); + } + mwc-list { + --mdc-list-item-meta-size: auto; + --mdc-list-side-padding-right: 4px; + --mdc-icon-button-size: 36px; + } + .warning { + color: var(--error-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-filter-categories": HaFilterCategories; + } +} diff --git a/src/components/ha-filter-devices.ts b/src/components/ha-filter-devices.ts new file mode 100644 index 0000000000..72692f3cfd --- /dev/null +++ b/src/components/ha-filter-devices.ts @@ -0,0 +1,206 @@ +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + PropertyValues, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { stringCompare } from "../common/string/compare"; +import { computeDeviceName } from "../data/device_registry"; +import { findRelated, RelatedResult } from "../data/search"; +import { haStyleScrollbar } from "../resources/styles"; +import type { HomeAssistant } from "../types"; +import "./ha-expansion-panel"; +import "./ha-check-list-item"; +import { loadVirtualizer } from "../resources/virtualizer"; + +@customElement("ha-filter-devices") +export class HaFilterDevices extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: string[]; + + @property() public type?: keyof RelatedResult; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @property({ type: Boolean }) public narrow = false; + + @state() private _shouldRender = false; + + public willUpdate(properties: PropertyValues) { + super.willUpdate(properties); + + if (!this.hasUpdated) { + loadVirtualizer(); + } + } + + protected render() { + return html` + +
+ ${this.hass.localize("ui.panel.config.devices.caption")} + ${this.value?.length + ? html`
${this.value?.length}
` + : nothing} +
+ ${this._shouldRender + ? html` + + + ` + : nothing} +
+ `; + } + + private _renderItem = (device) => + html` + ${computeDeviceName(device, this.hass)} + `; + + private _handleItemClick(ev) { + const listItem = ev.target.closest("ha-check-list-item"); + const value = listItem?.value; + if (!value) { + return; + } + if (this.value?.includes(value)) { + this.value = this.value?.filter((val) => val !== value); + } else { + this.value = [...(this.value || []), value]; + } + listItem.selected = this.value?.includes(value); + this._findRelated(); + } + + protected updated(changed) { + if (changed.has("expanded") && this.expanded) { + setTimeout(() => { + if (!this.expanded) return; + this.renderRoot.querySelector("mwc-list")!.style.height = + `${this.clientHeight - 49}px`; + }, 300); + } + } + + private _expandedWillChange(ev) { + this._shouldRender = ev.detail.expanded; + } + + private _expandedChanged(ev) { + this.expanded = ev.detail.expanded; + } + + private _devices = memoizeOne((devices: HomeAssistant["devices"]) => { + const values = Object.values(devices); + return values.sort((a, b) => + stringCompare( + a.name_by_user || a.name || "", + b.name_by_user || b.name || "", + this.hass.locale.language + ) + ); + }); + + private async _findRelated() { + const relatedPromises: Promise[] = []; + + if (!this.value?.length) { + fireEvent(this, "data-table-filter-changed", { + value: [], + items: undefined, + }); + this.value = []; + return; + } + + const value: string[] = []; + + for (const deviceId of this.value) { + value.push(deviceId); + if (this.type) { + relatedPromises.push(findRelated(this.hass, "device", deviceId)); + } + } + this.value = value; + const results = await Promise.all(relatedPromises); + const items: Set = new Set(); + for (const result of results) { + if (result[this.type!]) { + result[this.type!]!.forEach((item) => items.add(item)); + } + } + + fireEvent(this, "data-table-filter-changed", { + value, + items: this.type ? items : undefined, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyleScrollbar, + css` + :host { + border-bottom: 1px solid var(--divider-color); + } + :host([expanded]) { + flex: 1; + height: 0; + } + + ha-expansion-panel { + --ha-card-border-radius: 0; + --expansion-panel-content-padding: 0; + } + .header { + display: flex; + align-items: center; + } + .badge { + display: inline-block; + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: 0; + min-width: 16px; + box-sizing: border-box; + border-radius: 50%; + font-weight: 400; + font-size: 11px; + background-color: var(--accent-color); + line-height: 16px; + text-align: center; + padding: 0px 2px; + color: var(--text-accent-color, var(--text-primary-color)); + } + ha-check-list-item { + width: 100%; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-filter-devices": HaFilterDevices; + } +} diff --git a/src/components/ha-filter-entities.ts b/src/components/ha-filter-entities.ts new file mode 100644 index 0000000000..94c98b87e8 --- /dev/null +++ b/src/components/ha-filter-entities.ts @@ -0,0 +1,220 @@ +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + PropertyValues, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { computeStateDomain } from "../common/entity/compute_state_domain"; +import { computeStateName } from "../common/entity/compute_state_name"; +import { stringCompare } from "../common/string/compare"; +import { findRelated, RelatedResult } from "../data/search"; +import { haStyleScrollbar } from "../resources/styles"; +import type { HomeAssistant } from "../types"; +import "./ha-state-icon"; +import "./ha-check-list-item"; +import { loadVirtualizer } from "../resources/virtualizer"; + +@customElement("ha-filter-entities") +export class HaFilterEntities extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: string[]; + + @property() public type?: keyof RelatedResult; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @state() private _shouldRender = false; + + public willUpdate(properties: PropertyValues) { + super.willUpdate(properties); + + if (!this.hasUpdated) { + loadVirtualizer(); + } + } + + protected render() { + return html` + +
+ ${this.hass.localize("ui.panel.config.entities.caption")} + ${this.value?.length + ? html`
${this.value?.length}
` + : nothing} +
+ ${this._shouldRender + ? html` + + + + + ` + : nothing} +
+ `; + } + + protected updated(changed) { + if (changed.has("expanded") && this.expanded) { + setTimeout(() => { + if (!this.expanded) return; + this.renderRoot.querySelector("mwc-list")!.style.height = + `${this.clientHeight - 49}px`; + }, 300); + } + } + + private _renderItem = (entity) => + html` + + ${computeStateName(entity)} + `; + + private _handleItemClick(ev) { + const listItem = ev.target.closest("ha-check-list-item"); + const value = listItem?.value; + if (!value) { + return; + } + if (this.value?.includes(value)) { + this.value = this.value?.filter((val) => val !== value); + } else { + this.value = [...(this.value || []), value]; + } + listItem.selected = this.value?.includes(value); + this._findRelated(); + } + + private _expandedWillChange(ev) { + this._shouldRender = ev.detail.expanded; + } + + private _expandedChanged(ev) { + this.expanded = ev.detail.expanded; + } + + private _entities = memoizeOne( + (states: HomeAssistant["states"], type: this["type"]) => { + const values = Object.values(states); + return values + .filter( + (entityState) => !type || computeStateDomain(entityState) !== type + ) + .sort((a, b) => + stringCompare( + computeStateName(a), + computeStateName(b), + this.hass.locale.language + ) + ); + } + ); + + private async _findRelated() { + const relatedPromises: Promise[] = []; + + if (!this.value?.length) { + fireEvent(this, "data-table-filter-changed", { + value: [], + items: undefined, + }); + this.value = []; + return; + } + + const value: string[] = []; + + for (const entityId of this.value) { + value.push(entityId); + if (this.type) { + relatedPromises.push(findRelated(this.hass, "entity", entityId)); + } + } + this.value = value; + const results = await Promise.all(relatedPromises); + const items: Set = new Set(); + for (const result of results) { + if (result[this.type!]) { + result[this.type!]!.forEach((item) => items.add(item)); + } + } + + fireEvent(this, "data-table-filter-changed", { + value, + items: this.type ? items : undefined, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyleScrollbar, + css` + :host { + border-bottom: 1px solid var(--divider-color); + } + :host([expanded]) { + flex: 1; + height: 0; + } + ha-expansion-panel { + --ha-card-border-radius: 0; + --expansion-panel-content-padding: 0; + } + .header { + display: flex; + align-items: center; + } + .badge { + display: inline-block; + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: 0; + min-width: 16px; + box-sizing: border-box; + border-radius: 50%; + font-weight: 400; + font-size: 11px; + background-color: var(--accent-color); + line-height: 16px; + text-align: center; + padding: 0px 2px; + color: var(--text-accent-color, var(--text-primary-color)); + } + ha-check-list-item { + width: 100%; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-filter-entities": HaFilterEntities; + } +} diff --git a/src/components/ha-filter-floor-areas.ts b/src/components/ha-filter-floor-areas.ts new file mode 100644 index 0000000000..a4a67869fc --- /dev/null +++ b/src/components/ha-filter-floor-areas.ts @@ -0,0 +1,287 @@ +import "@material/mwc-menu/mwc-menu-surface"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { + FloorRegistryEntry, + getFloorAreaLookup, + subscribeFloorRegistry, +} from "../data/floor_registry"; +import { findRelated, RelatedResult } from "../data/search"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { haStyleScrollbar } from "../resources/styles"; +import type { HomeAssistant } from "../types"; +import "./ha-check-list-item"; + +@customElement("ha-filter-floor-areas") +export class HaFilterFloorAreas extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: { + floors?: string[]; + areas?: string[]; + }; + + @property() public type?: keyof RelatedResult; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @state() private _shouldRender = false; + + @state() private _floors?: FloorRegistryEntry[]; + + protected render() { + const areas = this._areas(this.hass.areas, this._floors); + + return html` + +
+ ${this.hass.localize("ui.panel.config.areas.caption")} + ${this.value?.areas?.length || this.value?.floors?.length + ? html`
+ ${(this.value?.areas?.length || 0) + + (this.value?.floors?.length || 0)} +
` + : nothing} +
+ ${this._shouldRender + ? html` + + ${repeat( + areas?.floors || [], + (floor) => floor.floor_id, + (floor) => html` + + ${floor.icon + ? html`` + : nothing} + ${floor.name} + + ${repeat( + floor.areas, + (area) => area.area_id, + (area) => this._renderArea(area) + )} + ` + )} + ${repeat( + areas?.unassisgnedAreas, + (area) => area.area_id, + (area) => this._renderArea(area) + )} + + ` + : nothing} +
+ `; + } + + private _renderArea(area) { + return html` + ${area.icon + ? html`` + : nothing} + ${area.name} + `; + } + + private _handleItemClick(ev) { + ev.stopPropagation(); + + const listItem = ev.currentTarget; + const type = listItem?.type; + const value = listItem?.value; + + if (ev.detail.selected === listItem.selected || !value) { + return; + } + + if (this.value?.[type]?.includes(value)) { + this.value = { + ...this.value, + [type]: this.value[type].filter((val) => val !== value), + }; + } else { + if (!this.value) { + this.value = {}; + } + this.value = { + ...this.value, + [type]: [...(this.value[type] || []), value], + }; + } + + listItem.selected = this.value[type]?.includes(value); + + this._findRelated(); + } + + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeFloorRegistry(this.hass.connection, (floors) => { + this._floors = floors; + }), + ]; + } + + protected updated(changed) { + if (changed.has("expanded") && this.expanded) { + setTimeout(() => { + if (!this.expanded) return; + this.renderRoot.querySelector("mwc-list")!.style.height = + `${this.clientHeight - 49}px`; + }, 300); + } + } + + private _expandedWillChange(ev) { + this._shouldRender = ev.detail.expanded; + } + + private _expandedChanged(ev) { + this.expanded = ev.detail.expanded; + } + + private _areas = memoizeOne( + (areaReg: HomeAssistant["areas"], floors?: FloorRegistryEntry[]) => { + const areas = Object.values(areaReg); + + const floorAreaLookup = getFloorAreaLookup(areas); + + const unassisgnedAreas = areas.filter( + (area) => !area.floor_id || !floorAreaLookup[area.floor_id] + ); + return { + floors: floors?.map((floor) => ({ + ...floor, + areas: floorAreaLookup[floor.floor_id] || [], + })), + unassisgnedAreas: unassisgnedAreas, + }; + } + ); + + private async _findRelated() { + const relatedPromises: Promise[] = []; + + if ( + !this.value || + (!this.value.areas?.length && !this.value.floors?.length) + ) { + fireEvent(this, "data-table-filter-changed", { + value: {}, + items: undefined, + }); + return; + } + + if (this.value.areas) { + for (const areaId of this.value.areas) { + if (this.type) { + relatedPromises.push(findRelated(this.hass, "area", areaId)); + } + } + } + + if (this.value.floors) { + for (const floorId of this.value.floors) { + if (this.type) { + relatedPromises.push(findRelated(this.hass, "floor", floorId)); + } + } + } + + const results = await Promise.all(relatedPromises); + const items: Set = new Set(); + for (const result of results) { + if (result[this.type!]) { + result[this.type!]!.forEach((item) => items.add(item)); + } + } + + fireEvent(this, "data-table-filter-changed", { + value: this.value, + items: this.type ? items : undefined, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyleScrollbar, + css` + :host { + border-bottom: 1px solid var(--divider-color); + } + :host([expanded]) { + flex: 1; + height: 0; + } + ha-expansion-panel { + --ha-card-border-radius: 0; + --expansion-panel-content-padding: 0; + } + .header { + display: flex; + align-items: center; + } + .badge { + display: inline-block; + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: 0; + min-width: 16px; + box-sizing: border-box; + border-radius: 50%; + font-weight: 400; + font-size: 11px; + background-color: var(--accent-color); + line-height: 16px; + text-align: center; + padding: 0px 2px; + color: var(--text-accent-color, var(--text-primary-color)); + } + .floor { + padding-left: 32px; + padding-inline-start: 32px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-filter-floor-areas": HaFilterFloorAreas; + } + interface HASSDomEvents { + "data-table-filter-changed": { value: any; items: Set | undefined }; + } +} diff --git a/src/components/ha-filter-integrations.ts b/src/components/ha-filter-integrations.ts new file mode 100644 index 0000000000..7373c6af3a --- /dev/null +++ b/src/components/ha-filter-integrations.ts @@ -0,0 +1,183 @@ +import { SelectedDetail } from "@material/mwc-list"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { stringCompare } from "../common/string/compare"; +import { haStyleScrollbar } from "../resources/styles"; +import type { HomeAssistant } from "../types"; +import { + fetchIntegrationManifests, + IntegrationManifest, +} from "../data/integration"; +import "./ha-domain-icon"; + +@customElement("ha-filter-integrations") +export class HaFilterIntegrations extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: string[]; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @state() private _manifests?: IntegrationManifest[]; + + @state() private _shouldRender = false; + + protected render() { + return html` + +
+ ${this.hass.localize("ui.panel.config.integrations.caption")} + ${this.value?.length + ? html`
${this.value?.length}
` + : nothing} +
+ ${this._manifests && this._shouldRender + ? html` + + ${this._integrations(this._manifests).map( + (integration) => + html` + + ${integration.name || integration.domain} + ` + )} + + ` + : nothing} +
+ `; + } + + protected updated(changed) { + if (changed.has("expanded") && this.expanded) { + setTimeout(() => { + if (!this.expanded) return; + this.renderRoot.querySelector("mwc-list")!.style.height = + `${this.clientHeight - 49}px`; + }, 300); + } + } + + private _expandedWillChange(ev) { + this._shouldRender = ev.detail.expanded; + } + + private _expandedChanged(ev) { + this.expanded = ev.detail.expanded; + } + + protected async firstUpdated() { + this._manifests = await fetchIntegrationManifests(this.hass); + } + + private _integrations = memoizeOne((manifest: IntegrationManifest[]) => + manifest + .filter( + (mnfst) => + !mnfst.integration_type || + !["entity", "system", "hardware"].includes(mnfst.integration_type) + ) + .sort((a, b) => + stringCompare( + a.name || a.domain, + b.name || b.domain, + this.hass.locale.language + ) + ) + ); + + private async _integrationsSelected( + ev: CustomEvent>> + ) { + const integrations = this._integrations(this._manifests!); + + if (!ev.detail.index.size) { + fireEvent(this, "data-table-filter-changed", { + value: [], + items: undefined, + }); + this.value = []; + return; + } + + const value: string[] = []; + + for (const index of ev.detail.index) { + const domain = integrations[index].domain; + value.push(domain); + } + this.value = value; + + fireEvent(this, "data-table-filter-changed", { + value, + items: undefined, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyleScrollbar, + css` + :host { + border-bottom: 1px solid var(--divider-color); + } + :host([expanded]) { + flex: 1; + height: 0; + } + ha-expansion-panel { + --ha-card-border-radius: 0; + --expansion-panel-content-padding: 0; + } + .header { + display: flex; + align-items: center; + } + .badge { + display: inline-block; + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: 0; + min-width: 16px; + box-sizing: border-box; + border-radius: 50%; + font-weight: 400; + font-size: 11px; + background-color: var(--accent-color); + line-height: 16px; + text-align: center; + padding: 0px 2px; + color: var(--text-accent-color, var(--text-primary-color)); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-filter-integrations": HaFilterIntegrations; + } +} diff --git a/src/components/ha-filter-labels.ts b/src/components/ha-filter-labels.ts new file mode 100644 index 0000000000..622b694538 --- /dev/null +++ b/src/components/ha-filter-labels.ts @@ -0,0 +1,190 @@ +import { SelectedDetail } from "@material/mwc-list"; +import "@material/mwc-menu/mwc-menu-surface"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { computeCssColor } from "../common/color/compute-color"; +import { fireEvent } from "../common/dom/fire_event"; +import { + LabelRegistryEntry, + subscribeLabelRegistry, +} from "../data/label_registry"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { haStyleScrollbar } from "../resources/styles"; +import type { HomeAssistant } from "../types"; +import "./chips/ha-assist-chip"; +import "./ha-expansion-panel"; +import "./ha-icon"; +import "./ha-check-list-item"; + +@customElement("ha-filter-labels") +export class HaFilterLabels extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public value?: string[]; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @state() private _labels: LabelRegistryEntry[] = []; + + @state() private _shouldRender = false; + + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeLabelRegistry(this.hass.connection, (labels) => { + this._labels = labels; + }), + ]; + } + + protected render() { + return html` + +
+ ${this.hass.localize("ui.panel.config.labels.caption")} + ${this.value?.length + ? html`
${this.value?.length}
` + : nothing} +
+ ${this._shouldRender + ? html` + + ${this._labels.map((label) => { + const color = label.color + ? computeCssColor(label.color) + : undefined; + return html` + + ${label.icon + ? html`` + : nothing} + + `; + })} + + ` + : nothing} +
+ `; + } + + protected updated(changed) { + if (changed.has("expanded") && this.expanded) { + setTimeout(() => { + if (!this.expanded) return; + this.renderRoot.querySelector("mwc-list")!.style.height = + `${this.clientHeight - 49}px`; + }, 300); + } + } + + private _expandedWillChange(ev) { + this._shouldRender = ev.detail.expanded; + } + + private _expandedChanged(ev) { + this.expanded = ev.detail.expanded; + } + + private async _labelSelected(ev: CustomEvent>>) { + if (!ev.detail.index.size) { + fireEvent(this, "data-table-filter-changed", { + value: [], + items: undefined, + }); + this.value = []; + return; + } + + const value: string[] = []; + + for (const index of ev.detail.index) { + const labelId = this._labels[index].label_id; + value.push(labelId); + } + this.value = value; + + fireEvent(this, "data-table-filter-changed", { + value, + items: undefined, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyleScrollbar, + css` + :host { + border-bottom: 1px solid var(--divider-color); + } + :host([expanded]) { + flex: 1; + height: 0; + } + ha-expansion-panel { + --ha-card-border-radius: 0; + --expansion-panel-content-padding: 0; + } + .header { + display: flex; + align-items: center; + } + .badge { + display: inline-block; + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: 0; + min-width: 16px; + box-sizing: border-box; + border-radius: 50%; + font-weight: 400; + font-size: 11px; + background-color: var(--accent-color); + line-height: 16px; + text-align: center; + padding: 0px 2px; + color: var(--text-accent-color, var(--text-primary-color)); + } + .warning { + color: var(--error-color); + } + ha-assist-chip { + border: 1px solid var(--color); + --md-assist-chip-icon-size: 16px; + --md-assist-chip-leading-space: 12px; + --md-assist-chip-trailing-space: 12px; + --ha-assist-chip-active-container-color: var(--color); + --ha-assist-chip-active-container-opacity: 0.3; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-filter-labels": HaFilterLabels; + } +} diff --git a/src/components/ha-filter-states.ts b/src/components/ha-filter-states.ts new file mode 100644 index 0000000000..83a2da46eb --- /dev/null +++ b/src/components/ha-filter-states.ts @@ -0,0 +1,165 @@ +import { SelectedDetail } from "@material/mwc-list"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { haStyleScrollbar } from "../resources/styles"; +import type { HomeAssistant } from "../types"; +import "./ha-expansion-panel"; +import "./ha-check-list-item"; +import "./ha-icon"; + +@customElement("ha-filter-states") +export class HaFilterStates extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property({ attribute: false }) public value?: string[]; + + @property({ attribute: false }) public states?: { + value: any; + label?: string; + icon?: string; + }[]; + + @property({ type: Boolean }) public narrow = false; + + @property({ type: Boolean, reflect: true }) public expanded = false; + + @state() private _shouldRender = false; + + protected render() { + if (!this.states) { + return nothing; + } + const hasIcon = this.states.find((item) => item.icon); + return html` + +
+ ${this.label} + ${this.value?.length + ? html`
${this.value?.length}
` + : nothing} +
+ ${this._shouldRender + ? html` + + ${this.states.map( + (item) => + html` + ${item.icon + ? html`` + : nothing} + ${item.label} + ` + )} + + ` + : nothing} +
+ `; + } + + protected updated(changed) { + if (changed.has("expanded") && this.expanded) { + setTimeout(() => { + if (!this.expanded) return; + this.renderRoot.querySelector("mwc-list")!.style.height = + `${this.clientHeight - 49}px`; + }, 300); + } + } + + private _expandedWillChange(ev) { + this._shouldRender = ev.detail.expanded; + } + + private _expandedChanged(ev) { + this.expanded = ev.detail.expanded; + } + + private async _statesSelected(ev: CustomEvent>>) { + if (!ev.detail.index.size) { + fireEvent(this, "data-table-filter-changed", { + value: [], + items: undefined, + }); + this.value = []; + return; + } + + const value: string[] = []; + + for (const index of ev.detail.index) { + const val = this.states![index].value; + value.push(val); + } + this.value = value; + + fireEvent(this, "data-table-filter-changed", { + value, + items: undefined, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyleScrollbar, + css` + :host { + border-bottom: 1px solid var(--divider-color); + } + :host([expanded]) { + flex: 1; + height: 0; + } + ha-expansion-panel { + --ha-card-border-radius: 0; + --expansion-panel-content-padding: 0; + } + .header { + display: flex; + align-items: center; + } + .badge { + display: inline-block; + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: 0; + min-width: 16px; + box-sizing: border-box; + border-radius: 50%; + font-weight: 400; + font-size: 11px; + background-color: var(--accent-color); + line-height: 16px; + text-align: center; + padding: 0px 2px; + color: var(--text-accent-color, var(--text-primary-color)); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-filter-states": HaFilterStates; + } +} diff --git a/src/components/search-input-outlined.ts b/src/components/search-input-outlined.ts new file mode 100644 index 0000000000..06b74dffc1 --- /dev/null +++ b/src/components/search-input-outlined.ts @@ -0,0 +1,112 @@ +import "@material/web/textfield/outlined-text-field"; +import type { MdOutlinedTextField } from "@material/web/textfield/outlined-text-field"; +import { mdiMagnify } from "@mdi/js"; +import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { HomeAssistant } from "../types"; +import "./ha-icon-button"; +import "./ha-svg-icon"; + +@customElement("search-input-outlined") +class SearchInputOutlined extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public filter?: string; + + @property({ type: Boolean }) + public suffix = false; + + @property({ type: Boolean }) + public autofocus = false; + + @property({ type: String }) + public label?: string; + + @property({ type: String }) + public placeholder?: string; + + public focus() { + this._input?.focus(); + } + + @query("md-outlined-text-field", true) private _input!: MdOutlinedTextField; + + protected render(): TemplateResult { + return html` + + + + + + `; + } + + private async _filterChanged(value: string) { + fireEvent(this, "value-changed", { value: String(value) }); + } + + private async _filterInputChanged(e) { + this._filterChanged(e.target.value); + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: inline-flex; + } + md-outlined-text-field { + display: block; + width: 100%; + --md-sys-color-on-surface: var(--primary-text-color); + --md-sys-color-primary: var(--primary-text-color); + --md-outlined-text-field-input-text-color: var(--primary-text-color); + --md-sys-color-on-surface-variant: var(--secondary-text-color); + --md-outlined-field-top-space: 5.5px; + --md-outlined-field-bottom-space: 5.5px; + --md-outlined-field-outline-color: var(--outline-color); + --md-outlined-field-container-shape-start-start: 10px; + --md-outlined-field-container-shape-start-end: 10px; + --md-outlined-field-container-shape-end-end: 10px; + --md-outlined-field-container-shape-end-start: 10px; + --md-outlined-field-focus-outline-width: 1px; + --md-outlined-field-focus-outline-color: var(--primary-color); + } + ha-svg-icon, + ha-icon-button { + display: flex; + --mdc-icon-size: var(--md-input-chip-icon-size, 18px); + color: var(--primary-text-color); + } + ha-svg-icon { + outline: none; + } + .clear-button { + --mdc-icon-size: 20px; + } + .trailing { + display: flex; + align-items: center; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "search-input-outlined": SearchInputOutlined; + } +} diff --git a/src/data/category_registry.ts b/src/data/category_registry.ts new file mode 100644 index 0000000000..ee771e80cf --- /dev/null +++ b/src/data/category_registry.ts @@ -0,0 +1,86 @@ +import { Connection, createCollection } from "home-assistant-js-websocket"; +import { Store } from "home-assistant-js-websocket/dist/store"; +import { stringCompare } from "../common/string/compare"; +import { HomeAssistant } from "../types"; +import { debounce } from "../common/util/debounce"; + +export interface CategoryRegistryEntry { + category_id: string; + name: string; + icon: string | null; +} + +export interface CategoryRegistryEntryMutableParams { + name: string; + icon?: string | null; +} + +export const fetchCategoryRegistry = (conn: Connection, scope: string) => + conn + .sendMessagePromise({ + type: "config/category_registry/list", + scope, + }) + .then((categories) => + categories.sort((ent1, ent2) => stringCompare(ent1.name, ent2.name)) + ); + +export const subscribeCategoryRegistry = ( + conn: Connection, + scope: string, + onChange: (floors: CategoryRegistryEntry[]) => void +) => + createCollection( + `_categoryRegistry_${scope}`, + (conn2: Connection) => fetchCategoryRegistry(conn2, scope), + (conn2: Connection, store: Store) => + conn2.subscribeEvents( + debounce( + () => + fetchCategoryRegistry(conn2, scope).then( + (categories: CategoryRegistryEntry[]) => + store.setState(categories, true) + ), + 500, + true + ), + "category_registry_updated" + ), + conn, + onChange + ); + +export const createCategoryRegistryEntry = ( + hass: HomeAssistant, + scope: string, + values: CategoryRegistryEntryMutableParams +) => + hass.callWS({ + type: "config/category_registry/create", + scope, + ...values, + }); + +export const updateCategoryRegistryEntry = ( + hass: HomeAssistant, + scope: string, + category_id: string, + updates: Partial +) => + hass.callWS({ + type: "config/category_registry/update", + scope, + category_id, + ...updates, + }); + +export const deleteCategoryRegistryEntry = ( + hass: HomeAssistant, + scope: string, + category_id: string +) => + hass.callWS({ + type: "config/category_registry/delete", + scope, + category_id, + }); diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index acb05b2d47..9377c869cd 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -61,6 +61,7 @@ export interface EntityRegistryEntry { unique_id: string; translation_key?: string; options: EntityRegistryOptions | null; + categories: { [scope: string]: string }; } export interface ExtEntityRegistryEntry extends EntityRegistryEntry { @@ -137,6 +138,7 @@ export interface EntityRegistryEntryUpdateParams { | LightEntityOptions; aliases?: string[]; labels?: string[]; + categories?: { [scope: string]: string | null }; } const batteryPriorities = ["sensor", "binary_sensor"]; diff --git a/src/data/search.ts b/src/data/search.ts index 47ccd71514..ec21d03d58 100644 --- a/src/data/search.ts +++ b/src/data/search.ts @@ -26,6 +26,7 @@ export type ItemType = | "config_entry" | "device" | "entity" + | "floor" | "group" | "scene" | "script" diff --git a/src/layouts/hass-tabs-subpage-data-table.ts b/src/layouts/hass-tabs-subpage-data-table.ts index 096ec15e30..73bec8c7a0 100644 --- a/src/layouts/hass-tabs-subpage-data-table.ts +++ b/src/layouts/hass-tabs-subpage-data-table.ts @@ -1,15 +1,37 @@ -import "@material/mwc-button/mwc-button"; +import { ResizeController } from "@lit-labs/observers/resize-controller"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, query } from "lit/decorators"; +import "@material/mwc-button/mwc-button"; +import { + mdiArrowDown, + mdiArrowUp, + mdiClose, + mdiFilterRemove, + mdiFilterVariant, + mdiFormatListChecks, + mdiMenuDown, +} from "@mdi/js"; +import { + CSSResultGroup, + LitElement, + TemplateResult, + css, + html, + nothing, +} from "lit"; +import { customElement, property, query, state } from "lit/decorators"; import { fireEvent } from "../common/dom/fire_event"; import { LocalizeFunc } from "../common/translations/localize"; +import "../components/chips/ha-assist-chip"; +import "../components/chips/ha-filter-chip"; import "../components/data-table/ha-data-table"; import type { DataTableColumnContainer, DataTableRowData, HaDataTable, + SortingDirection, } from "../components/data-table/ha-data-table"; +import "../components/ha-dialog"; +import "../components/search-input-outlined"; import type { HomeAssistant, Route } from "../types"; import "./hass-tabs-subpage"; import type { PageNavigation } from "./hass-tabs-subpage"; @@ -87,22 +109,16 @@ export class HaTabsSubpageDataTable extends LitElement { @property() public searchLabel?: string; /** - * List of strings that show what the data is currently filtered by. - * @type {Array} - */ - @property({ type: Array }) public activeFilters?; - - /** - * Text to how how many items are hidden. - * @type {String} - */ - @property() public hiddenLabel?: string; - - /** - * How many items are hidden because of active filters. + * Number of active filters. * @type {Number} */ - @property({ type: Number }) public numHidden = 0; + @property({ type: Number }) public filters?; + + /** + * Number of current selections. + * @type {Number} + */ + @property({ type: Number }) public selected?; /** * What path to use when the back button is pressed. @@ -138,57 +154,146 @@ export class HaTabsSubpageDataTable extends LitElement { @property({ attribute: false }) public tabs: PageNavigation[] = []; /** - * Force hides the filter menu. + * Show the filter menu. * @type {Boolean} */ - @property({ type: Boolean }) public hideFilterMenu = false; + @property({ type: Boolean }) public hasFilters = false; + + @property({ type: Boolean }) public showFilters = false; + + @property() public initialGroupColumn?: string; + + @state() private _sortColumn?: string; + + @state() private _sortDirection: SortingDirection = null; + + @state() private _groupColumn?: string; + + @state() private _selectMode = false; @query("ha-data-table", true) private _dataTable!: HaDataTable; + private _showPaneController = new ResizeController(this, { + callback: (entries) => entries[0]?.contentRect.width > 750, + }); + public clearSelection() { this._dataTable.clearSelection(); } + protected firstUpdated() { + if (this.initialGroupColumn) { + this._groupColumn = this.initialGroupColumn; + } + } + protected render(): TemplateResult { - const hiddenLabel = this.numHidden - ? this.hiddenLabel || - this.hass.localize("ui.components.data-table.hidden", { - number: this.numHidden, - }) || - this.numHidden - : undefined; + const localize = this.localizeFunc || this.hass.localize; + const showPane = this._showPaneController.value ?? !this.narrow; + const filterButton = this.hasFilters + ? html`
+ + + + ${this.filters + ? html`
${this.filters}
` + : nothing} +
` + : nothing; - const filterInfo = this.activeFilters - ? html`${this.hass.localize("ui.components.data-table.filtering_by")} - ${this.activeFilters.join(", ")} - ${hiddenLabel ? `(${hiddenLabel})` : ""}` - : hiddenLabel; + const selectModeBtn = + this.selectable && !this._selectMode + ? html` + + ` + : nothing; - const headerToolbar = html` - ${!this.narrow - ? html`
`; + + const sortByMenu = Object.values(this.columns).find((col) => col.sortable) + ? html` + - ${filterInfo - ? html`
- ${filterInfo} - - ${this.hass.localize("ui.components.data-table.clear")} - -
` - : ""} - -
` - : ""} -
`; + + ${Object.entries(this.columns).map(([id, column]) => + column.sortable + ? html` + ${this._sortColumn === id + ? html`` + : nothing} + ${column.title || column.label} + ` + : nothing + )} + ` + : nothing; + + const groupByMenu = Object.values(this.columns).find((col) => col.groupable) + ? html` + + + ${Object.entries(this.columns).map(([id, column]) => + column.groupable + ? html` + ${column.title || column.label} + ` + : nothing + )} +
  • + ${localize( + "ui.components.subpage-data-table.dont_group_by" + )} +
    ` + : nothing; return html` + ${this._selectMode + ? html`
    +
    + +

    + ${localize("ui.components.subpage-data-table.selected", { + selected: this.selected || "0", + })} +

    +
    +
    + +
    +
    ` + : nothing} + ${this.showFilters + ? !showPane + ? html` + + + ${localize( + "ui.components.subpage-data-table.filters" + )} + + +
    +
    ` + : html`
    +
    + + + + +
    +
    + +
    +
    ` + : nothing} ${this.empty ? html`
    ${this.noDataText}
    ` - : html`${!this.hideFilterMenu - ? html` -
    - ${this.narrow - ? html` -
    - ${this.numHidden || this.activeFilters - ? html`${this.numHidden || "!"}` - : ""} - -
    - ` - : ""} -
    - ` - : ""} + : html`
    + +
    ${this.narrow ? html`
    -
    ${headerToolbar}
    +
    ${searchBar}
    ` @@ -240,30 +400,76 @@ export class HaTabsSubpageDataTable extends LitElement { .data=${this.data} .noDataText=${this.noDataText} .filter=${this.filter} - .selectable=${this.selectable} + .selectable=${this._selectMode} .hasFab=${this.hasFab} .id=${this.id} .clickable=${this.clickable} .appendRow=${this.appendRow} + .sortColumn=${this._sortColumn} + .sortDirection=${this._sortDirection} + .groupColumn=${this._groupColumn} > ${!this.narrow ? html`
    -
    ${headerToolbar}
    +
    + ${this.hasFilters && !this.showFilters + ? html`${filterButton}` + : nothing}${selectModeBtn}${searchBar}${groupByMenu}${sortByMenu} +
    ` - : html`
    `} + : html`
    +
    + ${this.hasFilters && !this.showFilters + ? html`${filterButton}` + : nothing} + ${selectModeBtn}${groupByMenu}${sortByMenu} +
    `} `} -
    `; } - private _preventDefault(ev) { - ev.preventDefault(); + private _clearFilters() { + fireEvent(this, "clear-filter"); + } + + private _toggleFilters() { + this.showFilters = !this.showFilters; + } + + private _sortingChanged(ev) { + this._sortDirection = ev.detail.direction; + this._sortColumn = this._sortDirection ? ev.detail.column : undefined; + } + + private _handleSortBy(ev) { + ev.stopPropagation(); + const columnId = ev.currentTarget.value; + if (!this._sortDirection || this._sortColumn !== columnId) { + this._sortDirection = "asc"; + } else if (this._sortDirection === "asc") { + this._sortDirection = "desc"; + } else { + this._sortDirection = null; + } + this._sortColumn = this._sortDirection === null ? undefined : columnId; + } + + private _handleGroupBy(ev) { + this._groupColumn = ev.currentTarget.value; + } + + private _enableSelectMode() { + this._selectMode = true; + } + + private _disableSelectMode() { + this._selectMode = false; } private _handleSearchChange(ev: CustomEvent) { @@ -274,54 +480,56 @@ export class HaTabsSubpageDataTable extends LitElement { fireEvent(this, "search-changed", { value: this.filter }); } - private _clearFilter() { - fireEvent(this, "clear-filter"); - } - static get styles(): CSSResultGroup { return css` + :host { + display: block; + } + ha-data-table { width: 100%; height: 100%; --data-table-border-width: 0; } - :host(:not([narrow])) ha-data-table { + :host(:not([narrow])) ha-data-table, + .pane { height: calc(100vh - 1px - var(--header-height)); display: block; } + + .pane-content { + height: calc(100vh - 1px - var(--header-height) - var(--header-height)); + display: flex; + flex-direction: column; + } + :host([narrow]) hass-tabs-subpage { --main-title-margin: 0; } + :host([narrow]) { + --expansion-panel-summary-padding: 0 16px; + } .table-header { display: flex; align-items: center; --mdc-shape-small: 0; height: 56px; + width: 100%; + justify-content: space-between; + padding: 0 16px; + gap: 16px; + box-sizing: border-box; + background: var(--primary-background-color); + border-bottom: 1px solid var(--divider-color); + } + search-input-outlined { + flex: 1; } .search-toolbar { display: flex; align-items: center; color: var(--secondary-text-color); } - search-input { - --mdc-text-field-fill-color: var(--sidebar-background-color); - --mdc-text-field-idle-line-color: var(--divider-color); - --text-field-overflow: visible; - z-index: 5; - } - .table-header search-input { - display: block; - position: absolute; - top: 0; - right: 0; - left: 0; - } - .search-toolbar search-input { - display: block; - width: 100%; - color: var(--secondary-text-color); - --mdc-ripple-color: transparant; - } .filters { --mdc-text-field-fill-color: var(--input-fill-color); --mdc-text-field-idle-line-color: var(--input-idle-line-color); @@ -382,9 +590,6 @@ export class HaTabsSubpageDataTable extends LitElement { top: 4px; font-size: 0.65em; } - .filter-menu { - position: relative; - } .center { display: flex; align-items: center; @@ -395,6 +600,92 @@ export class HaTabsSubpageDataTable extends LitElement { width: 100%; padding: 16px; } + + .badge { + position: absolute; + top: -4px; + right: -4px; + min-width: 16px; + box-sizing: border-box; + border-radius: 50%; + font-weight: 400; + font-size: 11px; + background-color: var(--accent-color); + line-height: 16px; + text-align: center; + padding: 0px 2px; + color: var(--text-accent-color, var(--text-primary-color)); + } + + .narrow-header-row { + display: flex; + align-items: center; + gap: 16px; + padding: 0 16px; + overflow-x: scroll; + -ms-overflow-style: none; + scrollbar-width: none; + } + + .selection-bar { + background: rgba(var(--rgb-primary-color), 0.1); + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + box-sizing: border-box; + font-size: 14px; + } + + .center-vertical { + display: flex; + align-items: center; + } + + .relative { + position: relative; + } + + .selection-bar p { + margin-left: 16px; + } + + ha-assist-chip { + --ha-assist-chip-container-shape: 10px; + } + ha-button-menu { + --mdc-list-item-meta-size: 16px; + --mdc-list-item-meta-display: flex; + } + ha-button-menu ha-assist-chip { + --md-assist-chip-trailing-space: 8px; + } + + .select-mode-chip { + --md-assist-chip-icon-label-space: 0; + } + + ha-dialog { + --mdc-dialog-min-width: calc( + 100vw - env(safe-area-inset-right) - env(safe-area-inset-left) + ); + --mdc-dialog-max-width: calc( + 100vw - env(safe-area-inset-right) - env(safe-area-inset-left) + ); + --mdc-dialog-min-height: 100%; + --mdc-dialog-max-height: 100%; + --vertical-align-dialog: flex-end; + --ha-dialog-border-radius: 0; + --dialog-content-padding: 0; + } + + .filter-dialog-content { + height: calc(100vh - 1px - var(--header-height)); + display: flex; + flex-direction: column; + } `; } } diff --git a/src/layouts/hass-tabs-subpage.ts b/src/layouts/hass-tabs-subpage.ts index 3fe46f8456..c557856eb8 100644 --- a/src/layouts/hass-tabs-subpage.ts +++ b/src/layouts/hass-tabs-subpage.ts @@ -4,6 +4,7 @@ import { CSSResultGroup, html, LitElement, + nothing, PropertyValues, TemplateResult, } from "lit"; @@ -57,6 +58,8 @@ class HassTabsSubpage extends LitElement { @property({ type: Boolean, reflect: true, attribute: "is-wide" }) public isWide = false; + @property({ type: Boolean }) public pane = false; + @state() private _activeTab?: PageNavigation; // @ts-ignore @@ -128,49 +131,62 @@ class HassTabsSubpage extends LitElement { const showTabs = tabs.length > 1; return html`
    - ${this.mainPage || (!this.backPath && history.state?.root) - ? html` - - ` - : this.backPath - ? html` - - +
    + ${this.mainPage || (!this.backPath && history.state?.root) + ? html` + - - ` - : html` - - `} - ${this.narrow || !showTabs - ? html`
    - ${!showTabs ? tabs[0] : ""} -
    ` + .narrow=${this.narrow} + > + ` + : this.backPath + ? html` + + + + ` + : html` + + `} + ${this.narrow || !showTabs + ? html`
    + ${!showTabs ? tabs[0] : ""} +
    ` + : ""} + ${showTabs && !this.narrow + ? html`
    ${tabs}
    ` + : ""} +
    + +
    +
    + + ${showTabs && this.narrow + ? html`
    ${tabs}
    ` : ""} - ${showTabs - ? html` -
    - ${tabs} -
    - ` - : ""} -
    - -
    -
    - +
    + ${this.pane + ? html`
    +
    +
    + +
    +
    ` + : nothing} +
    + +
    @@ -206,6 +222,15 @@ class HassTabsSubpage extends LitElement { position: fixed; } + .container { + display: flex; + height: calc(100% - var(--header-height)); + } + + :host([narrow]) .container { + height: 100%; + } + ha-menu-button { margin-right: 24px; margin-inline-end: 24px; @@ -213,18 +238,22 @@ class HassTabsSubpage extends LitElement { } .toolbar { - display: flex; - align-items: center; font-size: 20px; height: var(--header-height); background-color: var(--sidebar-background-color); font-weight: 400; border-bottom: 1px solid var(--divider-color); + box-sizing: border-box; + } + .toolbar-content { padding: 8px 12px; + display: flex; + align-items: center; + height: 100%; box-sizing: border-box; } @media (max-width: 599px) { - .toolbar { + .toolbar-content { padding: 4px; } } @@ -297,10 +326,6 @@ class HassTabsSubpage extends LitElement { margin-right: env(safe-area-inset-right); margin-inline-start: env(safe-area-inset-left); margin-inline-end: env(safe-area-inset-right); - height: calc(100% - 1px - var(--header-height)); - height: calc( - 100% - 1px - var(--header-height) - env(safe-area-inset-bottom) - ); overflow: auto; -webkit-overflow-scrolling: touch; } @@ -329,6 +354,21 @@ class HassTabsSubpage extends LitElement { inset-inline-end: 24px; inset-inline-start: initial; } + + .pane { + border-right: 1px solid var(--divider-color); + border-inline-end: 1px solid var(--divider-color); + border-inline-start: initial; + box-sizing: border-box; + display: flex; + flex: 0 0 var(--sidepane-width, 250px); + width: var(--sidepane-width, 250px); + flex-direction: column; + position: relative; + } + .pane .ha-scrollbar { + flex: 1; + } `, ]; } diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts index 10249c8592..3222c1b1e2 100644 --- a/src/panels/config/automation/add-automation-element-dialog.ts +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -1,4 +1,3 @@ -import "@material/mwc-list/mwc-list"; import "@material/web/divider/divider"; import { mdiClose, mdiContentPaste, mdiPlus } from "@mdi/js"; import Fuse, { IFuseOptions } from "fuse.js"; diff --git a/src/panels/config/automation/ha-automation-picker.ts b/src/panels/config/automation/ha-automation-picker.ts index db23980b2d..a5fb93a70d 100644 --- a/src/panels/config/automation/ha-automation-picker.ts +++ b/src/panels/config/automation/ha-automation-picker.ts @@ -1,5 +1,6 @@ +import { consume } from "@lit-labs/context"; +import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; import { - mdiCancel, mdiContentDuplicate, mdiDelete, mdiHelpCircle, @@ -9,34 +10,41 @@ import { mdiPlus, mdiRobotHappy, mdiStopCircleOutline, + mdiTag, mdiTransitConnection, } from "@mdi/js"; -import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; +import { differenceInDays } from "date-fns/esm"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { - css, CSSResultGroup, - html, LitElement, - nothing, TemplateResult, + css, + html, + nothing, } from "lit"; import { customElement, property, state } from "lit/decorators"; -import memoizeOne from "memoize-one"; -import { differenceInDays } from "date-fns/esm"; import { styleMap } from "lit/directives/style-map"; +import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { relativeTime } from "../../../common/datetime/relative_time"; -import { fireEvent, HASSDomEvent } from "../../../common/dom/fire_event"; +import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event"; import { computeStateName } from "../../../common/entity/compute_state_name"; import { navigate } from "../../../common/navigate"; +import { LocalizeFunc } from "../../../common/translations/localize"; +import "../../../components/chips/ha-assist-chip"; import type { DataTableColumnContainer, RowClickedEvent, } from "../../../components/data-table/ha-data-table"; -import "../../../components/ha-button-related-filter-menu"; -import "../../../components/ha-label"; +import "../../../components/entity/ha-entity-toggle"; import "../../../components/ha-fab"; +import "../../../components/ha-filter-floor-areas"; +import "../../../components/ha-filter-blueprints"; +import "../../../components/ha-filter-categories"; +import "../../../components/ha-filter-devices"; +import "../../../components/ha-filter-entities"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-svg-icon"; @@ -49,28 +57,43 @@ import { showAutomationEditor, triggerAutomationActions, } from "../../../data/automation"; +import { + CategoryRegistryEntry, + subscribeCategoryRegistry, +} from "../../../data/category_registry"; +import { fullEntitiesContext } from "../../../data/context"; +import { UNAVAILABLE } from "../../../data/entity"; +import { EntityRegistryEntry } from "../../../data/entity_registry"; +import { findRelated } from "../../../data/search"; import { showAlertDialog, showConfirmationDialog, } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-tabs-subpage-data-table"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; import { HomeAssistant, Route } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; +import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; import { configSections } from "../ha-panel-config"; import { showNewAutomationDialog } from "./show-dialog-new-automation"; -import { findRelated } from "../../../data/search"; -import { fetchBlueprints } from "../../../data/blueprint"; -import { UNAVAILABLE } from "../../../data/entity"; +import "../../../components/data-table/ha-data-table-labels"; +import { + LabelRegistryEntry, + subscribeLabelRegistry, +} from "../../../data/label_registry"; +import "../../../components/ha-filter-labels"; type AutomationItem = AutomationEntity & { name: string; last_triggered?: string | undefined; - disabled: boolean; + formatted_state: string; + category: string | undefined; + labels: LabelRegistryEntry[]; }; @customElement("ha-automation-picker") -class HaAutomationPicker extends LitElement { +class HaAutomationPicker extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean }) public isWide = false; @@ -81,17 +104,33 @@ class HaAutomationPicker extends LitElement { @property({ attribute: false }) public automations!: AutomationEntity[]; - @state() private _activeFilters?: string[]; - @state() private _searchParms = new URLSearchParams(window.location.search); @state() private _filteredAutomations?: string[] | null; - @state() private _filterValue?; + @state() private _filters: Record< + string, + { value: string[] | undefined; items: Set | undefined } + > = {}; + + @state() private _expandedFilter?: string; + + @state() + _categories!: CategoryRegistryEntry[]; + + @state() + _labels!: LabelRegistryEntry[]; + + @state() + @consume({ context: fullEntitiesContext, subscribe: true }) + _entityReg!: EntityRegistryEntry[]; private _automations = memoizeOne( ( automations: AutomationEntity[], + entityReg: EntityRegistryEntry[], + categoryReg?: CategoryRegistryEntry[], + labelReg?: LabelRegistryEntry[], filteredAutomations?: string[] | null ): AutomationItem[] => { if (filteredAutomations === null) { @@ -103,23 +142,38 @@ class HaAutomationPicker extends LitElement { filteredAutomations!.includes(automation.entity_id) ) : automations - ).map((automation) => ({ - ...automation, - name: computeStateName(automation), - last_triggered: automation.attributes.last_triggered || undefined, - disabled: automation.state === "off", - })); + ).map((automation) => { + const entityRegEntry = entityReg.find( + (reg) => reg.entity_id === automation.entity_id + ); + const category = entityRegEntry?.categories.automation; + const labels = labelReg && entityRegEntry?.labels; + return { + ...automation, + name: computeStateName(automation), + last_triggered: automation.attributes.last_triggered || undefined, + formatted_state: this.hass.formatEntityState(automation), + category: category + ? categoryReg?.find((cat) => cat.category_id === category)?.name + : undefined, + labels: (labels || []).map( + (lbl) => labelReg!.find((label) => label.label_id === lbl)! + ), + }; + }); } ); private _columns = memoizeOne( - (narrow: boolean, _locale): DataTableColumnContainer => { + ( + narrow: boolean, + localize: LocalizeFunc, + locale: HomeAssistant["locale"] + ): DataTableColumnContainer => { const columns: DataTableColumnContainer = { icon: { title: "", - label: this.hass.localize( - "ui.panel.config.automation.picker.headers.state" - ), + label: localize("ui.panel.config.automation.picker.headers.state"), type: "icon", template: (automation) => html``, }, name: { - title: this.hass.localize( - "ui.panel.config.automation.picker.headers.name" - ), + title: localize("ui.panel.config.automation.picker.headers.name"), main: true, sortable: true, filterable: true, direction: "asc", grows: true, - template: narrow - ? (automation) => { - const date = new Date(automation.attributes.last_triggered); - const now = new Date(); - const dayDifference = differenceInDays(now, date); - return html` - ${automation.name} -
    - ${this.hass.localize("ui.card.automation.last_triggered")}: - ${automation.attributes.last_triggered - ? dayDifference > 3 - ? formatShortDateTime( - date, - this.hass.locale, - this.hass.config - ) - : relativeTime(date, this.hass.locale) - : this.hass.localize("ui.components.relative_time.never")} -
    - `; - } - : undefined, - }, - }; - if (!narrow) { - columns.last_triggered = { - sortable: true, - width: "20%", - title: this.hass.localize("ui.card.automation.last_triggered"), template: (automation) => { - if (!automation.last_triggered) { - return this.hass.localize("ui.components.relative_time.never"); - } - const date = new Date(automation.last_triggered); + const date = new Date(automation.attributes.last_triggered); const now = new Date(); const dayDifference = differenceInDays(now, date); return html` - ${dayDifference > 3 - ? formatShortDateTime(date, this.hass.locale, this.hass.config) - : relativeTime(date, this.hass.locale)} +
    ${automation.name}
    + ${narrow + ? html`
    + ${this.hass.localize("ui.card.automation.last_triggered")}: + ${automation.attributes.last_triggered + ? dayDifference > 3 + ? formatShortDateTime(date, locale, this.hass.config) + : relativeTime(date, locale) + : localize("ui.components.relative_time.never")} +
    ` + : nothing} + ${automation.labels.length + ? html`` + : nothing} `; }, + }, + category: { + title: localize("ui.panel.config.automation.picker.headers.category"), + hidden: true, + groupable: true, + filterable: true, + sortable: true, + }, + labels: { + title: "", + hidden: true, + filterable: true, + template: (automation) => + automation.labels.map((lbl) => lbl.name).join(" "), + }, + }; + columns.last_triggered = { + sortable: true, + width: "130px", + title: localize("ui.card.automation.last_triggered"), + hidden: narrow, + template: (automation) => { + if (!automation.last_triggered) { + return this.hass.localize("ui.components.relative_time.never"); + } + const date = new Date(automation.last_triggered); + const now = new Date(); + const dayDifference = differenceInDays(now, date); + return html` + ${dayDifference > 3 + ? formatShortDateTime(date, locale, this.hass.config) + : relativeTime(date, locale)} + `; + }, + }; + + if (!this.narrow) { + columns.formatted_state = { + width: "82px", + sortable: true, + groupable: true, + title: "", + label: this.hass.localize("ui.panel.config.automation.picker.state"), + template: (automation) => html` + + `, }; } - columns.disabled = this.narrow - ? { - title: "", - template: (automation) => - automation.disabled - ? html` - - ${this.hass.localize( - "ui.panel.config.automation.picker.disabled" - )} - - - ` - : "", - } - : { - width: "20%", - title: "", - template: (automation) => - automation.disabled - ? html` - - ${this.hass.localize( - "ui.panel.config.automation.picker.disabled" - )} - - ` - : "", - }; - columns.actions = { title: "", - width: this.narrow ? undefined : "10%", + width: "64px", type: "overflow-menu", template: (automation) => html` this._showInfo(automation), }, + { + path: mdiTag, + label: this.hass.localize( + `ui.panel.config.automation.picker.${automation.category ? "edit_category" : "assign_category"}` + ), + action: () => this._editCategory(automation), + }, { path: mdiPlay, label: this.hass.localize( @@ -292,6 +349,21 @@ class HaAutomationPicker extends LitElement { } ); + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeCategoryRegistry( + this.hass.connection, + "automation", + (categories) => { + this._categories = categories; + } + ), + subscribeLabelRegistry(this.hass.connection, (labels) => { + this._labels = labels; + }), + ]; + } + protected render(): TemplateResult { return html` filter.value?.length + ).length} + .columns=${this._columns( + this.narrow, + this.hass.localize, + this.hass.locale + )} + initialGroupColumn="category" + .data=${this._automations( + this.automations, + this._entityReg, + this._categories, + this._labels, + this._filteredAutomations + )} .empty=${!this.automations.length} @row-click=${this._handleRowClicked} .noDataText=${this.hass.localize( @@ -312,6 +398,7 @@ class HaAutomationPicker extends LitElement { @clear-filter=${this._clearFilter} hasFab clickable + class=${this.narrow ? "narrow" : ""} > - - + .type=${"automation"} + .value=${this._filters["ha-filter-floor-areas"]?.value} + @data-table-filter-changed=${this._filterChanged} + slot="filter-pane" + .expanded=${this._expandedFilter === "ha-filter-floor-areas"} + .narrow=${this.narrow} + @expanded-changed=${this._filterExpanded} + > + + + + + ${!this.automations.length ? html`
    @@ -378,44 +515,114 @@ class HaAutomationPicker extends LitElement { } } + private _filterExpanded(ev) { + if (ev.detail.expanded) { + this._expandedFilter = ev.target.localName; + } else if (this._expandedFilter === ev.target.localName) { + this._expandedFilter = undefined; + } + } + + private _labelClicked = (ev: CustomEvent) => { + const label = ev.detail.label; + this._filters = { + ...this._filters, + "ha-filter-labels": { + value: [label.label_id], + items: undefined, + }, + }; + this._applyFilters(); + }; + + private _filterChanged(ev) { + const type = ev.target.localName; + this._filters[type] = ev.detail; + this._applyFilters(); + } + + private _applyFilters() { + const filters = Object.entries(this._filters); + let items: Set | undefined; + for (const [key, filter] of filters) { + if (filter.items) { + if (!items) { + items = filter.items; + continue; + } + items = + "intersection" in items + ? // @ts-ignore + items.intersection(filter.items) + : new Set([...items].filter((x) => filter.items!.has(x))); + } + if (key === "ha-filter-categories" && filter.value?.length) { + const categoryItems: Set = new Set(); + this.automations + .filter( + (automation) => + filter.value![0] === + this._entityReg.find( + (reg) => reg.entity_id === automation.entity_id + )?.categories.automation + ) + .forEach((automation) => categoryItems.add(automation.entity_id)); + if (!items) { + items = categoryItems; + continue; + } + items = + "intersection" in items + ? // @ts-ignore + items.intersection(categoryItems) + : new Set([...items].filter((x) => categoryItems!.has(x))); + } + if (key === "ha-filter-labels" && filter.value?.length) { + const labelItems: Set = new Set(); + this.automations + .filter((automation) => + this._entityReg + .find((reg) => reg.entity_id === automation.entity_id) + ?.labels.some((lbl) => filter.value!.includes(lbl)) + ) + .forEach((automation) => labelItems.add(automation.entity_id)); + if (!items) { + items = labelItems; + continue; + } + items = + "intersection" in items + ? // @ts-ignore + items.intersection(labelItems) + : new Set([...items].filter((x) => labelItems!.has(x))); + } + } + this._filteredAutomations = items ? [...items] : undefined; + } + private async _filterBlueprint() { const blueprint = this._searchParms.get("blueprint"); if (!blueprint) { return; } - const [related, blueprints] = await Promise.all([ - findRelated(this.hass, "automation_blueprint", blueprint), - fetchBlueprints(this.hass, "automation"), - ]); - this._filteredAutomations = related.automation || []; - const blueprintMeta = blueprints[blueprint]; - this._activeFilters = [ - this.hass.localize( - "ui.panel.config.automation.picker.filtered_by_blueprint", - { - name: - !blueprintMeta || "error" in blueprintMeta - ? blueprint - : blueprintMeta.metadata.name || blueprint, - } - ), - ]; - } - - private _relatedFilterChanged(ev: CustomEvent) { - this._filterValue = ev.detail.value; - if (!this._filterValue) { - this._clearFilter(); - return; - } - this._activeFilters = [ev.detail.filter]; - this._filteredAutomations = ev.detail.items.automation || null; + const related = await findRelated( + this.hass, + "automation_blueprint", + blueprint + ); + this._filters = { + ...this._filters, + "ha-filter-blueprints": { + value: [blueprint], + items: new Set(related.automation || []), + }, + }; + this._applyFilters(); } private _clearFilter() { - this._filteredAutomations = undefined; - this._activeFilters = undefined; - this._filterValue = undefined; + this._filters = {}; + this._applyFilters(); } private _showInfo(automation: any) { @@ -426,6 +633,27 @@ class HaAutomationPicker extends LitElement { triggerAutomationActions(this.hass, automation.entity_id); } + private _editCategory(automation: any) { + const entityReg = this._entityReg.find( + (reg) => reg.entity_id === automation.entity_id + ); + if (!entityReg) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.automation.picker.no_category_support" + ), + text: this.hass.localize( + "ui.panel.config.automation.picker.no_category_entity_reg" + ), + }); + return; + } + showAssignCategoryDialog(this, { + scope: "automation", + entityReg, + }); + } + private _showTrace(automation: any) { if (!automation.attributes.id) { showAlertDialog(this, { @@ -552,6 +780,12 @@ class HaAutomationPicker extends LitElement { return [ haStyle, css` + hass-tabs-subpage-data-table { + --data-table-row-height: 60px; + } + hass-tabs-subpage-data-table.narrow { + --data-table-row-height: 72px; + } .empty { --paper-font-headline_-_font-size: 28px; --mdc-icon-size: 80px; diff --git a/src/panels/config/blueprint/ha-blueprint-overview.ts b/src/panels/config/blueprint/ha-blueprint-overview.ts index 3a290835d3..efaa3e9fdd 100644 --- a/src/panels/config/blueprint/ha-blueprint-overview.ts +++ b/src/panels/config/blueprint/ha-blueprint-overview.ts @@ -261,7 +261,7 @@ class HaBlueprintOverview extends LitElement { hasFab clickable @row-click=${this._handleRowClicked} - .appendRow=${html`
    +
    + ${this._error + ? html`${this._error}` + : ""} +
    + +
    +
    + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.save")} + + + `; + } + + private _categoryChanged(ev: CustomEvent): void { + if (!ev.detail.value) { + return; + } + this._category = ev.detail.value; + } + + private async _updateEntry() { + this._submitting = true; + this._error = undefined; + try { + await updateEntityRegistryEntry( + this.hass, + this._params!.entityReg.entity_id, + { + categories: { [this._scope!]: this._category || null }, + } + ); + this.closeDialog(); + } catch (err: any) { + this._error = + err.message || + this.hass.localize("ui.panel.config.category.assign.unknown_error"); + } finally { + this._submitting = false; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-textfield, + ha-icon-picker { + display: block; + margin-bottom: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-assign-category": DialogAssignCategory; + } +} diff --git a/src/panels/config/category/dialog-category-registry-detail.ts b/src/panels/config/category/dialog-category-registry-detail.ts new file mode 100644 index 0000000000..508e5b9b42 --- /dev/null +++ b/src/panels/config/category/dialog-category-registry-detail.ts @@ -0,0 +1,175 @@ +import "@material/mwc-button"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-alert"; +import { createCloseHeading } from "../../../components/ha-dialog"; +import "../../../components/ha-icon-picker"; +import "../../../components/ha-settings-row"; +import "../../../components/ha-textfield"; +import { + CategoryRegistryEntryMutableParams, + createCategoryRegistryEntry, + updateCategoryRegistryEntry, +} from "../../../data/category_registry"; +import { haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { CategoryRegistryDetailDialogParams } from "./show-dialog-category-registry-detail"; + +class DialogCategoryDetail extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _name!: string; + + @state() private _icon!: string | null; + + @state() private _error?: string; + + @state() private _params?: CategoryRegistryDetailDialogParams; + + @state() private _submitting?: boolean; + + public async showDialog( + params: CategoryRegistryDetailDialogParams + ): Promise { + this._params = params; + this._error = undefined; + this._name = this._params.entry ? this._params.entry.name : ""; + this._icon = this._params.entry?.icon || null; + await this.updateComplete; + } + + public closeDialog(): void { + this._error = ""; + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params) { + return nothing; + } + const entry = this._params.entry; + const nameInvalid = !this._isNameValid(); + return html` + +
    + ${this._error + ? html`${this._error}` + : ""} +
    + + + +
    +
    + + ${this.hass.localize("ui.common.cancel")} + + + ${entry + ? this.hass.localize("ui.common.save") + : this.hass.localize("ui.common.add")} + +
    + `; + } + + private _isNameValid() { + return this._name.trim() !== ""; + } + + private _nameChanged(ev) { + this._error = undefined; + this._name = ev.target.value; + } + + private _iconChanged(ev) { + this._error = undefined; + this._icon = ev.detail.value; + } + + private async _updateEntry() { + const create = !this._params!.entry; + this._submitting = true; + try { + const values: CategoryRegistryEntryMutableParams = { + name: this._name.trim(), + icon: this._icon || (create ? undefined : null), + }; + if (create) { + await createCategoryRegistryEntry( + this.hass, + this._params!.scope, + values + ); + } else { + await updateCategoryRegistryEntry( + this.hass, + this._params!.scope, + this._params!.entry!.category_id, + values + ); + } + this.closeDialog(); + } catch (err: any) { + this._error = + err.message || + this.hass.localize("ui.panel.config.category.editor.unknown_error"); + } finally { + this._submitting = false; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-textfield, + ha-icon-picker { + display: block; + margin-bottom: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-category-registry-detail": DialogCategoryDetail; + } +} + +customElements.define("dialog-category-registry-detail", DialogCategoryDetail); diff --git a/src/panels/config/category/ha-category-picker.ts b/src/panels/config/category/ha-category-picker.ts new file mode 100644 index 0000000000..f07fdfcc5b --- /dev/null +++ b/src/panels/config/category/ha-category-picker.ts @@ -0,0 +1,281 @@ +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { html, LitElement, nothing, PropertyValues } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { + fuzzyFilterSort, + ScorableTextItem, +} from "../../../common/string/filter/sequence-matching"; +import "../../../components/ha-combo-box"; +import type { HaComboBox } from "../../../components/ha-combo-box"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-list-item"; +import "../../../components/ha-svg-icon"; +import { + CategoryRegistryEntry, + createCategoryRegistryEntry, + subscribeCategoryRegistry, +} from "../../../data/category_registry"; +import { + showAlertDialog, + showPromptDialog, +} from "../../../dialogs/generic/show-dialog-box"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; +import { HomeAssistant, ValueChangedEvent } from "../../../types"; + +type ScorableCategoryRegistryEntry = ScorableTextItem & CategoryRegistryEntry; + +const rowRenderer: ComboBoxLitRenderer = (item) => + html` + ${item.icon + ? html`` + : nothing} + ${item.name} + `; + +@customElement("ha-category-picker") +export class HaCategoryPicker extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public scope?: string; + + @property() public label?: string; + + @property() public value?: string; + + @property() public helper?: string; + + @property() public placeholder?: string; + + @property({ type: Boolean, attribute: "no-add" }) + public noAdd = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @state() private _opened?: boolean; + + @state() private _categories?: CategoryRegistryEntry[]; + + @query("ha-combo-box", true) public comboBox!: HaComboBox; + + protected hassSubscribeRequiredHostProps = ["scope"]; + + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeCategoryRegistry( + this.hass.connection, + this.scope!, + (categories) => { + this._categories = categories; + } + ), + ]; + } + + private _suggestion?: string; + + private _init = false; + + public async open() { + await this.updateComplete; + await this.comboBox?.open(); + } + + public async focus() { + await this.updateComplete; + await this.comboBox?.focus(); + } + + private _getCategories = memoizeOne( + ( + categories: CategoryRegistryEntry[] | undefined, + noAdd: this["noAdd"] + ): CategoryRegistryEntry[] => { + const result = categories ? [...categories] : []; + if (!result?.length) { + result.push({ + category_id: "no_categories", + name: this.hass.localize( + "ui.components.category-picker.no_categories" + ), + icon: null, + }); + } + + return noAdd + ? result + : [ + ...result, + { + category_id: "add_new", + name: this.hass.localize("ui.components.category-picker.add_new"), + icon: "mdi:plus", + }, + ]; + } + ); + + protected updated(changedProps: PropertyValues) { + if ( + (!this._init && this.hass && this._categories) || + (this._init && changedProps.has("_opened") && this._opened) + ) { + this._init = true; + const categories = this._getCategories(this._categories, this.noAdd); + this.comboBox.items = categories; + this.comboBox.filteredItems = categories; + } + } + + protected render() { + if (!this._categories) { + return nothing; + } + return html` + + + `; + } + + private _filterChanged(ev: CustomEvent): void { + const target = ev.target as HaComboBox; + const filterString = ev.detail.value; + if (!filterString) { + this.comboBox.filteredItems = this.comboBox.items; + return; + } + + const filteredItems = fuzzyFilterSort( + filterString, + target.items || [] + ); + if (!this.noAdd && filteredItems?.length === 0) { + this._suggestion = filterString; + this.comboBox.filteredItems = [ + { + category_id: "add_new_suggestion", + name: this.hass.localize( + "ui.components.category-picker.add_new_sugestion", + { name: this._suggestion } + ), + picture: null, + }, + ]; + } else { + this.comboBox.filteredItems = filteredItems; + } + } + + private get _value() { + return this.value || ""; + } + + private _openedChanged(ev: ValueChangedEvent) { + this._opened = ev.detail.value; + } + + private _categoryChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + let newValue = ev.detail.value; + + if (newValue === "no_categories") { + newValue = ""; + } + + if (!["add_new_suggestion", "add_new"].includes(newValue)) { + if (newValue !== this._value) { + this._setValue(newValue); + } + return; + } + + (ev.target as any).value = this._value; + showPromptDialog(this, { + title: this.hass.localize( + "ui.components.category-picker.add_dialog.title" + ), + text: this.hass.localize("ui.components.category-picker.add_dialog.text"), + confirmText: this.hass.localize( + "ui.components.category-picker.add_dialog.add" + ), + inputLabel: this.hass.localize( + "ui.components.category-picker.add_dialog.name" + ), + defaultValue: + newValue === "add_new_suggestion" ? this._suggestion : undefined, + confirm: async (name) => { + if (!name) { + return; + } + try { + const category = await createCategoryRegistryEntry( + this.hass, + this.scope!, + { + name, + } + ); + this._categories = [...this._categories!, category]; + this.comboBox.filteredItems = this._getCategories( + this._categories, + this.noAdd + ); + await this.updateComplete; + await this.comboBox.updateComplete; + this._setValue(category.category_id); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.components.category-picker.add_dialog.failed_create_category" + ), + text: err.message, + }); + } + }, + cancel: () => { + this._setValue(undefined); + this._suggestion = undefined; + this.comboBox.setInputValue(""); + }, + }); + } + + private _setValue(value?: string) { + this.value = value; + setTimeout(() => { + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); + }, 0); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-category-picker": HaCategoryPicker; + } +} diff --git a/src/panels/config/category/show-dialog-assign-category.ts b/src/panels/config/category/show-dialog-assign-category.ts new file mode 100644 index 0000000000..3d4a8494b9 --- /dev/null +++ b/src/panels/config/category/show-dialog-assign-category.ts @@ -0,0 +1,21 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { EntityRegistryEntry } from "../../../data/entity_registry"; + +export interface AssignCategoryDialogParams { + entityReg: EntityRegistryEntry; + scope: string; +} + +export const loadAssignCategoryDialog = () => + import("./dialog-assign-category"); + +export const showAssignCategoryDialog = ( + element: HTMLElement, + dialogParams: AssignCategoryDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-assign-category", + dialogImport: loadAssignCategoryDialog, + dialogParams, + }); +}; diff --git a/src/panels/config/category/show-dialog-category-registry-detail.ts b/src/panels/config/category/show-dialog-category-registry-detail.ts new file mode 100644 index 0000000000..00ac4686e8 --- /dev/null +++ b/src/panels/config/category/show-dialog-category-registry-detail.ts @@ -0,0 +1,21 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { CategoryRegistryEntry } from "../../../data/category_registry"; + +export interface CategoryRegistryDetailDialogParams { + entry?: CategoryRegistryEntry; + scope: string; +} + +export const loadCategoryRegistryDetailDialog = () => + import("./dialog-category-registry-detail"); + +export const showCategoryRegistryDetailDialog = ( + element: HTMLElement, + dialogParams: CategoryRegistryDetailDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-category-registry-detail", + dialogImport: loadCategoryRegistryDetailDialog, + dialogParams, + }); +}; diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index 726fd97075..a96db58f20 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -737,6 +737,7 @@ export class HaConfigEntities extends LitElement { has_entity_name: false, options: null, labels: [], + categories: {}, }); } if (changed) { diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts index 8a0607770a..6be03d6771 100644 --- a/src/panels/config/helpers/ha-config-helpers.ts +++ b/src/panels/config/helpers/ha-config-helpers.ts @@ -166,6 +166,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { sortable: true, width: "25%", filterable: true, + groupable: true, }; columns.editable = { title: "", diff --git a/src/panels/config/integrations/ha-config-integrations-dashboard.ts b/src/panels/config/integrations/ha-config-integrations-dashboard.ts index 55c5d1214e..d3dad024f2 100644 --- a/src/panels/config/integrations/ha-config-integrations-dashboard.ts +++ b/src/panels/config/integrations/ha-config-integrations-dashboard.ts @@ -70,6 +70,7 @@ import "./ha-integration-overflow-menu"; import { showAddIntegrationDialog } from "./show-add-integration-dialog"; import "./ha-disabled-config-entry-card"; import { caseInsensitiveStringCompare } from "../../../common/string/compare"; +import "../../../components/search-input-outlined"; export interface ConfigEntryExtended extends ConfigEntry { localized_domain_name?: string; @@ -327,15 +328,16 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { ${this.narrow ? html`
    - + > +
    ${filterMenu} ` @@ -345,36 +347,36 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { slot="toolbar-icon" > `} ${this._showIgnored @@ -810,36 +812,23 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { .empty-message h1 { margin: 0; } - search-input { - --mdc-text-field-fill-color: var(--sidebar-background-color); - --mdc-text-field-idle-line-color: var(--divider-color); - --text-field-overflow: visible; - } - search-input.header { - display: block; - color: var(--secondary-text-color); - margin-left: 8px; - margin-inline-start: 8px; - margin-inline-end: initial; - direction: var(--direction); - --mdc-ripple-color: transparant; + search-input-outlined { + flex: 1; } .search { display: flex; - justify-content: flex-end; + justify-content: space-between; width: 100%; align-items: center; height: 56px; position: sticky; top: 0; z-index: 2; - } - .search search-input { - display: block; - position: absolute; - top: 0; - right: 0; - left: 0; + background-color: var(--primary-background-color); + padding: 0 16px; + gap: 16px; + box-sizing: border-box; + border-bottom: 1px solid var(--divider-color); } .filters { --mdc-text-field-fill-color: var(--input-fill-color); @@ -848,6 +837,7 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { --text-field-overflow: initial; display: flex; justify-content: flex-end; + align-items: center; color: var(--primary-text-color); } .active-filters { @@ -865,6 +855,7 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { width: max-content; cursor: initial; direction: var(--direction); + height: 32px; } .active-filters mwc-button { margin-left: 8px; diff --git a/src/panels/config/scene/ha-scene-dashboard.ts b/src/panels/config/scene/ha-scene-dashboard.ts index f620c2235e..76205ba46a 100644 --- a/src/panels/config/scene/ha-scene-dashboard.ts +++ b/src/panels/config/scene/ha-scene-dashboard.ts @@ -27,7 +27,6 @@ import { DataTableColumnContainer, RowClickedEvent, } from "../../../components/data-table/ha-data-table"; -import "../../../components/ha-button-related-filter-menu"; import "../../../components/ha-fab"; import "../../../components/ha-button"; import "../../../components/ha-icon-button"; @@ -76,8 +75,6 @@ class HaSceneDashboard extends LitElement { @state() private _filteredScenes?: string[] | null; - @state() private _filterValue?; - private _scenes = memoizeOne( (scenes: SceneEntity[], filteredScenes?: string[] | null): SceneItem[] => { if (filteredScenes === null) { @@ -242,15 +239,6 @@ class HaSceneDashboard extends LitElement { .label=${this.hass.localize("ui.common.help")} .path=${mdiHelpCircle} > - - ${!this.scenes.length ? html`
    @@ -295,20 +283,9 @@ class HaSceneDashboard extends LitElement { } } - private _relatedFilterChanged(ev: CustomEvent) { - this._filterValue = ev.detail.value; - if (!this._filterValue) { - this._clearFilter(); - return; - } - this._activeFilters = [ev.detail.filter]; - this._filteredScenes = ev.detail.items.scene || null; - } - private _clearFilter() { this._filteredScenes = undefined; this._activeFilters = undefined; - this._filterValue = undefined; } private _showInfo(scene: SceneEntity) { diff --git a/src/panels/config/script/ha-script-picker.ts b/src/panels/config/script/ha-script-picker.ts index aca24a71ac..0e4c1d1ec7 100644 --- a/src/panels/config/script/ha-script-picker.ts +++ b/src/panels/config/script/ha-script-picker.ts @@ -30,7 +30,6 @@ import { DataTableColumnContainer, RowClickedEvent, } from "../../../components/data-table/ha-data-table"; -import "../../../components/ha-button-related-filter-menu"; import "../../../components/ha-fab"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-overflow-menu"; @@ -83,8 +82,6 @@ class HaScriptPicker extends LitElement { @state() private _filteredScripts?: string[] | null; - @state() private _filterValue?; - private _scripts = memoizeOne( ( scripts: ScriptEntity[], @@ -266,15 +263,6 @@ class HaScriptPicker extends LitElement { .path=${mdiHelpCircle} @click=${this._showHelp} > - - ${!this.scripts.length ? html`
    @@ -345,20 +333,9 @@ class HaScriptPicker extends LitElement { ]; } - private _relatedFilterChanged(ev: CustomEvent) { - this._filterValue = ev.detail.value; - if (!this._filterValue) { - this._clearFilter(); - return; - } - this._activeFilters = [ev.detail.filter]; - this._filteredScripts = ev.detail.items.script || null; - } - private _clearFilter() { this._filteredScripts = undefined; this._activeFilters = undefined; - this._filterValue = undefined; } private _handleRowClicked(ev: HASSDomEvent) { diff --git a/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts b/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts index 46f4f6c372..5283b10960 100644 --- a/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts +++ b/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts @@ -542,6 +542,7 @@ export class VoiceAssistantsExpose extends LitElement { )} .filter=${this._filter} selectable + .selected=${this._selectedEntities.length} clickable @selection-changed=${this._handleSelectionChanged} @clear-filter=${this._clearFilter} @@ -559,12 +560,6 @@ export class VoiceAssistantsExpose extends LitElement { })} slot="header" > -

    - ${this.hass.localize( - "ui.panel.config.entities.picker.selected", - { number: this._selectedEntities.length } - )} -

    ${!this.narrow ? html` diff --git a/src/translations/en.json b/src/translations/en.json index 4827539bcc..4a2a7b45fd 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -499,6 +499,14 @@ "add_entity_id": "Choose entity", "add_label_id": "Choose label" }, + "subpage-data-table": { + "filters": "Filters", + "sort_by": "Sort by {sortColumn}", + "group_by": "Group by {groupColumn}", + "dont_group_by": "Don't group", + "select": "Select", + "selected": "Selected {selected}" + }, "config-entry-picker": { "config_entry": "Integration" }, @@ -547,6 +555,23 @@ "device": "Device", "no_area": "No area" }, + "category-picker": { + "clear": "Clear", + "show_categories": "Show categories", + "categories": "Categories", + "category": "Category", + "add_category": "Add category", + "add_new_sugestion": "Add new category ''{name}''", + "add_new": "Add new category…", + "no_categories": "You don't have any categories", + "add_dialog": { + "title": "Add new category", + "text": "Enter the name of the new category.", + "name": "Name", + "add": "Add", + "failed_create_category": "Failed to create category." + } + }, "label-picker": { "clear": "Clear", "show_labels": "Show labels", @@ -555,14 +580,7 @@ "add_new_sugestion": "Add new label ''{name}''", "add_new": "Add new label…", "no_labels": "You don't have any labels", - "no_match": "No matching labels found", - "add_dialog": { - "title": "Add new label", - "text": "Enter the name of the new label.", - "name": "Name", - "add": "Add", - "failed_create_label": "Failed to create label." - } + "no_match": "No matching labels found" }, "area-picker": { "clear": "Clear", @@ -1924,6 +1942,29 @@ "aliases_description": "Aliases are alternative names used in voice assistants to refer to this floor." } }, + "category": { + "caption": "Categories", + "assign": { + "edit": "Edit category", + "assign": "Assign category", + "unknown_error": "An unknown error happened when assigning the category" + }, + "editor": { + "edit": "Edit category", + "delete": "Delete category", + "add": "Add category", + "create": "Create category", + "name": "Name", + "icon": "Icon", + "required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]", + "unknown_error": "An unknown error happened when saving the category", + "confirm_delete": "Are you sure you want to delete this category?", + "confirm_delete_text": "This will delete the category and unassign everything that is currently assigned to it." + }, + "filter": { + "show_all": "Show all" + } + }, "labels": { "caption": "Labels", "description": "Group devices and entities", @@ -2632,14 +2673,20 @@ "delete_confirm_text": "{name} will be permanently deleted.", "duplicate": "[%key:ui::common::duplicate%]", "disabled": "Disabled", + "state": "State", "filtered_by_blueprint": "blueprint: {name}", "traces_not_available": "[%key:ui::panel::config::automation::editor::traces_not_available%]", + "edit_category": "Edit category", + "assign_category": "Assign category", + "no_category_support": "You can't assign an category to this automation", + "no_category_entity_reg": "To assign an category to an automation it needs to have a unique ID.", "headers": { "toggle": "Enable/disable", "name": "Name", "trigger": "Trigger", "actions": "Actions", - "state": "State" + "state": "State", + "category": "Category" }, "empty_header": "Start automating", "empty_text_1": "Automations make Home Assistant automatically respond to things happening in and around your home.", @@ -3965,6 +4012,7 @@ }, "status": { "restored": "Restored", + "available": "Available", "unavailable": "Unavailable", "disabled": "Disabled", "readonly": "Read-only",