diff --git a/pyproject.toml b/pyproject.toml index c78a2c67f1..7aa35c5c66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "home-assistant-frontend" -version = "20240402.2" +version = "20240403.0" license = {text = "Apache-2.0"} description = "The Home Assistant frontend" readme = "README.md" diff --git a/src/components/chips/ha-assist-chip.ts b/src/components/chips/ha-assist-chip.ts index 5a7b0d1fd5..a284818fd4 100644 --- a/src/components/chips/ha-assist-chip.ts +++ b/src/components/chips/ha-assist-chip.ts @@ -45,8 +45,8 @@ export class HaAssistChip extends MdAssistChip { margin-inline-start: var(--_icon-label-space); } ::before { - background: var(--ha-assist-chip-container-color); - opacity: var(--ha-assist-chip-container-opacity); + background: var(--ha-assist-chip-container-color, transparent); + opacity: var(--ha-assist-chip-container-opacity, 1); } :where(.active)::before { background: var(--ha-assist-chip-active-container-color); diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index e0f7c8894e..2c95735e82 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -33,6 +33,7 @@ import "../ha-svg-icon"; import "../search-input"; import { filterData, sortData } from "./sort-filter"; import { groupBy } from "../../common/util/group-by"; +import { stringCompare } from "../../common/string/compare"; declare global { // for fire event @@ -529,7 +530,13 @@ export class HaDataTable extends LitElement { const sorted: { [key: string]: DataTableRowData[]; } = Object.keys(grouped) - .sort() + .sort((a, b) => + stringCompare( + ["", "-", "—"].includes(a) ? "zzz" : a, + ["", "-", "—"].includes(b) ? "zzz" : b, + this.hass.locale.language + ) + ) .reduce((obj, key) => { obj[key] = grouped[key]; return obj; diff --git a/src/components/ha-area-floor-picker.ts b/src/components/ha-area-floor-picker.ts index 711b550561..12a032bdec 100644 --- a/src/components/ha-area-floor-picker.ts +++ b/src/components/ha-area-floor-picker.ts @@ -1,8 +1,9 @@ import { mdiTextureBox } from "@mdi/js"; import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; -import { LitElement, PropertyValues, TemplateResult, html } from "lit"; +import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { computeDomain } from "../common/entity/compute_domain"; @@ -11,6 +12,7 @@ import { ScorableTextItem, fuzzyFilterSort, } from "../common/string/filter/sequence-matching"; +import { computeRTL } from "../common/util/compute_rtl"; import { AreaRegistryEntry } from "../data/area_registry"; import { DeviceEntityDisplayLookup, @@ -32,6 +34,7 @@ import "./ha-floor-icon"; import "./ha-icon-button"; import "./ha-list-item"; import "./ha-svg-icon"; +import "./ha-tree-indicator"; type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry; @@ -41,28 +44,11 @@ interface FloorAreaEntry { icon: string | null; strings: string[]; type: "floor" | "area"; - hasFloor?: boolean; level: number | null; + hasFloor?: boolean; + lastArea?: boolean; } -const rowRenderer: ComboBoxLitRenderer = (item) => - html` - ${item.type === "floor" - ? html`` - : item.icon - ? html`` - : html``} - ${item.name} - `; - @customElement("ha-area-floor-picker") export class HaAreaFloorPicker extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @@ -151,6 +137,44 @@ export class HaAreaFloorPicker extends SubscribeMixin(LitElement) { await this.comboBox?.focus(); } + private _rowRenderer: ComboBoxLitRenderer = (item) => { + const rtl = computeRTL(this.hass); + return html` + + ${item.type === "area" && item.hasFloor + ? html`` + : nothing} + ${item.type === "floor" + ? html`` + : item.icon + ? html`` + : html``} + ${item.name} + + `; + }; + private _getAreas = memoizeOne( ( floors: FloorRegistryEntry[], @@ -364,7 +388,7 @@ export class HaAreaFloorPicker extends SubscribeMixin(LitElement) { }); } output.push( - ...floorAreas.map((area) => ({ + ...floorAreas.map((area, index, array) => ({ id: area.area_id, type: "area" as const, name: area.name, @@ -372,6 +396,7 @@ export class HaAreaFloorPicker extends SubscribeMixin(LitElement) { strings: [area.area_id, ...area.aliases, area.name], hasFloor: true, level: null, + lastArea: index === array.length - 1, })) ); }); @@ -445,7 +470,7 @@ export class HaAreaFloorPicker extends SubscribeMixin(LitElement) { .placeholder=${this.placeholder ? this.hass.areas[this.placeholder]?.name : undefined} - .renderer=${rowRenderer} + .renderer=${this._rowRenderer} @filter-changed=${this._filterChanged} @opened-changed=${this._openedChanged} @value-changed=${this._areaChanged} diff --git a/src/components/ha-filter-floor-areas.ts b/src/components/ha-filter-floor-areas.ts index ba983b7349..8810c9d6d2 100644 --- a/src/components/ha-filter-floor-areas.ts +++ b/src/components/ha-filter-floor-areas.ts @@ -1,17 +1,19 @@ import "@material/mwc-menu/mwc-menu-surface"; import { mdiFilterVariantRemove, mdiTextureBox } from "@mdi/js"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; -import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; import { repeat } from "lit/directives/repeat"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; +import { computeRTL } from "../common/util/compute_rtl"; import { FloorRegistryEntry, getFloorAreaLookup, subscribeFloorRegistry, } from "../data/floor_registry"; -import { findRelated, RelatedResult } from "../data/search"; +import { RelatedResult, findRelated } from "../data/search"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { haStyleScrollbar } from "../resources/styles"; import type { HomeAssistant } from "../types"; @@ -19,6 +21,7 @@ import "./ha-check-list-item"; import "./ha-floor-icon"; import "./ha-icon"; import "./ha-svg-icon"; +import "./ha-tree-indicator"; @customElement("ha-filter-floor-areas") export class HaFilterFloorAreas extends SubscribeMixin(LitElement) { @@ -86,8 +89,10 @@ export class HaFilterFloorAreas extends SubscribeMixin(LitElement) { ${repeat( floor.areas, - (area) => area.area_id, - (area) => this._renderArea(area) + (area, index) => + `${area.area_id}${index === floor.areas.length - 1 ? "___last" : ""}`, + (area, index) => + this._renderArea(area, index === floor.areas.length - 1) )} ` )} @@ -103,23 +108,37 @@ export class HaFilterFloorAreas extends SubscribeMixin(LitElement) { `; } - private _renderArea(area) { - return html` - ${area.icon - ? html`` - : html``} - ${area.name} - `; + private _renderArea(area, last: boolean = false) { + const hasFloor = !!area.floor_id; + return html` + + ${hasFloor + ? html` + + ` + : nothing} + ${area.icon + ? html`` + : html``} + ${area.name} + + `; } private _handleItemClick(ev) { @@ -294,9 +313,26 @@ export class HaFilterFloorAreas extends SubscribeMixin(LitElement) { --mdc-list-item-graphic-margin: 16px; } .floor { - padding-left: 32px; - padding-inline-start: 32px; + padding-left: 48px; + padding-inline-start: 48px; + padding-inline-end: 16px; } + ha-tree-indicator { + width: 56px; + position: absolute; + top: 0px; + left: 0px; + } + .rtl ha-tree-indicator { + right: 0px; + left: initial; + transform: scaleX(-1); + } + .subdir { + margin-inline-end: 8px; + opacity: .6; + } + . `, ]; } diff --git a/src/components/ha-filter-integrations.ts b/src/components/ha-filter-integrations.ts index 2f8b6f3cf0..cab9726f44 100644 --- a/src/components/ha-filter-integrations.ts +++ b/src/components/ha-filter-integrations.ts @@ -13,6 +13,7 @@ import { import { haStyleScrollbar } from "../resources/styles"; import type { HomeAssistant } from "../types"; import "./ha-domain-icon"; +import "./search-input-outlined"; @customElement("ha-filter-integrations") export class HaFilterIntegrations extends LitElement { @@ -28,6 +29,8 @@ export class HaFilterIntegrations extends LitElement { @state() private _shouldRender = false; + @state() private _filter?: string; + protected render() { return html` ${this._manifests && this._shouldRender - ? html` + ? html` + ${repeat( - this._integrations(this._manifests, this.value), + this._integrations(this._manifests, this._filter, this.value), (i) => i.domain, (integration) => html`` )} - - ` + ` : nothing} `; @@ -103,12 +110,17 @@ export class HaFilterIntegrations extends LitElement { } private _integrations = memoizeOne( - (manifest: IntegrationManifest[], _value) => + (manifest: IntegrationManifest[], filter: string | undefined, _value) => manifest .filter( (mnfst) => - !mnfst.integration_type || - !["entity", "system", "hardware"].includes(mnfst.integration_type) + (!mnfst.integration_type || + !["entity", "system", "hardware"].includes( + mnfst.integration_type + )) && + (!filter || + mnfst.name.toLowerCase().includes(filter) || + mnfst.domain.toLowerCase().includes(filter)) ) .sort((a, b) => stringCompare( @@ -122,7 +134,11 @@ export class HaFilterIntegrations extends LitElement { private async _integrationsSelected( ev: CustomEvent>> ) { - const integrations = this._integrations(this._manifests!, this.value); + const integrations = this._integrations( + this._manifests!, + this._filter, + this.value + ); if (!ev.detail.index.size) { fireEvent(this, "data-table-filter-changed", { @@ -156,6 +172,10 @@ export class HaFilterIntegrations extends LitElement { }); } + private _handleSearchChange(ev: CustomEvent) { + this._filter = ev.detail.value.toLowerCase(); + } + static get styles(): CSSResultGroup { return [ haStyleScrollbar, @@ -195,6 +215,10 @@ export class HaFilterIntegrations extends LitElement { padding: 0px 2px; color: var(--text-primary-color); } + search-input-outlined { + display: block; + padding: 0 8px; + } `, ]; } diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts index f8e29dc7ef..c810292677 100644 --- a/src/components/ha-floor-picker.ts +++ b/src/components/ha-floor-picker.ts @@ -10,7 +10,10 @@ import { ScorableTextItem, fuzzyFilterSort, } from "../common/string/filter/sequence-matching"; -import { AreaRegistryEntry } from "../data/area_registry"; +import { + AreaRegistryEntry, + updateAreaRegistryEntry, +} from "../data/area_registry"; import { DeviceEntityDisplayLookup, DeviceRegistryEntry, @@ -441,9 +444,14 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) { showFloorRegistryDetailDialog(this, { suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", - createEntry: async (values) => { + createEntry: async (values, addedAreas) => { try { const floor = await createFloorRegistryEntry(this.hass, values); + addedAreas.forEach((areaId) => { + updateAreaRegistryEntry(this.hass, areaId, { + floor_id: floor.floor_id, + }); + }); const floors = [...this._floors!, floor]; this.comboBox.filteredItems = this._getFloors( floors, diff --git a/src/components/ha-labels-picker.ts b/src/components/ha-labels-picker.ts index 2cc592e26e..4d59f33045 100644 --- a/src/components/ha-labels-picker.ts +++ b/src/components/ha-labels-picker.ts @@ -2,8 +2,10 @@ import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import { LitElement, TemplateResult, css, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; +import memoizeOne from "memoize-one"; import { computeCssColor } from "../common/color/compute-color"; import { fireEvent } from "../common/dom/fire_event"; +import { stringCompare } from "../common/string/compare"; import { LabelRegistryEntry, subscribeLabelRegistry, @@ -17,7 +19,6 @@ import "./chips/ha-input-chip"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import "./ha-label-picker"; import type { HaLabelPicker } from "./ha-label-picker"; -import { stringCompare } from "../common/string/compare"; @customElement("ha-labels-picker") export class HaLabelsPicker extends SubscribeMixin(LitElement) { @@ -102,25 +103,35 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { ]; } + private _sortedLabels = memoizeOne( + ( + value: string[] | undefined, + labels: { [id: string]: LabelRegistryEntry } | undefined, + language: string + ) => + value + ?.map((id) => labels?.[id]) + .sort((a, b) => stringCompare(a?.name || "", b?.name || "", language)) + ); + protected render(): TemplateResult { - const labels = this.value - ?.map((id) => this._labels?.[id]) - .sort((a, b) => - stringCompare(a?.name || "", b?.name || "", this.hass.locale.language) - ); + const labels = this._sortedLabels( + this.value, + this._labels, + this.hass.locale.language + ); return html` ${labels?.length ? html` ${repeat( labels, (label) => label?.label_id, - (label, idx) => { + (label) => { const color = label?.color ? computeCssColor(label.color) : undefined; return html` id !== label.label_id)); } private _openDetail(ev) { - const label = ev.target.item; + const label = ev.currentTarget.item; showLabelDetailDialog(this, { entry: label, updateEntry: async (values) => { diff --git a/src/components/ha-outlined-text-field.ts b/src/components/ha-outlined-text-field.ts index 3118c85049..02ed120d6e 100644 --- a/src/components/ha-outlined-text-field.ts +++ b/src/components/ha-outlined-text-field.ts @@ -27,6 +27,10 @@ export class HaOutlinedTextField extends MdOutlinedTextField { --md-outlined-field-focus-outline-width: 1px; --mdc-icon-size: var(--md-input-chip-icon-size, 18px); } + md-outlined-field { + background: var(--ha-outlined-text-field-container-color, transparent); + opacity: var(--ha-outlined-text-field-container-opacity, 1); + } .input { font-family: Roboto, sans-serif; } diff --git a/src/components/ha-tree-indicator.ts b/src/components/ha-tree-indicator.ts new file mode 100644 index 0000000000..5912d7650c --- /dev/null +++ b/src/components/ha-tree-indicator.ts @@ -0,0 +1,36 @@ +import { LitElement, TemplateResult, css, html } from "lit"; +import { customElement, property } from "lit/decorators"; + +@customElement("ha-tree-indicator") +export class HaTreeIndicator extends LitElement { + @property({ type: Boolean, reflect: true }) + public end?: boolean = false; + + protected render(): TemplateResult { + return html` + + + + + `; + } + + static styles = css` + :host { + display: block; + width: 48px; + height: 48px; + } + line { + stroke: var(--divider-color); + stroke-width: 2; + stroke-dasharray: 2; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-tree-indicator": HaTreeIndicator; + } +} diff --git a/src/components/search-input-outlined.ts b/src/components/search-input-outlined.ts index 5e40df35c9..f0ce00c673 100644 --- a/src/components/search-input-outlined.ts +++ b/src/components/search-input-outlined.ts @@ -97,6 +97,7 @@ class SearchInputOutlined extends LitElement { ha-outlined-text-field { display: block; width: 100%; + --ha-outlined-text-field-container-color: var(--card-background-color); } ha-svg-icon, ha-icon-button { diff --git a/src/data/search.ts b/src/data/search.ts index ec21d03d58..5011f9a4c1 100644 --- a/src/data/search.ts +++ b/src/data/search.ts @@ -28,6 +28,7 @@ export type ItemType = | "entity" | "floor" | "group" + | "label" | "scene" | "script" | "automation_blueprint" diff --git a/src/layouts/hass-tabs-subpage-data-table.ts b/src/layouts/hass-tabs-subpage-data-table.ts index 57036a9ab4..e5cf082d3c 100644 --- a/src/layouts/hass-tabs-subpage-data-table.ts +++ b/src/layouts/hass-tabs-subpage-data-table.ts @@ -321,19 +321,28 @@ export class HaTabsSubpageDataTable extends LitElement { .path=${mdiMenuDown} > - ${localize("ui.components.subpage-data-table.select_all")} + +
+ ${localize("ui.components.subpage-data-table.select_all")} +
- ${localize("ui.components.subpage-data-table.select_none")} + +
+ ${localize( + "ui.components.subpage-data-table.select_none" + )} +
${localize( - "ui.components.subpage-data-table.close_select_mode" - )} + > +
+ ${localize( + "ui.components.subpage-data-table.close_select_mode" + )} +

@@ -349,39 +358,7 @@ export class HaTabsSubpageDataTable extends LitElement { : nothing} ${this.showFilters ? !showPane - ? html` - - - ${localize( - "ui.components.subpage-data-table.filters" - )} - ${this.filters - ? html`` - : nothing} - -

-
` + ? nothing : html`
+ ${this.showFilters && !showPane + ? html` + + + ${localize("ui.components.subpage-data-table.filters")} + ${this.filters + ? html`` + : nothing} + +
+
` + : nothing} `; } @@ -577,6 +587,7 @@ export class HaTabsSubpageDataTable extends LitElement { return css` :host { display: block; + height: 100%; } ha-data-table { @@ -732,7 +743,7 @@ export class HaTabsSubpageDataTable extends LitElement { padding: 8px 12px; box-sizing: border-box; font-size: 14px; - --ha-assist-chip-container-color: var(--primary-background-color); + --ha-assist-chip-container-color: var(--card-background-color); } .selection-controls { @@ -759,6 +770,7 @@ export class HaTabsSubpageDataTable extends LitElement { ha-assist-chip { --ha-assist-chip-container-shape: 10px; + --ha-assist-chip-container-color: var(--card-background-color); } .select-mode-chip { @@ -767,6 +779,7 @@ export class HaTabsSubpageDataTable extends LitElement { } ha-dialog { + --dialog-z-index: 100; --mdc-dialog-min-width: calc( 100vw - env(safe-area-inset-right) - env(safe-area-inset-left) ); diff --git a/src/panels/config/areas/dialog-floor-registry-detail.ts b/src/panels/config/areas/dialog-floor-registry-detail.ts index 12e666873f..d59a324f16 100644 --- a/src/panels/config/areas/dialog-floor-registry-detail.ts +++ b/src/panels/config/areas/dialog-floor-registry-detail.ts @@ -1,8 +1,13 @@ import "@material/mwc-button"; import "@material/mwc-list/mwc-list"; +import { mdiTextureBox } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { property, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import memoizeOne from "memoize-one"; import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/chips/ha-chip-set"; +import "../../../components/chips/ha-input-chip"; import "../../../components/ha-alert"; import "../../../components/ha-aliases-editor"; import { createCloseHeading } from "../../../components/ha-dialog"; @@ -11,10 +16,15 @@ import "../../../components/ha-picture-upload"; import "../../../components/ha-settings-row"; import "../../../components/ha-svg-icon"; import "../../../components/ha-textfield"; -import { FloorRegistryEntryMutableParams } from "../../../data/floor_registry"; -import { haStyleDialog } from "../../../resources/styles"; +import { + FloorRegistryEntry, + FloorRegistryEntryMutableParams, +} from "../../../data/floor_registry"; +import { haStyle, haStyleDialog } from "../../../resources/styles"; import { HomeAssistant } from "../../../types"; import { FloorRegistryDetailDialogParams } from "./show-dialog-floor-registry-detail"; +import { showAreaRegistryDetailDialog } from "./show-dialog-area-registry-detail"; +import { updateAreaRegistryEntry } from "../../../data/area_registry"; class DialogFloorDetail extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -33,9 +43,11 @@ class DialogFloorDetail extends LitElement { @state() private _submitting?: boolean; - public async showDialog( - params: FloorRegistryDetailDialogParams - ): Promise { + @state() private _addedAreas = new Set(); + + @state() private _removedAreas = new Set(); + + public showDialog(params: FloorRegistryDetailDialogParams): void { this._params = params; this._error = undefined; this._name = this._params.entry @@ -44,16 +56,40 @@ class DialogFloorDetail extends LitElement { this._aliases = this._params.entry?.aliases || []; this._icon = this._params.entry?.icon || null; this._level = this._params.entry?.level ?? null; - await this.updateComplete; + this._addedAreas.clear(); + this._removedAreas.clear(); } public closeDialog(): void { this._error = ""; this._params = undefined; + this._addedAreas.clear(); + this._removedAreas.clear(); fireEvent(this, "dialog-closed", { dialog: this.localName }); } + private _floorAreas = memoizeOne( + ( + entry: FloorRegistryEntry | undefined, + areas: HomeAssistant["areas"], + added: Set, + removed: Set + ) => + Object.values(areas).filter( + (area) => + (area.floor_id === entry?.floor_id || added.has(area.area_id)) && + !removed.has(area.area_id) + ) + ); + protected render() { + const areas = this._floorAreas( + this._params?.entry, + this.hass.areas, + this._addedAreas, + this._removedAreas + ); + if (!this._params) { return nothing; } @@ -125,6 +161,52 @@ class DialogFloorDetail extends LitElement { : nothing} +

+ ${this.hass.localize( + "ui.panel.config.floors.editor.areas_section" + )} +

+ +

+ ${this.hass.localize( + "ui.panel.config.floors.editor.areas_description" + )} +

+ ${areas.length + ? html` + ${repeat( + areas, + (area) => area.area_id, + (area) => + html` + ${area.icon + ? html`` + : html``} + ` + )} + ` + : nothing} + a.area_id)} + .label=${this.hass.localize( + "ui.panel.config.floors.editor.add_area" + )} + > +

${this.hass.localize( "ui.panel.config.floors.editor.aliases_section" @@ -159,6 +241,41 @@ class DialogFloorDetail extends LitElement { `; } + private _openArea(ev) { + const area = ev.target.area; + showAreaRegistryDetailDialog(this, { + entry: area, + updateEntry: (values) => + updateAreaRegistryEntry(this.hass!, area.area_id, values), + }); + } + + private _removeArea(ev) { + const areaId = ev.target.area.area_id; + if (this._addedAreas.has(areaId)) { + this._addedAreas.delete(areaId); + this._addedAreas = new Set(this._addedAreas); + return; + } + this._removedAreas.add(areaId); + this._removedAreas = new Set(this._removedAreas); + } + + private _addArea(ev) { + const areaId = ev.detail.value; + if (!areaId) { + return; + } + ev.target.value = ""; + if (this._removedAreas.has(areaId)) { + this._removedAreas.delete(areaId); + this._removedAreas = new Set(this._removedAreas); + return; + } + this._addedAreas.add(areaId); + this._addedAreas = new Set(this._addedAreas); + } + private _isNameValid() { return this._name.trim() !== ""; } @@ -189,9 +306,13 @@ class DialogFloorDetail extends LitElement { aliases: this._aliases, }; if (create) { - await this._params!.createEntry!(values); + await this._params!.createEntry!(values, this._addedAreas); } else { - await this._params!.updateEntry!(values); + await this._params!.updateEntry!( + values, + this._addedAreas, + this._removedAreas + ); } this.closeDialog(); } catch (err: any) { @@ -209,6 +330,7 @@ class DialogFloorDetail extends LitElement { static get styles(): CSSResultGroup { return [ + haStyle, haStyleDialog, css` ha-textfield { @@ -218,6 +340,9 @@ class DialogFloorDetail extends LitElement { ha-floor-icon { color: var(--secondary-text-color); } + ha-chip-set { + margin-bottom: 8px; + } `, ]; } diff --git a/src/panels/config/areas/ha-config-areas-dashboard.ts b/src/panels/config/areas/ha-config-areas-dashboard.ts index 12584b4f22..1b00b463d8 100644 --- a/src/panels/config/areas/ha-config-areas-dashboard.ts +++ b/src/panels/config/areas/ha-config-areas-dashboard.ts @@ -414,10 +414,31 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) { private _openFloorDialog(entry?: FloorRegistryEntry) { showFloorRegistryDetailDialog(this, { entry, - createEntry: async (values) => - createFloorRegistryEntry(this.hass!, values), - updateEntry: async (values) => - updateFloorRegistryEntry(this.hass!, entry!.floor_id, values), + createEntry: async (values, addedAreas) => { + const floor = await createFloorRegistryEntry(this.hass!, values); + addedAreas.forEach((areaId) => { + updateAreaRegistryEntry(this.hass, areaId, { + floor_id: floor.floor_id, + }); + }); + }, + updateEntry: async (values, addedAreas, removedAreas) => { + const floor = await updateFloorRegistryEntry( + this.hass!, + entry!.floor_id, + values + ); + addedAreas.forEach((areaId) => { + updateAreaRegistryEntry(this.hass, areaId, { + floor_id: floor.floor_id, + }); + }); + removedAreas.forEach((areaId) => { + updateAreaRegistryEntry(this.hass, areaId, { + floor_id: null, + }); + }); + }, }); } diff --git a/src/panels/config/areas/show-dialog-floor-registry-detail.ts b/src/panels/config/areas/show-dialog-floor-registry-detail.ts index ecaa74786b..d321f60670 100644 --- a/src/panels/config/areas/show-dialog-floor-registry-detail.ts +++ b/src/panels/config/areas/show-dialog-floor-registry-detail.ts @@ -7,9 +7,14 @@ import { export interface FloorRegistryDetailDialogParams { entry?: FloorRegistryEntry; suggestedName?: string; - createEntry?: (values: FloorRegistryEntryMutableParams) => Promise; + createEntry?: ( + values: FloorRegistryEntryMutableParams, + addedAreas: Set + ) => Promise; updateEntry?: ( - updates: Partial + updates: Partial, + addedAreas: Set, + removedAreas: Set ) => Promise; } diff --git a/src/panels/config/automation/ha-automation-picker.ts b/src/panels/config/automation/ha-automation-picker.ts index 24232a69f8..28cd56dfc5 100644 --- a/src/panels/config/automation/ha-automation-picker.ts +++ b/src/panels/config/automation/ha-automation-picker.ts @@ -73,6 +73,7 @@ import { } from "../../../data/automation"; import { CategoryRegistryEntry, + createCategoryRegistryEntry, subscribeCategoryRegistry, } from "../../../data/category_registry"; import { fullEntitiesContext } from "../../../data/context"; @@ -84,6 +85,7 @@ import { } from "../../../data/entity_registry"; import { LabelRegistryEntry, + createLabelRegistryEntry, subscribeLabelRegistry, } from "../../../data/label_registry"; import { findRelated } from "../../../data/search"; @@ -98,11 +100,14 @@ import { HomeAssistant, Route, ServiceCallResponse } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; import { turnOnOffEntity } from "../../lovelace/common/entity/turn-on-off-entity"; import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; +import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail"; import { configSections } from "../ha-panel-config"; +import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; import { showNewAutomationDialog } from "./show-dialog-new-automation"; type AutomationItem = AutomationEntity & { name: string; + area: string | undefined; last_triggered?: string | undefined; formatted_state: string; category: string | undefined; @@ -152,6 +157,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { ( automations: AutomationEntity[], entityReg: EntityRegistryEntry[], + areas: HomeAssistant["areas"], categoryReg?: CategoryRegistryEntry[], labelReg?: LabelRegistryEntry[], filteredAutomations?: string[] | null @@ -174,6 +180,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { return { ...automation, name: computeStateName(automation), + area: entityRegEntry?.area_id + ? areas[entityRegEntry?.area_id]?.name + : undefined, last_triggered: automation.attributes.last_triggered || undefined, formatted_state: this.hass.formatEntityState(automation), category: category @@ -242,6 +251,13 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { `; }, }, + area: { + title: localize("ui.panel.config.automation.picker.headers.area"), + hidden: true, + groupable: true, + filterable: true, + sortable: true, + }, category: { title: localize("ui.panel.config.automation.picker.headers.category"), hidden: true, @@ -256,33 +272,32 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { 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)} - `; + 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 = { + formatted_state: { width: "82px", sortable: true, groupable: true, title: "", + type: "overflow", + hidden: narrow, label: this.hass.localize("ui.panel.config.automation.picker.state"), template: (automation) => html` `, - }; - } - - columns.actions = { - title: "", - width: "64px", - type: "icon-button", - template: (automation) => html` - - `, + }, + actions: { + title: "", + width: "64px", + type: "icon-button", + template: (automation) => html` + + `, + }, }; return columns; } @@ -357,21 +371,49 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { "ui.panel.config.automation.picker.bulk_actions.no_category" )}

+ + + +
+ ${this.hass.localize("ui.panel.config.category.editor.add")} +
`; - const labelItems = html` ${this._labels?.map((label) => { - const color = label.color ? computeCssColor(label.color) : undefined; - return html` - - ${label.icon - ? html`` - : nothing} - ${label.name} - - `; - })}`; + const labelItems = html`${this._labels?.map((label) => { + const color = label.color ? computeCssColor(label.color) : undefined; + const selected = this._selected.every((entityId) => + this.hass.entities[entityId]?.labels.includes(label.label_id) + ); + const partial = + !selected && + this._selected.some((entityId) => + this.hass.entities[entityId]?.labels.includes(label.label_id) + ); + return html` + + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })} + + +
+ ${this.hass.localize("ui.panel.config.labels.add_label")} +
`; return html` [] = []; this._selected.forEach((entityId) => { promises.push( updateEntityRegistryEntry(this.hass, entityId, { - labels: this.hass.entities[entityId].labels.concat(label), + labels: + action === "add" + ? this.hass.entities[entityId].labels.concat(label) + : this.hass.entities[entityId].labels.filter( + (lbl) => lbl !== label + ), }) ); }); @@ -1079,6 +1128,20 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) { await Promise.all(promises); } + private _createCategory() { + showCategoryRegistryDetailDialog(this, { + scope: "automation", + createEntry: (values) => + createCategoryRegistryEntry(this.hass, "automation", values), + }); + } + + private _createLabel() { + showLabelDetailDialog(this, { + createEntry: (values) => createLabelRegistryEntry(this.hass, values), + }); + } + static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index 76e086cba9..ba9630e2fa 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -57,6 +57,7 @@ import { import { IntegrationManifest } from "../../../data/integration"; import { LabelRegistryEntry, + createLabelRegistryEntry, subscribeLabelRegistry, } from "../../../data/label_registry"; import "../../../layouts/hass-tabs-subpage-data-table"; @@ -67,6 +68,7 @@ import { brandsUrl } from "../../../util/brands-url"; import { configSections } from "../ha-panel-config"; import "../integrations/ha-integration-overflow-menu"; import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog"; +import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; interface DeviceRowData extends DeviceRegistryEntry { device?: DeviceRowData; @@ -542,20 +544,42 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { this._labels ); - const labelItems = html` ${this._labels?.map((label) => { - const color = label.color ? computeCssColor(label.color) : undefined; - return html` - - ${label.icon - ? html`` - : nothing} - ${label.name} - - `; - })}`; + const labelItems = html`${this._labels?.map((label) => { + const color = label.color ? computeCssColor(label.color) : undefined; + const selected = this._selected.every((deviceId) => + this.hass.devices[deviceId]?.labels.includes(label.label_id) + ); + const partial = + !selected && + this._selected.some((deviceId) => + this.hass.devices[deviceId]?.labels.includes(label.label_id) + ); + return html` + + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })} + + +
+ ${this.hass.localize("ui.panel.config.labels.add_label")} +
`; return html` [] = []; this._selected.forEach((deviceId) => { promises.push( updateDeviceRegistryEntry(this.hass, deviceId, { - labels: this.hass.devices[deviceId].labels.concat(label), + labels: + action === "add" + ? this.hass.devices[deviceId].labels.concat(label) + : this.hass.devices[deviceId].labels.filter( + (lbl) => lbl !== label + ), }) ); }); await Promise.all(promises); } + private _createLabel() { + showLabelDetailDialog(this, { + createEntry: (values) => createLabelRegistryEntry(this.hass, values), + }); + } + static get styles(): CSSResultGroup { return [ css` diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index 80adf624d5..910448ac3a 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -70,6 +70,7 @@ import { import { entryIcon } from "../../../data/icons"; import { LabelRegistryEntry, + createLabelRegistryEntry, subscribeLabelRegistry, } from "../../../data/label_registry"; import { @@ -86,6 +87,7 @@ import type { HomeAssistant, Route } from "../../../types"; import { configSections } from "../ha-panel-config"; import "../integrations/ha-integration-overflow-menu"; import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog"; +import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; export interface StateEntity extends Omit { @@ -132,7 +134,7 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { { value: string[] | undefined; items: Set | undefined } > = {}; - @state() private _selectedEntities: string[] = []; + @state() private _selected: string[] = []; @state() private _expandedFilter?: string; @@ -515,19 +517,41 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { ); const labelItems = html` ${this._labels?.map((label) => { - const color = label.color ? computeCssColor(label.color) : undefined; - return html` - - ${label.icon - ? html`` - : nothing} - ${label.name} - - `; - })}`; + const color = label.color ? computeCssColor(label.color) : undefined; + const selected = this._selected.every((entityId) => + this.hass.entities[entityId]?.labels.includes(label.label_id) + ); + const partial = + !selected && + this._selected.some((entityId) => + this.hass.entities[entityId]?.labels.includes(label.label_id) + ); + return html` + + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })} + + +
+ ${this.hass.localize("ui.panel.config.labels.add_label")} +
`; return html` ): void { - this._selectedEntities = ev.detail.value; + this._selected = ev.detail.value; } private async _enableSelected() { showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.entities.picker.enable_selected.confirm_title", - { number: this._selectedEntities.length } + { number: this._selected.length } ), text: this.hass.localize( "ui.panel.config.entities.picker.enable_selected.confirm_text" @@ -914,7 +938,7 @@ ${ let require_restart = false; let reload_delay = 0; await Promise.all( - this._selectedEntities.map(async (entity) => { + this._selected.map(async (entity) => { const result = await updateEntityRegistryEntry(this.hass, entity, { disabled_by: null, }); @@ -951,7 +975,7 @@ ${ showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.entities.picker.disable_selected.confirm_title", - { number: this._selectedEntities.length } + { number: this._selected.length } ), text: this.hass.localize( "ui.panel.config.entities.picker.disable_selected.confirm_text" @@ -959,7 +983,7 @@ ${ confirmText: this.hass.localize("ui.common.disable"), dismissText: this.hass.localize("ui.common.cancel"), confirm: () => { - this._selectedEntities.forEach((entity) => + this._selected.forEach((entity) => updateEntityRegistryEntry(this.hass, entity, { disabled_by: "user", }) @@ -973,7 +997,7 @@ ${ showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.entities.picker.hide_selected.confirm_title", - { number: this._selectedEntities.length } + { number: this._selected.length } ), text: this.hass.localize( "ui.panel.config.entities.picker.hide_selected.confirm_text" @@ -981,7 +1005,7 @@ ${ confirmText: this.hass.localize("ui.common.hide"), dismissText: this.hass.localize("ui.common.cancel"), confirm: () => { - this._selectedEntities.forEach((entity) => + this._selected.forEach((entity) => updateEntityRegistryEntry(this.hass, entity, { hidden_by: "user", }) @@ -992,7 +1016,7 @@ ${ } private _unhideSelected() { - this._selectedEntities.forEach((entity) => + this._selected.forEach((entity) => updateEntityRegistryEntry(this.hass, entity, { hidden_by: null, }) @@ -1002,11 +1026,21 @@ ${ private async _handleBulkLabel(ev) { const label = ev.currentTarget.value; + const action = ev.currentTarget.action; const promises: Promise[] = []; - this._selectedEntities.forEach((entityId) => { + this._selected.forEach((entityId) => { + const entityReg = + this.hass.entities[entityId] || + this._entities.find((entReg) => entReg.entity_id === entityId); + if (!entityReg) { + return; + } promises.push( updateEntityRegistryEntry(this.hass, entityId, { - labels: this.hass.entities[entityId].labels.concat(label), + labels: + action === "add" + ? entityReg.labels.concat(label) + : entityReg.labels.filter((lbl) => lbl !== label), }) ); }); @@ -1014,21 +1048,19 @@ ${ } private _removeSelected() { - const removeableEntities = this._selectedEntities.filter((entity) => { + const removeableEntities = this._selected.filter((entity) => { const stateObj = this.hass.states[entity]; return stateObj?.attributes.restored; }); showConfirmationDialog(this, { title: this.hass.localize( `ui.panel.config.entities.picker.remove_selected.confirm_${ - removeableEntities.length !== this._selectedEntities.length - ? "partly_" - : "" + removeableEntities.length !== this._selected.length ? "partly_" : "" }title`, { number: removeableEntities.length } ), text: - removeableEntities.length === this._selectedEntities.length + removeableEntities.length === this._selected.length ? this.hass.localize( "ui.panel.config.entities.picker.remove_selected.confirm_text" ) @@ -1036,7 +1068,7 @@ ${ "ui.panel.config.entities.picker.remove_selected.confirm_partly_text", { removable: removeableEntities.length, - selected: this._selectedEntities.length, + selected: this._selected.length, } ), confirmText: this.hass.localize("ui.common.remove"), @@ -1091,6 +1123,12 @@ ${ }); } + private _createLabel() { + showLabelDetailDialog(this, { + createEntry: (values) => createLabelRegistryEntry(this.hass, values), + }); + } + static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts index 125663863f..a63b0c2a01 100644 --- a/src/panels/config/helpers/ha-config-helpers.ts +++ b/src/panels/config/helpers/ha-config-helpers.ts @@ -1,5 +1,15 @@ +import { consume } from "@lit-labs/context"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; -import { mdiAlertCircle, mdiPencilOff, mdiPlus } from "@mdi/js"; +import { + mdiAlertCircle, + mdiChevronRight, + mdiCog, + mdiDotsVertical, + mdiMenuDown, + mdiPencilOff, + mdiPlus, + mdiTag, +} from "@mdi/js"; import { HassEntity } from "home-assistant-js-websocket"; import { CSSResultGroup, @@ -11,8 +21,9 @@ import { nothing, } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { consume } from "@lit-labs/context"; import memoizeOne from "memoize-one"; +import { computeCssColor } from "../../../common/color/compute-color"; +import { HASSDomEvent } from "../../../common/dom/fire_event"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { navigate } from "../../../common/navigate"; import { @@ -23,22 +34,42 @@ import { extractSearchParam } from "../../../common/url/search-params"; import { DataTableColumnContainer, RowClickedEvent, + SelectionChangedEvent, } from "../../../components/data-table/ha-data-table"; import "../../../components/data-table/ha-data-table-labels"; import "../../../components/ha-fab"; +import "../../../components/ha-filter-categories"; +import "../../../components/ha-filter-devices"; +import "../../../components/ha-filter-entities"; +import "../../../components/ha-filter-floor-areas"; +import "../../../components/ha-filter-labels"; import "../../../components/ha-icon"; +import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-state-icon"; import "../../../components/ha-svg-icon"; +import { + CategoryRegistryEntry, + createCategoryRegistryEntry, + subscribeCategoryRegistry, +} from "../../../data/category_registry"; import { ConfigEntry, subscribeConfigEntries, } from "../../../data/config_entries"; import { getConfigFlowHandlers } from "../../../data/config_flow"; +import { fullEntitiesContext } from "../../../data/context"; import { EntityRegistryEntry, + UpdateEntityRegistryEntryResult, subscribeEntityRegistry, + updateEntityRegistryEntry, } from "../../../data/entity_registry"; import { domainToName } from "../../../data/integration"; +import { + LabelRegistryEntry, + createLabelRegistryEntry, + subscribeLabelRegistry, +} from "../../../data/label_registry"; import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; import { @@ -49,18 +80,15 @@ import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-tabs-subpage-data-table"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; +import { haStyle } from "../../../resources/styles"; import { HomeAssistant, Route } from "../../../types"; +import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; import { configSections } from "../ha-panel-config"; import "../integrations/ha-integration-overflow-menu"; import { isHelperDomain } from "./const"; import { showHelperDetailDialog } from "./show-dialog-helper-detail"; -import { - LabelRegistryEntry, - subscribeLabelRegistry, -} from "../../../data/label_registry"; -import { fullEntitiesContext } from "../../../data/context"; -import "../../../components/ha-filter-labels"; -import { haStyle } from "../../../resources/styles"; +import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail"; +import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; type HelperItem = { id: string; @@ -71,6 +99,7 @@ type HelperItem = { type: string; configEntry?: ConfigEntry; entity?: HassEntity; + category: string | undefined; label_entries: LabelRegistryEntry[]; }; @@ -111,6 +140,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { @state() private _configEntries?: Record; + @state() private _selected: string[] = []; + @state() private _activeFilters?: string[]; @state() private _filters: Record< @@ -120,6 +151,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { @state() private _expandedFilter?: string; + @state() + _categories!: CategoryRegistryEntry[]; + @state() _labels!: LabelRegistryEntry[]; @@ -156,65 +190,86 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { subscribeLabelRegistry(this.hass.connection, (labels) => { this._labels = labels; }), + subscribeCategoryRegistry( + this.hass.connection, + "helpers", + (categories) => { + this._categories = categories; + } + ), ]; } private _columns = memoizeOne( - (narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => { - const columns: DataTableColumnContainer = { - icon: { - title: "", - label: localize("ui.panel.config.helpers.picker.headers.icon"), - type: "icon", - template: (helper) => - helper.entity - ? html`` - : html``, - }, - name: { - title: localize("ui.panel.config.helpers.picker.headers.name"), - main: true, - sortable: true, - filterable: true, - grows: true, - direction: "asc", - template: (helper) => html` -
${helper.name}
- ${narrow - ? html`
${helper.entity_id}
` - : nothing} - ${helper.label_entries.length - ? html` - - ` - : nothing} - `, - }, - }; - if (!narrow) { - columns.entity_id = { - title: localize("ui.panel.config.helpers.picker.headers.entity_id"), - sortable: true, - filterable: true, - width: "25%", - }; - } - columns.localized_type = { + ( + narrow: boolean, + localize: LocalizeFunc + ): DataTableColumnContainer => ({ + icon: { + title: "", + label: localize("ui.panel.config.helpers.picker.headers.icon"), + type: "icon", + template: (helper) => + helper.entity + ? html`` + : html``, + }, + name: { + title: localize("ui.panel.config.helpers.picker.headers.name"), + main: true, + sortable: true, + filterable: true, + grows: true, + direction: "asc", + template: (helper) => html` +
${helper.name}
+ ${narrow + ? html`
${helper.entity_id}
` + : nothing} + ${helper.label_entries.length + ? html` + + ` + : nothing} + `, + }, + entity_id: { + title: localize("ui.panel.config.helpers.picker.headers.entity_id"), + hidden: this.narrow, + sortable: true, + filterable: true, + width: "25%", + }, + category: { + title: localize("ui.panel.config.helpers.picker.headers.category"), + hidden: true, + groupable: true, + filterable: true, + sortable: true, + }, + labels: { + title: "", + hidden: true, + filterable: true, + template: (helper) => + helper.label_entries.map((lbl) => lbl.name).join(" "), + }, + localized_type: { title: localize("ui.panel.config.helpers.picker.headers.type"), sortable: true, width: "25%", filterable: true, groupable: true, - }; - columns.editable = { + }, + editable: { title: "", label: this.hass.localize( "ui.panel.config.helpers.picker.headers.editable" @@ -237,9 +292,36 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { ` : ""} `, - }; - return columns; - } + }, + actions: { + title: "", + width: "64px", + type: "overflow-menu", + template: (helper) => html` + this._openSettings(helper), + }, + { + path: mdiTag, + label: this.hass.localize( + `ui.panel.config.automation.picker.${helper.category ? "edit_category" : "assign_category"}` + ), + action: () => this._editCategory(helper), + }, + ]} + > + + `, + }, + }) ); private _getItems = memoizeOne( @@ -249,6 +331,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { entityEntries: Record, configEntries: Record, entityReg: EntityRegistryEntry[], + categoryReg?: CategoryRegistryEntry[], labelReg?: LabelRegistryEntry[], filteredStateItems?: string[] | null ): HelperItem[] => { @@ -305,6 +388,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { (reg) => reg.entity_id === item.entity_id ); const labels = labelReg && entityRegEntry?.labels; + const category = entityRegEntry?.categories.helpers; return { ...item, localized_type: item.configEntry @@ -315,6 +399,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { label_entries: (labels || []).map( (lbl) => labelReg!.find((label) => label.label_id === lbl)! ), + category: category + ? categoryReg?.find((cat) => cat.category_id === category)?.name + : undefined, }; }); } @@ -330,6 +417,67 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { return html` `; } + const categoryItems = html`${this._categories?.map( + (category) => + html` + ${category.icon + ? html`` + : html``} +
${category.name}
+
` + )} + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.no_category" + )} +
+
+ + +
+ ${this.hass.localize("ui.panel.config.category.editor.add")} +
+
`; + const labelItems = html`${this._labels?.map((label) => { + const color = label.color ? computeCssColor(label.color) : undefined; + const selected = this._selected.every((entityId) => + this.hass.entities[entityId]?.labels.includes(label.label_id) + ); + const partial = + !selected && + this._selected.some((entityId) => + this.hass.entities[entityId]?.labels.includes(label.label_id) + ); + return html` + + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })} + +
+ ${this.hass.localize("ui.panel.config.labels.add_label")} +
+
`; + return html` filter.value?.length @@ -348,9 +499,11 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { this._entityEntries, this._configEntries, this._entityReg, + this._categories, this._labels, this._filteredStateItems )} + initialGroupColumn="category" .activeFilters=${this._activeFilters} @clear-filter=${this._clearFilter} @row-click=${this._openEditDialog} @@ -361,6 +514,26 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { )} class=${this.narrow ? "narrow" : ""} > + + + + + ${!this.narrow + ? html` + + + + ${categoryItems} + + ${this.hass.dockedSidebar === "docked" + ? nothing + : html` + + + + ${labelItems} + `}` + : nothing} + ${this.narrow || this.hass.dockedSidebar === "docked" + ? html` + + ${ + this.narrow + ? html` + + ` + : html`` + } + + ${ + this.narrow + ? html` + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.move_category" + )} +
+ +
+ ${categoryItems} +
` + : nothing + } + ${ + this.narrow || this.hass.dockedSidebar === "docked" + ? html` + +
+ ${this.hass.localize( + "ui.panel.config.automation.picker.bulk_actions.add_label" + )} +
+ +
+ ${labelItems} +
` + : nothing + } +
` + : nothing} labelItems!.has(x))); } + if (key === "ha-filter-categories" && filter.value?.length) { + const categoryItems: Set = new Set(); + this._stateItems + .filter( + (stateItem) => + filter.value![0] === + this._entityReg.find( + (reg) => reg.entity_id === stateItem.entity_id + )?.categories.helpers + ) + .forEach((stateItem) => categoryItems.add(stateItem.entity_id)); + if (!items) { + items = categoryItems; + continue; + } + items = + "intersection" in items + ? // @ts-ignore + items.intersection(categoryItems) + : new Set([...items].filter((x) => categoryItems!.has(x))); + } } this._filteredStateItems = items ? [...items] : undefined; } @@ -446,6 +748,65 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { this._applyFilters(); } + private _editCategory(helper: any) { + const entityReg = this._entityReg.find( + (reg) => reg.entity_id === helper.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: "helpers", + entityReg, + }); + } + + private async _handleBulkCategory(ev) { + const category = ev.currentTarget.value; + const promises: Promise[] = []; + this._selected.forEach((entityId) => { + promises.push( + updateEntityRegistryEntry(this.hass, entityId, { + categories: { helpers: category }, + }) + ); + }); + await Promise.all(promises); + } + + private async _handleBulkLabel(ev) { + const label = ev.currentTarget.value; + const action = ev.currentTarget.action; + const promises: Promise[] = []; + this._selected.forEach((entityId) => { + promises.push( + updateEntityRegistryEntry(this.hass, entityId, { + labels: + action === "add" + ? this.hass.entities[entityId].labels.concat(label) + : this.hass.entities[entityId].labels.filter( + (lbl) => lbl !== label + ), + }) + ); + }); + await Promise.all(promises); + } + + private _handleSelectionChanged( + ev: HASSDomEvent + ): void { + this._selected = ev.detail.value; + } + protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); if (this.route.path === "/add") { @@ -563,10 +924,35 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { } } + private _openSettings(helper: HelperItem) { + if (helper.entity) { + showMoreInfoDialog(this, { + entityId: helper.entity_id, + view: "settings", + }); + } else { + showOptionsFlowDialog(this, helper.configEntry!); + } + } + private _createHelper() { showHelperDetailDialog(this, {}); } + private _createCategory() { + showCategoryRegistryDetailDialog(this, { + scope: "helpers", + createEntry: (values) => + createCategoryRegistryEntry(this.hass, "helpers", values), + }); + } + + private _createLabel() { + showLabelDetailDialog(this, { + createEntry: (values) => createLabelRegistryEntry(this.hass, values), + }); + } + static get styles(): CSSResultGroup { return [ haStyle, @@ -577,6 +963,16 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { hass-tabs-subpage-data-table.narrow { --data-table-row-height: 72px; } + ha-assist-chip { + --ha-assist-chip-container-shape: 10px; + } + ha-button-menu-new ha-assist-chip { + --md-assist-chip-trailing-space: 8px; + } + ha-label { + --ha-label-background-color: var(--color, var(--grey-color)); + --ha-label-background-opacity: 0.5; + } `, ]; } diff --git a/src/panels/config/scene/ha-scene-dashboard.ts b/src/panels/config/scene/ha-scene-dashboard.ts index cb8bebaaf1..990ab42656 100644 --- a/src/panels/config/scene/ha-scene-dashboard.ts +++ b/src/panels/config/scene/ha-scene-dashboard.ts @@ -27,6 +27,7 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; +import { computeCssColor } from "../../../common/color/compute-color"; import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { relativeTime } from "../../../common/datetime/relative_time"; import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event"; @@ -48,12 +49,13 @@ import "../../../components/ha-filter-floor-areas"; import "../../../components/ha-filter-labels"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-overflow-menu"; -import "../../../components/ha-state-icon"; -import "../../../components/ha-svg-icon"; import "../../../components/ha-menu-item"; +import "../../../components/ha-state-icon"; import "../../../components/ha-sub-menu"; +import "../../../components/ha-svg-icon"; import { CategoryRegistryEntry, + createCategoryRegistryEntry, subscribeCategoryRegistry, } from "../../../data/category_registry"; import { fullEntitiesContext } from "../../../data/context"; @@ -66,6 +68,7 @@ import { import { forwardHaptic } from "../../../data/haptics"; import { LabelRegistryEntry, + createLabelRegistryEntry, subscribeLabelRegistry, } from "../../../data/label_registry"; import { @@ -86,11 +89,13 @@ import { HomeAssistant, Route } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; import { showToast } from "../../../util/toast"; import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; +import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail"; import { configSections } from "../ha-panel-config"; -import { computeCssColor } from "../../../common/color/compute-color"; +import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; type SceneItem = SceneEntity & { name: string; + area: string | undefined; category: string | undefined; labels: LabelRegistryEntry[]; }; @@ -136,6 +141,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { ( scenes: SceneEntity[], entityReg: EntityRegistryEntry[], + areas: HomeAssistant["areas"], categoryReg?: CategoryRegistryEntry[], labelReg?: LabelRegistryEntry[], filteredScenes?: string[] | null @@ -156,6 +162,9 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { return { ...scene, name: computeStateName(scene), + area: entityRegEntry?.area_id + ? areas[entityRegEntry?.area_id]?.name + : undefined, category: category ? categoryReg?.find((cat) => cat.category_id === category)?.name : undefined, @@ -198,6 +207,13 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { : nothing} `, }, + area: { + title: localize("ui.panel.config.scene.picker.headers.area"), + hidden: true, + groupable: true, + filterable: true, + sortable: true, + }, category: { title: localize("ui.panel.config.scene.picker.headers.category"), hidden: true, @@ -211,14 +227,13 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { filterable: true, template: (scene) => scene.labels.map((lbl) => lbl.name).join(" "), }, - }; - if (!narrow) { - columns.state = { + state: { title: localize( "ui.panel.config.scene.picker.headers.last_activated" ), sortable: true, width: "30%", + hidden: narrow, template: (scene) => { const lastActivated = scene.state; if (!lastActivated || isUnavailableState(lastActivated)) { @@ -233,80 +248,80 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { : relativeTime(date, this.hass.locale)} `; }, - }; - } - columns.only_editable = { - title: "", - width: "56px", - template: (scene) => - !scene.attributes.id - ? html` - - ${this.hass.localize( - "ui.panel.config.scene.picker.only_editable" - )} - - - ` - : "", - }; - columns.actions = { - title: "", - width: "64px", - type: "overflow-menu", - template: (scene) => html` - this._showInfo(scene), - }, - { - path: mdiPlay, - label: this.hass.localize( - "ui.panel.config.scene.picker.activate" - ), - action: () => this._activateScene(scene), - }, - { - path: mdiTag, - label: this.hass.localize( - `ui.panel.config.scene.picker.${scene.category ? "edit_category" : "assign_category"}` - ), - action: () => this._editCategory(scene), - }, - { - divider: true, - }, - { - path: mdiContentDuplicate, - label: this.hass.localize( - "ui.panel.config.scene.picker.duplicate" - ), - action: () => this._duplicate(scene), - disabled: !scene.attributes.id, - }, - { - label: this.hass.localize( - "ui.panel.config.scene.picker.delete" - ), - path: mdiDelete, - action: () => this._deleteConfirm(scene), - warning: scene.attributes.id, - disabled: !scene.attributes.id, - }, - ]} - > - - `, + }, + only_editable: { + title: "", + width: "56px", + template: (scene) => + !scene.attributes.id + ? html` + + ${this.hass.localize( + "ui.panel.config.scene.picker.only_editable" + )} + + + ` + : "", + }, + actions: { + title: "", + width: "64px", + type: "overflow-menu", + template: (scene) => html` + this._showInfo(scene), + }, + { + path: mdiPlay, + label: this.hass.localize( + "ui.panel.config.scene.picker.activate" + ), + action: () => this._activateScene(scene), + }, + { + path: mdiTag, + label: this.hass.localize( + `ui.panel.config.scene.picker.${scene.category ? "edit_category" : "assign_category"}` + ), + action: () => this._editCategory(scene), + }, + { + divider: true, + }, + { + path: mdiContentDuplicate, + label: this.hass.localize( + "ui.panel.config.scene.picker.duplicate" + ), + action: () => this._duplicate(scene), + disabled: !scene.attributes.id, + }, + { + label: this.hass.localize( + "ui.panel.config.scene.picker.delete" + ), + path: mdiDelete, + action: () => this._deleteConfirm(scene), + warning: scene.attributes.id, + disabled: !scene.attributes.id, + }, + ]} + > + + `, + }, }; return columns; @@ -350,21 +365,49 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { "ui.panel.config.automation.picker.bulk_actions.no_category" )}
+
+ + +
+ ${this.hass.localize("ui.panel.config.category.editor.add")} +
`; const labelItems = html` ${this._labels?.map((label) => { - const color = label.color ? computeCssColor(label.color) : undefined; - return html` - - ${label.icon - ? html`` - : nothing} - ${label.name} - - `; - })}`; + const color = label.color ? computeCssColor(label.color) : undefined; + const selected = this._selected.every((entityId) => + this.hass.entities[entityId]?.labels.includes(label.label_id) + ); + const partial = + !selected && + this._selected.some((entityId) => + this.hass.entities[entityId]?.labels.includes(label.label_id) + ); + return html` + + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })} + + +
+ ${this.hass.localize("ui.panel.config.labels.add_label")} +
`; return html` [] = []; this._selected.forEach((entityId) => { promises.push( updateEntityRegistryEntry(this.hass, entityId, { - labels: this.hass.entities[entityId].labels.concat(label), + labels: + action === "add" + ? this.hass.entities[entityId].labels.concat(label) + : this.hass.entities[entityId].labels.filter( + (lbl) => lbl !== label + ), }) ); }); @@ -828,6 +878,20 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) { }); } + private _createCategory() { + showCategoryRegistryDetailDialog(this, { + scope: "scene", + createEntry: (values) => + createCategoryRegistryEntry(this.hass, "scene", values), + }); + } + + private _createLabel() { + showLabelDetailDialog(this, { + createEntry: (values) => createLabelRegistryEntry(this.hass, values), + }); + } + static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/panels/config/script/ha-script-picker.ts b/src/panels/config/script/ha-script-picker.ts index 15441b45da..4d0242e934 100644 --- a/src/panels/config/script/ha-script-picker.ts +++ b/src/panels/config/script/ha-script-picker.ts @@ -27,6 +27,7 @@ import { import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; +import { computeCssColor } from "../../../common/color/compute-color"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { relativeTime } from "../../../common/datetime/relative_time"; @@ -49,11 +50,12 @@ import "../../../components/ha-filter-floor-areas"; import "../../../components/ha-filter-labels"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-overflow-menu"; -import "../../../components/ha-svg-icon"; import "../../../components/ha-menu-item"; import "../../../components/ha-sub-menu"; +import "../../../components/ha-svg-icon"; import { CategoryRegistryEntry, + createCategoryRegistryEntry, subscribeCategoryRegistry, } from "../../../data/category_registry"; import { fullEntitiesContext } from "../../../data/context"; @@ -65,6 +67,7 @@ import { } from "../../../data/entity_registry"; import { LabelRegistryEntry, + createLabelRegistryEntry, subscribeLabelRegistry, } from "../../../data/label_registry"; import { @@ -88,11 +91,13 @@ import { documentationUrl } from "../../../util/documentation-url"; import { showToast } from "../../../util/toast"; import { showNewAutomationDialog } from "../automation/show-dialog-new-automation"; import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; +import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail"; import { configSections } from "../ha-panel-config"; -import { computeCssColor } from "../../../common/color/compute-color"; +import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; type ScriptItem = ScriptEntity & { name: string; + area: string | undefined; category: string | undefined; labels: LabelRegistryEntry[]; }; @@ -140,6 +145,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { ( scripts: ScriptEntity[], entityReg: EntityRegistryEntry[], + areas: HomeAssistant["areas"], categoryReg?: CategoryRegistryEntry[], labelReg?: LabelRegistryEntry[], filteredScripts?: string[] | null @@ -162,6 +168,9 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { return { ...script, name: computeStateName(script), + area: entityRegEntry?.area_id + ? areas[entityRegEntry?.area_id]?.name + : undefined, last_triggered: script.attributes.last_triggered || undefined, category: category ? categoryReg?.find((cat) => cat.category_id === category)?.name @@ -227,6 +236,13 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { `; }, }, + area: { + title: localize("ui.panel.config.script.picker.headers.area"), + hidden: true, + groupable: true, + filterable: true, + sortable: true, + }, category: { title: localize("ui.panel.config.script.picker.headers.category"), hidden: true, @@ -240,9 +256,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { filterable: true, template: (script) => script.labels.map((lbl) => lbl.name).join(" "), }, - }; - if (!narrow) { - columns.last_triggered = { + last_triggered: { + hidden: narrow, sortable: true, width: "40%", title: localize("ui.card.automation.last_triggered"), @@ -262,66 +277,67 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { : this.hass.localize("ui.components.relative_time.never")} `; }, - }; - } - - columns.actions = { - title: "", - width: "64px", - type: "overflow-menu", - template: (script) => html` - this._showInfo(script), - }, - { - path: mdiTag, - label: this.hass.localize( - `ui.panel.config.script.picker.${script.category ? "edit_category" : "assign_category"}` - ), - action: () => this._editCategory(script), - }, - { - path: mdiPlay, - label: this.hass.localize("ui.panel.config.script.picker.run"), - action: () => this._runScript(script), - }, - { - path: mdiTransitConnection, - label: this.hass.localize( - "ui.panel.config.script.picker.show_trace" - ), - action: () => this._showTrace(script), - }, - { - divider: true, - }, - { - path: mdiContentDuplicate, - label: this.hass.localize( - "ui.panel.config.script.picker.duplicate" - ), - action: () => this._duplicate(script), - }, - { - label: this.hass.localize( - "ui.panel.config.script.picker.delete" - ), - path: mdiDelete, - action: () => this._deleteConfirm(script), - warning: true, - }, - ]} - > - - `, + }, + actions: { + title: "", + width: "64px", + type: "overflow-menu", + template: (script) => html` + this._showInfo(script), + }, + { + path: mdiTag, + label: this.hass.localize( + `ui.panel.config.script.picker.${script.category ? "edit_category" : "assign_category"}` + ), + action: () => this._editCategory(script), + }, + { + path: mdiPlay, + label: this.hass.localize( + "ui.panel.config.script.picker.run" + ), + action: () => this._runScript(script), + }, + { + path: mdiTransitConnection, + label: this.hass.localize( + "ui.panel.config.script.picker.show_trace" + ), + action: () => this._showTrace(script), + }, + { + divider: true, + }, + { + path: mdiContentDuplicate, + label: this.hass.localize( + "ui.panel.config.script.picker.duplicate" + ), + action: () => this._duplicate(script), + }, + { + label: this.hass.localize( + "ui.panel.config.script.picker.delete" + ), + path: mdiDelete, + action: () => this._deleteConfirm(script), + warning: true, + }, + ]} + > + + `, + }, }; return columns; @@ -361,22 +377,49 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { ${this.hass.localize( "ui.panel.config.automation.picker.bulk_actions.no_category" )} +
+ +
+ ${this.hass.localize("ui.panel.config.category.editor.add")}
`; - const labelItems = html` ${this._labels?.map((label) => { - const color = label.color ? computeCssColor(label.color) : undefined; - return html` - - ${label.icon - ? html`` - : nothing} - ${label.name} - - `; - })}`; + const labelItems = html`${this._labels?.map((label) => { + const color = label.color ? computeCssColor(label.color) : undefined; + const selected = this._selected.every((entityId) => + this.hass.entities[entityId]?.labels.includes(label.label_id) + ); + const partial = + !selected && + this._selected.some((entityId) => + this.hass.entities[entityId]?.labels.includes(label.label_id) + ); + return html` + + + ${label.icon + ? html`` + : nothing} + ${label.name} + + `; + })} + + +
+ ${this.hass.localize("ui.panel.config.labels.add_label")} +
`; return html` [] = []; this._selected.forEach((entityId) => { promises.push( updateEntityRegistryEntry(this.hass, entityId, { - labels: this.hass.entities[entityId].labels.concat(label), + labels: + action === "add" + ? this.hass.entities[entityId].labels.concat(label) + : this.hass.entities[entityId].labels.filter( + (lbl) => lbl !== label + ), }) ); }); @@ -944,6 +994,20 @@ class HaScriptPicker extends SubscribeMixin(LitElement) { } } + private _createCategory() { + showCategoryRegistryDetailDialog(this, { + scope: "script", + createEntry: (values) => + createCategoryRegistryEntry(this.hass, "script", values), + }); + } + + private _createLabel() { + showLabelDetailDialog(this, { + createEntry: (values) => createLabelRegistryEntry(this.hass, values), + }); + } + static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/translations/en.json b/src/translations/en.json index f41492dce9..d4e2852c0a 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1927,7 +1927,10 @@ "aliases_section": "Aliases", "no_aliases": "No configured aliases", "configured_aliases": "{count} configured {count, plural,\n one {alias}\n other {aliases}\n}", - "aliases_description": "Aliases are alternative names used in voice assistants to refer to this floor." + "aliases_description": "Aliases are alternative names used in voice assistants to refer to this floor.", + "areas_section": "Areas", + "areas_description": "Specify the areas that are on this floor.", + "add_area": "Add area" } }, "category": { @@ -2263,7 +2266,8 @@ "name": "Name", "entity_id": "Entity ID", "type": "Type", - "editable": "Editable" + "editable": "Editable", + "category": "Category" }, "create_helper": "Create helper", "no_helpers": "Looks like you don't have any helpers yet!" @@ -2686,7 +2690,8 @@ "trigger": "Trigger", "actions": "Actions", "state": "State", - "category": "Category" + "category": "Category", + "area": "Area" }, "bulk_action": "Action", "bulk_actions": { @@ -3560,7 +3565,8 @@ "headers": { "name": "Name", "state": "State", - "category": "Category" + "category": "Category", + "area": "Area" }, "edit_category": "[%key:ui::panel::config::automation::picker::edit_category%]", "assign_category": "[%key:ui::panel::config::automation::picker::assign_category%]", @@ -3669,7 +3675,8 @@ "state": "State", "name": "Name", "last_activated": "Last activated", - "category": "Category" + "category": "Category", + "area": "Area" }, "edit_category": "[%key:ui::panel::config::automation::picker::edit_category%]", "assign_category": "[%key:ui::panel::config::automation::picker::assign_category%]",