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:
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
365
src/panels/config/devices/ha-config-devices-unassigned.ts
Normal file
365
src/panels/config/devices/ha-config-devices-unassigned.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user