1
0
mirror of https://github.com/home-assistant/frontend.git synced 2025-12-20 02:38:53 +00:00

Create unassigned devices panel

This commit is contained in:
Paul Bottein
2025-12-11 16:21:59 +01:00
parent 253b49871e
commit 3a5175781d
5 changed files with 397 additions and 1 deletions

View File

@@ -20,6 +20,7 @@ import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail"; import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-button";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import "./ha-generic-picker"; import "./ha-generic-picker";
import type { HaGenericPicker } from "./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({ attribute: "add-button-label" }) public addButtonLabel?: string;
@property({ type: Boolean, attribute: "button-style" })
public buttonStyle = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker; @query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() { public async open() {
@@ -390,10 +394,26 @@ export class HaAreaPicker extends LitElement {
)} )}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
${this.buttonStyle
? html`<ha-button
slot="field"
.disabled=${this.disabled}
@click=${this._openPicker}
appearance="plain"
size="small"
>
${placeholder}
</ha-button>`
: nothing}
</ha-generic-picker> </ha-generic-picker>
`; `;
} }
private _openPicker(ev: Event) {
ev.stopPropagation();
this._picker?.open();
}
private _valueChanged(ev: ValueChangedEvent<string>) { private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
const value = ev.detail.value; const value = ev.detail.value;

View File

@@ -795,7 +795,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
.narrow=${this.narrow} .narrow=${this.narrow}
.backPath=${this._searchParms.has("historyBack") .backPath=${this._searchParms.has("historyBack")
? undefined ? undefined
: "/config"} : "/config/devices"}
.tabs=${configSections.devices} .tabs=${configSections.devices}
.route=${this.route} .route=${this.route}
.searchLabel=${this.hass.localize( .searchLabel=${this.hass.localize(

View File

@@ -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<EntityRegistryEntry> = {};
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<string, ConfigEntry> = {};
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<typeof this._unassignedDevices>[number];
const columns: DataTableColumnContainer<DeviceItem> = {
icon: {
title: "",
label: localize("ui.panel.config.devices.data_table.icon"),
type: "icon",
moveable: false,
showNarrow: true,
template: (device) =>
device.domains.length
? html`<img
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: device.domains[0],
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
/>`
: "",
},
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`
<ha-area-picker
.hass=${this.hass}
data-device-id=${device.id}
.placeholder=${localize(
"ui.panel.config.devices.unassigned.assign"
)}
no-add
button-style
@value-changed=${this._assignArea}
></ha-area-picker>
`,
},
};
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`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/devices/dashboard"
.tabs=${TABS}
.route=${this.route}
.searchLabel=${this.hass.localize(
"ui.panel.config.devices.unassigned.search",
{ number: devicesOutput.length }
)}
.columns=${this._columns(this.hass.localize, this.narrow)}
.data=${devicesOutput}
.filter=${this._filter}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@columns-changed=${this._handleColumnsChanged}
@search-changed=${this._handleSearchChange}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@row-click=${this._handleRowClicked}
clickable
class=${this.narrow ? "narrow" : ""}
.noDataText=${this.hass.localize(
"ui.panel.config.devices.unassigned.no_devices"
)}
>
</hass-tabs-subpage-data-table>
`;
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
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;
}
}

View File

@@ -8,6 +8,7 @@ import { HassRouterPage } from "../../../layouts/hass-router-page";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import "./ha-config-device-page"; import "./ha-config-device-page";
import "./ha-config-devices-dashboard"; import "./ha-config-devices-dashboard";
import "./ha-config-devices-unassigned";
@customElement("ha-config-devices") @customElement("ha-config-devices")
class HaConfigDevices extends HassRouterPage { class HaConfigDevices extends HassRouterPage {
@@ -26,6 +27,10 @@ class HaConfigDevices extends HassRouterPage {
tag: "ha-config-devices-dashboard", tag: "ha-config-devices-dashboard",
cache: true, cache: true,
}, },
unassigned: {
tag: "ha-config-devices-unassigned",
cache: true,
},
device: { device: {
tag: "ha-config-device-page", tag: "ha-config-device-page",
}, },

View File

@@ -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": { "esphome": {
"show_encryption_key": "Show encryption key", "show_encryption_key": "Show encryption key",
"encryption_key_title": "ESPHome Encryption Key", "encryption_key_title": "ESPHome Encryption Key",