diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 6a779119b0..47d630d236 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -20,6 +20,7 @@ import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail"; import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; +import "./ha-button"; import "./ha-combo-box-item"; import "./ha-generic-picker"; import type { HaGenericPicker } from "./ha-generic-picker"; @@ -95,6 +96,9 @@ export class HaAreaPicker extends LitElement { @property({ attribute: "add-button-label" }) public addButtonLabel?: string; + @property({ type: Boolean, attribute: "button-style" }) + public buttonStyle = false; + @query("ha-generic-picker") private _picker?: HaGenericPicker; public async open() { @@ -390,10 +394,26 @@ export class HaAreaPicker extends LitElement { )} @value-changed=${this._valueChanged} > + ${this.buttonStyle + ? html` + ${placeholder} + ` + : nothing} `; } + private _openPicker(ev: Event) { + ev.stopPropagation(); + this._picker?.open(); + } + private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); const value = ev.detail.value; diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index 13a4b70d48..8241e56a53 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -795,7 +795,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { .narrow=${this.narrow} .backPath=${this._searchParms.has("historyBack") ? undefined - : "/config"} + : "/config/devices"} .tabs=${configSections.devices} .route=${this.route} .searchLabel=${this.hass.localize( diff --git a/src/panels/config/devices/ha-config-devices-unassigned.ts b/src/panels/config/devices/ha-config-devices-unassigned.ts new file mode 100644 index 0000000000..53db616dfc --- /dev/null +++ b/src/panels/config/devices/ha-config-devices-unassigned.ts @@ -0,0 +1,365 @@ +import { consume } from "@lit/context"; +import type { CSSResultGroup, TemplateResult } from "lit"; +import { LitElement, css, html, nothing } from "lit"; + +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { storage } from "../../../common/decorators/storage"; +import type { HASSDomEvent } from "../../../common/dom/fire_event"; +import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name"; +import { navigate } from "../../../common/navigate"; +import type { LocalizeFunc } from "../../../common/translations/localize"; +import type { + DataTableColumnContainer, + RowClickedEvent, + SortingChangedEvent, +} from "../../../components/data-table/ha-data-table"; + +import "../../../components/ha-area-picker"; +import type { ConfigEntry } from "../../../data/config_entries"; +import { sortConfigEntries } from "../../../data/config_entries"; +import { fullEntitiesContext } from "../../../data/context"; +import type { DeviceEntityLookup } from "../../../data/device/device_registry"; +import { updateDeviceRegistryEntry } from "../../../data/device/device_registry"; +import type { EntityRegistryEntry } from "../../../data/entity/entity_registry"; +import type { IntegrationManifest } from "../../../data/integration"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; +import "../../../layouts/hass-tabs-subpage-data-table"; +import type { PageNavigation } from "../../../layouts/hass-tabs-subpage"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant, Route } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; + +const TABS: PageNavigation[] = [ + { + path: "/config/devices/unassigned", + translationKey: "ui.panel.config.devices.unassigned.caption", + }, +]; + +@customElement("ha-config-devices-unassigned") +export class HaConfigDevicesUnassigned extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: "is-wide", type: Boolean }) public isWide = false; + + @property({ attribute: false }) public entries!: ConfigEntry[]; + + @state() + @consume({ context: fullEntitiesContext, subscribe: true }) + entities!: EntityRegistryEntry[]; + + @property({ attribute: false }) public manifests!: IntegrationManifest[]; + + @property({ attribute: false }) public route!: Route; + + @state() + @storage({ + storage: "sessionStorage", + key: "devices-unassigned-table-search", + state: true, + subscribe: false, + }) + private _filter = ""; + + @storage({ + key: "devices-unassigned-table-sort", + state: false, + subscribe: false, + }) + private _activeSorting?: SortingChangedEvent; + + @storage({ + key: "devices-unassigned-table-grouping", + state: false, + subscribe: false, + }) + private _activeGrouping?: string; + + @storage({ + key: "devices-unassigned-table-collapsed", + state: false, + subscribe: false, + }) + private _activeCollapsed?: string; + + @storage({ + key: "devices-unassigned-table-column-order", + state: false, + subscribe: false, + }) + private _activeColumnOrder?: string[]; + + @storage({ + key: "devices-unassigned-table-hidden-columns", + state: false, + subscribe: false, + }) + private _activeHiddenColumns?: string[]; + + private _unassignedDevices = memoizeOne( + ( + devices: HomeAssistant["devices"], + entries: ConfigEntry[], + entities: EntityRegistryEntry[], + localize: LocalizeFunc + ) => { + const deviceEntityLookup: DeviceEntityLookup = {}; + for (const entity of entities) { + if (!entity.device_id) { + continue; + } + if (!(entity.device_id in deviceEntityLookup)) { + deviceEntityLookup[entity.device_id] = []; + } + deviceEntityLookup[entity.device_id].push(entity); + } + + const entryLookup: Record = {}; + for (const entry of entries) { + entryLookup[entry.entry_id] = entry; + } + + // Filter to only unassigned and enabled devices + const unassignedDevices = Object.values(devices).filter( + (device) => device.area_id === null && device.disabled_by === null + ); + + return unassignedDevices.map((device) => { + const deviceEntries = sortConfigEntries( + device.config_entries + .filter((entId) => entId in entryLookup) + .map((entId) => entryLookup[entId]), + device.primary_config_entry + ); + + return { + ...device, + name: computeDeviceNameDisplay( + device, + this.hass, + deviceEntityLookup[device.id] + ), + model: + device.model || + `<${localize("ui.panel.config.devices.data_table.unknown")}>`, + manufacturer: + device.manufacturer || + `<${localize("ui.panel.config.devices.data_table.unknown")}>`, + integration: deviceEntries.length + ? deviceEntries + .map( + (entry) => + localize(`component.${entry.domain}.title`) || entry.domain + ) + .join(", ") + : this.hass.localize( + "ui.panel.config.devices.data_table.no_integration" + ), + domains: deviceEntries.map((entry) => entry.domain), + }; + }); + } + ); + + private _columns = memoizeOne((localize: LocalizeFunc, narrow: boolean) => { + type DeviceItem = ReturnType[number]; + + const columns: DataTableColumnContainer = { + icon: { + title: "", + label: localize("ui.panel.config.devices.data_table.icon"), + type: "icon", + moveable: false, + showNarrow: true, + template: (device) => + device.domains.length + ? html`` + : "", + }, + name: { + title: localize("ui.panel.config.devices.data_table.device"), + main: true, + sortable: true, + filterable: true, + direction: "asc", + flex: 2, + minWidth: "150px", + }, + integration: { + title: localize("ui.panel.config.devices.data_table.integration"), + sortable: true, + filterable: true, + groupable: true, + minWidth: "120px", + }, + manufacturer: { + title: localize("ui.panel.config.devices.data_table.manufacturer"), + sortable: true, + filterable: true, + groupable: true, + minWidth: "120px", + defaultHidden: narrow, + }, + model: { + title: localize("ui.panel.config.devices.data_table.model"), + sortable: true, + filterable: true, + minWidth: "120px", + defaultHidden: narrow, + }, + assign: { + title: "", + label: localize("ui.panel.config.devices.unassigned.assign"), + type: "overflow-menu", + moveable: false, + showNarrow: true, + minWidth: "150px", + template: (device) => html` + + `, + }, + }; + + return columns; + }); + + protected render(): TemplateResult { + if (!this.hass || !this.entries || !this.entities) { + return nothing as unknown as TemplateResult; + } + + const devicesOutput = this._unassignedDevices( + this.hass.devices, + this.entries, + this.entities, + this.hass.localize + ); + + return html` + + + `; + } + + private _handleRowClicked(ev: HASSDomEvent) { + const deviceId = ev.detail.id; + navigate(`/config/devices/device/${deviceId}`); + } + + private _handleSearchChange(ev: CustomEvent) { + this._filter = ev.detail.value; + } + + private _assignArea = async (ev: CustomEvent) => { + const areaPicker = ev.currentTarget as any; + const deviceId = areaPicker.dataset.deviceId; + const areaId = ev.detail.value; + + if (!areaId || !deviceId) { + return; + } + + // Reset the picker + areaPicker.value = undefined; + + try { + await updateDeviceRegistryEntry(this.hass, deviceId, { + area_id: areaId, + }); + } catch (err: any) { + showAlertDialog(this, { + text: err.message || "Unknown error", + }); + } + }; + + private _handleSortingChanged(ev: CustomEvent) { + this._activeSorting = ev.detail; + } + + private _handleGroupingChanged(ev: CustomEvent) { + this._activeGrouping = ev.detail.value; + } + + private _handleCollapseChanged(ev: CustomEvent) { + this._activeCollapsed = ev.detail.value; + } + + private _handleColumnsChanged(ev: CustomEvent) { + this._activeColumnOrder = ev.detail.columnOrder; + this._activeHiddenColumns = ev.detail.hiddenColumns; + } + + static get styles(): CSSResultGroup { + return [ + css` + :host { + display: block; + } + hass-tabs-subpage-data-table { + --data-table-row-height: 60px; + } + hass-tabs-subpage-data-table.narrow { + --data-table-row-height: 72px; + } + `, + haStyle, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-devices-unassigned": HaConfigDevicesUnassigned; + } +} diff --git a/src/panels/config/devices/ha-config-devices.ts b/src/panels/config/devices/ha-config-devices.ts index 9532d2ba48..119696ca74 100644 --- a/src/panels/config/devices/ha-config-devices.ts +++ b/src/panels/config/devices/ha-config-devices.ts @@ -8,6 +8,7 @@ import { HassRouterPage } from "../../../layouts/hass-router-page"; import type { HomeAssistant } from "../../../types"; import "./ha-config-device-page"; import "./ha-config-devices-dashboard"; +import "./ha-config-devices-unassigned"; @customElement("ha-config-devices") class HaConfigDevices extends HassRouterPage { @@ -26,6 +27,10 @@ class HaConfigDevices extends HassRouterPage { tag: "ha-config-devices-dashboard", cache: true, }, + unassigned: { + tag: "ha-config-devices-unassigned", + cache: true, + }, device: { tag: "ha-config-device-page", }, diff --git a/src/translations/en.json b/src/translations/en.json index 736384f565..047524481f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5494,6 +5494,12 @@ } } }, + "unassigned": { + "caption": "Unassigned devices", + "search": "Search {number} unassigned {number, plural,\n one {device}\n other {devices}\n}", + "no_devices": "All devices are assigned to areas", + "assign": "Assign to area" + }, "esphome": { "show_encryption_key": "Show encryption key", "encryption_key_title": "ESPHome Encryption Key",