Compare commits

..
12 changed files with 291 additions and 94 deletions
+101 -33
View File
@@ -32,8 +32,11 @@ import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import "../animation/ha-fade-in";
import "../ha-checkbox";
import type { HaCheckbox } from "../ha-checkbox";
import "../skeleton/ha-skeleton-icon";
import "../skeleton/ha-skeleton-text";
import "../ha-svg-icon";
import "../input/ha-input-search";
import { filterData, sortData } from "./sort-filter";
@@ -111,6 +114,25 @@ export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
// Default row height, used to fill the viewport with skeleton rows.
const ROW_HEIGHT = 52;
const cellClasses = (column: DataTableColumnData) => ({
"mdc-data-table__cell--flex": column.type === "flex",
"mdc-data-table__cell--numeric": column.type === "numeric",
"mdc-data-table__cell--icon": column.type === "icon",
"mdc-data-table__cell--icon-button": column.type === "icon-button",
"mdc-data-table__cell--overflow-menu": column.type === "overflow-menu",
"mdc-data-table__cell--overflow": column.type === "overflow",
forceLTR: Boolean(column.forceLTR),
});
const cellStyles = (column: DataTableColumnData) => ({
minWidth: column.minWidth,
maxWidth: column.maxWidth,
flex: column.flex || 1,
});
@customElement("ha-data-table")
export class HaDataTable extends LitElement {
@state()
@@ -520,20 +542,79 @@ export class HaDataTable extends LitElement {
</div>
${
!this._filteredData?.length
? html`
<div class="mdc-data-table__content">
<div class="mdc-data-table__row" role="row">
<div
class="mdc-data-table__cell grows center"
role="cell"
>
${
this.loading ||
!this._filteredData ||
(this.data.length && !this._filteredDataSourceLength)
? this._i18n?.localize?.("ui.common.loading") ||
? this.loading ||
!this._filteredData ||
(this.data.length && !this._filteredDataSourceLength)
? html`
<div class="mdc-data-table__content" role="row">
<ha-fade-in .duration=${300} easing="ease-in">
<div role="cell">
<div
role="progressbar"
aria-label=${
this._i18n?.localize?.("ui.common.loading") ||
"Loading"
: this.data.length
}
>
${Array.from(
{
length: this.autoHeight
? 1
: Math.ceil(window.innerHeight / ROW_HEIGHT),
},
() => html`
<div class="mdc-data-table__row">
${
this.selectable
? html`<div
class="mdc-data-table__cell mdc-data-table__cell--checkbox"
></div>`
: nothing
}
${Object.entries(columns).map(
([key, column]) =>
(this.narrow &&
!column.main &&
!column.showNarrow) ||
!this._isColumnVisible(key, column)
? nothing
: html`
<div
class="mdc-data-table__cell ${classMap(
cellClasses(column)
)}"
style=${styleMap(cellStyles(column))}
>
${
column.type === "icon"
? html`<ha-skeleton-icon></ha-skeleton-icon>`
: column.type ===
"icon-button" ||
column.type ===
"overflow-menu"
? nothing
: html`<ha-skeleton-text></ha-skeleton-text>`
}
</div>
`
)}
</div>
`
)}
</div>
</div>
</ha-fade-in>
</div>
`
: html`
<div class="mdc-data-table__content">
<div class="mdc-data-table__row" role="row">
<div
class="mdc-data-table__cell grows center"
role="cell"
>
${
this.data.length
? this._i18n?.localize?.(
"ui.components.data-table.no_match_filter"
) || "No rows matching current filters"
@@ -542,11 +623,11 @@ export class HaDataTable extends LitElement {
"ui.components.data-table.no-data"
) ||
"No data"
}
}
</div>
</div>
</div>
</div>
`
`
: html`
<lit-virtualizer
scroller
@@ -640,22 +721,8 @@ export class HaDataTable extends LitElement {
@mouseover=${this._setTitle}
@focus=${this._setTitle}
role=${column.main ? "rowheader" : "cell"}
class="mdc-data-table__cell ${classMap({
"mdc-data-table__cell--flex": column.type === "flex",
"mdc-data-table__cell--numeric": column.type === "numeric",
"mdc-data-table__cell--icon": column.type === "icon",
"mdc-data-table__cell--icon-button":
column.type === "icon-button",
"mdc-data-table__cell--overflow-menu":
column.type === "overflow-menu",
"mdc-data-table__cell--overflow": column.type === "overflow",
forceLTR: Boolean(column.forceLTR),
})}"
style=${styleMap({
minWidth: column.minWidth,
maxWidth: column.maxWidth,
flex: column.flex || 1,
})}
class="mdc-data-table__cell ${classMap(cellClasses(column))}"
style=${styleMap(cellStyles(column))}
>
${
column.template
@@ -1330,7 +1397,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__cell--icon:first-child ha-svg-icon,
.mdc-data-table__cell--icon:first-child ha-state-icon,
.mdc-data-table__cell--icon:first-child ha-domain-icon,
.mdc-data-table__cell--icon:first-child ha-service-icon {
.mdc-data-table__cell--icon:first-child ha-service-icon,
.mdc-data-table__cell--icon:first-child ha-skeleton-icon {
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
@@ -37,6 +37,8 @@ export class HaConfigApplicationCredentials extends LitElement {
@state() public _applicationCredentials: ApplicationCredential[] = [];
@state() private _loading = true;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@@ -154,6 +156,7 @@ export class HaConfigApplicationCredentials extends LitElement {
back-path="/config"
.tabs=${configSections.devices}
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._getApplicationCredentials(
this._applicationCredentials,
this.hass.localize
@@ -278,7 +281,13 @@ export class HaConfigApplicationCredentials extends LitElement {
}
private async _fetchApplicationCredentials() {
this._applicationCredentials = await fetchApplicationCredentials(this.hass);
try {
this._applicationCredentials = await fetchApplicationCredentials(
this.hass
);
} finally {
this._loading = false;
}
}
private _addApplicationCredential() {
@@ -123,8 +123,6 @@ const RATING_ICON = {
8: mdiNumeric8,
};
const MAX_RATING = 8;
const POLL_INTERVAL_SECONDS = 5;
@customElement("supervisor-app-info")
@@ -1092,8 +1090,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
`ui.panel.config.apps.dashboard.capability.${id}.title` as LocalizeKeys
),
text: this.i18n.localize(
`ui.panel.config.apps.dashboard.capability.${id}.description`,
{ max: MAX_RATING }
`ui.panel.config.apps.dashboard.capability.${id}.description`
),
});
}
@@ -15,6 +15,7 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeFloorName } from "../../../common/entity/compute_floor_name";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
@@ -42,6 +43,8 @@ import type {
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-alert";
import "../../../components/skeleton/ha-skeleton-icon";
import "../../../components/skeleton/ha-skeleton-text";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -115,13 +118,15 @@ export class HaConfigDeviceDashboard extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public entries!: ConfigEntry[];
@property({ attribute: false }) public entries?: ConfigEntry[];
@property({ attribute: false }) public entriesFailed = false;
@state() private _subEntries?: SubEntry[];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
entities: EntityRegistryEntry[] = [];
entities?: EntityRegistryEntry[];
@property({ attribute: false }) public manifests!: IntegrationManifest[];
@@ -238,13 +243,13 @@ export class HaConfigDeviceDashboard extends LitElement {
this._filters = this._storageFilters;
this._setFiltersFromUrl();
}
if (changedProps.has("_selected")) {
if (changedProps.has("_selected") || changedProps.has("entries")) {
this._selectedCanDelete = this._selected.filter((d) => {
const device = this.hass.devices[d];
const entries = device.config_entries;
return entries.some(
(entryId) =>
this.entries.find((e) => e.entry_id === entryId)
this.entries?.find((e) => e.entry_id === entryId)
?.supports_remove_device
);
});
@@ -307,6 +312,10 @@ export class HaConfigDeviceDashboard extends LitElement {
};
}
private _reloadConfigEntries() {
fireEvent(this, "reload-config-entries");
}
private _clearFilter() {
this._filters = {};
if (!this._fromUrl) {
@@ -317,8 +326,9 @@ export class HaConfigDeviceDashboard extends LitElement {
private _devicesAndFilterDomains = memoizeOne(
(
devices: HomeAssistant["devices"],
entries: ConfigEntry[],
entities: EntityRegistryEntry[],
entries: ConfigEntry[] | undefined,
entriesFailed: boolean,
entities: EntityRegistryEntry[] = [],
areas: HomeAssistant["areas"],
manifests: IntegrationManifest[],
filters: DataTableFilters,
@@ -346,7 +356,8 @@ export class HaConfigDeviceDashboard extends LitElement {
}
const entryLookup: Record<string, ConfigEntry> = {};
for (const entry of entries) {
for (const entry of entries ?? []) {
entryLookup[entry.entry_id] = entry;
}
@@ -371,7 +382,7 @@ export class HaConfigDeviceDashboard extends LitElement {
)
);
const configEntries = entries.filter(
const configEntries = (entries ?? []).filter(
(entry) =>
entry.entry_id &&
(filter.value as string[]).includes(entry.entry_id)
@@ -412,7 +423,7 @@ export class HaConfigDeviceDashboard extends LitElement {
Array.isArray(filter.value) &&
filter.value.length
) {
const entryIds = entries
const entryIds = (entries ?? [])
.filter((entry) =>
(filter.value as string[]).includes(entry.domain)
)
@@ -531,16 +542,21 @@ export class HaConfigDeviceDashboard extends LitElement {
`<${localize("ui.panel.config.devices.data_table.unknown")}>`,
area: areaName,
floor: floorName,
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"
),
integration: !entries
? localize("ui.common.loading")
: entriesFailed
? `<${localize("ui.panel.config.devices.data_table.unknown")}>`
: 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),
parent_device_name: parentDevice
? computeDeviceNameDisplay(
@@ -599,21 +615,23 @@ export class HaConfigDeviceDashboard extends LitElement {
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,
},
this.hass.auth.data.hassUrl
)}
/>`
: "",
!this.entries
? html`<ha-skeleton-icon></ha-skeleton-icon>`
: device.domains.length
? html`<img
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: device.domains[0],
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: "",
},
name: {
title: localize("ui.panel.config.devices.data_table.device"),
@@ -642,7 +660,9 @@ export class HaConfigDeviceDashboard extends LitElement {
.labels=${device.label_entries}
></ha-data-table-labels>
`
: nothing
: device.labels.length && !this._labels
? html`<ha-skeleton-text></ha-skeleton-text>`
: nothing
}
`,
},
@@ -654,6 +674,10 @@ export class HaConfigDeviceDashboard extends LitElement {
filterable: true,
groupable: true,
minWidth: "120px",
template: (device) =>
!this.entries
? html`<ha-skeleton-text></ha-skeleton-text>`
: device.integration,
},
device_family_name: {
title: localize("ui.panel.config.devices.data_table.parent_device"),
@@ -697,6 +721,9 @@ export class HaConfigDeviceDashboard extends LitElement {
minWidth: "101px",
valueColumn: "battery_level",
template: (device) => {
if (!this.entities) {
return html`<ha-skeleton-text></ha-skeleton-text>`;
}
const batteryEntityPair = device.battery_entity;
const battery =
batteryEntityPair && batteryEntityPair[0]
@@ -831,6 +858,7 @@ export class HaConfigDeviceDashboard extends LitElement {
const { devicesOutput } = this._devicesAndFilterDomains(
this.hass.devices,
this.entries,
this.entriesFailed,
this.entities,
this.hass.areas,
this.manifests,
@@ -895,6 +923,22 @@ export class HaConfigDeviceDashboard extends LitElement {
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.devices.add_device")}
</ha-button>
${
this.entriesFailed
? html`<ha-alert slot="top-header" alert-type="error">
${this.hass.localize(
"ui.panel.config.devices.config_entries_load_failed"
)}
<ha-button
slot="action"
appearance="plain"
@click=${this._reloadConfigEntries}
>
${this.hass.localize("ui.panel.config.devices.retry")}
</ha-button>
</ha-alert>`
: nothing
}
${
Array.isArray(this._filters.config_entry?.value) &&
this._filters.config_entry?.value.length
@@ -903,10 +947,13 @@ export class HaConfigDeviceDashboard extends LitElement {
"ui.panel.config.devices.filtering_by_config_entry"
)}
${
this.entries?.find(
(entry) =>
entry.entry_id === this._filters.config_entry!.value![0]
)?.title || this._filters.config_entry.value[0]
!this.entries
? html`<ha-skeleton-text></ha-skeleton-text>`
: this.entries.find(
(entry) =>
entry.entry_id ===
this._filters.config_entry!.value![0]
)?.title || this._filters.config_entry.value[0]
}${
this._filters.config_entry.value.length === 1 &&
Array.isArray(this._filters.sub_entry?.value) &&
@@ -1118,6 +1165,7 @@ export class HaConfigDeviceDashboard extends LitElement {
this._devicesAndFilterDomains(
this.hass.devices,
this.entries,
this.entriesFailed,
this.entities,
this.hass.areas,
this.manifests,
@@ -1357,6 +1405,13 @@ ${rejected
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
}
ha-alert[slot="top-header"] {
display: block;
margin: var(--ha-space-2) var(--ha-space-4);
}
ha-alert ha-skeleton-text {
--ha-skeleton-text-width: 100px;
}
ha-dropdown::part(menu),
ha-dropdown::part(submenu) {
--auto-size-available-width: calc(50vw - var(--ha-space-4));
+34 -8
View File
@@ -31,7 +31,9 @@ class HaConfigDevices extends HassRouterPage {
},
};
@state() private _configEntries: ConfigEntry[] = [];
@state() private _configEntries?: ConfigEntry[];
@state() private _configEntriesFailed = false;
@state() private _manifests: IntegrationManifest[] = [];
@@ -39,6 +41,9 @@ class HaConfigDevices extends HassRouterPage {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this.addEventListener("reload-config-entries", () =>
this._loadConfigEntries()
);
this._loadData();
}
}
@@ -48,9 +53,12 @@ class HaConfigDevices extends HassRouterPage {
if (this._currentPage === "device") {
pageEl.deviceId = this.routeTail.path.substr(1);
pageEl.entries = this._configEntries ?? [];
} else {
pageEl.entries = this._configEntries;
pageEl.entriesFailed = this._configEntriesFailed;
}
pageEl.entries = this._configEntries;
pageEl.manifests = this._manifests;
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
@@ -59,17 +67,35 @@ class HaConfigDevices extends HassRouterPage {
private async _loadData() {
await Promise.all([
getConfigEntries(this.hass).then((configEntries) => {
this._configEntries = configEntries;
}),
fetchIntegrationManifests(this.hass).then((manifests) => {
this._manifests = manifests;
}),
this._loadConfigEntries(),
fetchIntegrationManifests(this.hass)
.then((manifests) => {
this._manifests = manifests;
})
.catch(() => {
// The pages remain usable without integration manifests.
}),
]);
}
private async _loadConfigEntries() {
this._configEntriesFailed = false;
this._configEntries = undefined;
try {
this._configEntries = await getConfigEntries(this.hass);
} catch {
this._configEntriesFailed = true;
this._configEntries = [];
}
}
}
declare global {
interface HASSDomEvents {
"reload-config-entries": undefined;
}
interface HTMLElementTagNameMap {
"ha-config-devices": HaConfigDevices;
}
@@ -38,6 +38,8 @@ class HaConfigHardwareAll extends LitElement {
@state() private _error?: string;
@state() private _loading = true;
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer<HardwareDeviceRow> => ({
name: {
@@ -98,6 +100,7 @@ class HaConfigHardwareAll extends LitElement {
.tabs=${hardwareTabs(this.hass)}
clickable
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._hardware ? this._data(this._hardware) : []}
.noDataText=${
this._error ||
@@ -113,6 +116,8 @@ class HaConfigHardwareAll extends LitElement {
this._hardware = await fetchHassioHardwareInfo(this.hass);
} catch (err: any) {
this._error = extractApiErrorMessage(err);
} finally {
this._loading = false;
}
}
@@ -45,6 +45,8 @@ export class ZHAGroupsDashboard extends LitElement {
@state() private _groups: ZHAGroup[] = [];
@state() private _loading = true;
private _firstUpdatedCalled = false;
public connectedCallback(): void {
@@ -114,6 +116,7 @@ export class ZHAGroupsDashboard extends LitElement {
.narrow=${this.narrow}
.route=${this.route}
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._formattedGroups(this._groups)}
@row-click=${this._handleRowClicked}
clickable
@@ -128,7 +131,11 @@ export class ZHAGroupsDashboard extends LitElement {
}
private async _fetchGroups() {
this._groups = (await fetchGroups(this.hass!)).sort(sortZHAGroups);
try {
this._groups = (await fetchGroups(this.hass!)).sort(sortZHAGroups);
} finally {
this._loading = false;
}
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
@@ -30,6 +30,8 @@ class ZWaveJSProvisioned extends LitElement {
@state() private _provisioningEntries: ZwaveJSProvisioningEntry[] = [];
@state() private _loading = true;
@state() private _nodeIdToDevice: Record<number, DeviceRegistryEntry> = {};
protected render() {
@@ -50,6 +52,7 @@ class ZWaveJSProvisioned extends LitElement {
this.configEntryId
}"
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._getData(this._provisioningEntries, this._nodeIdToDevice)}
>
</hass-tabs-subpage-data-table>
@@ -176,10 +179,14 @@ class ZWaveJSProvisioned extends LitElement {
}
private async _fetchProvisioningEntries() {
this._provisioningEntries = await fetchZwaveProvisioningEntries(
this.hass!,
this.configEntryId
);
try {
this._provisioningEntries = await fetchZwaveProvisioningEntries(
this.hass!,
this.configEntryId
);
} finally {
this._loading = false;
}
}
private _unprovision = async (ev) => {
@@ -101,6 +101,8 @@ export class HaConfigLovelaceDashboards extends LitElement {
@state() private _dashboards: LovelaceDashboard[] = [];
@state() private _loading = true;
@state()
@storage({
storage: "sessionStorage",
@@ -409,6 +411,7 @@ export class HaConfigLovelaceDashboards extends LitElement {
this._dashboards,
this.hass.localize
)}
.loading=${this._loading}
.data=${this._getItems(
this._dashboards,
defaultPanel,
@@ -476,7 +479,11 @@ export class HaConfigLovelaceDashboards extends LitElement {
}
private async _getDashboards() {
this._dashboards = await fetchDashboards(this.hass);
try {
this._dashboards = await fetchDashboards(this.hass);
} finally {
this._loading = false;
}
}
private _handleRowClicked(ev: CustomEvent) {
+8 -1
View File
@@ -58,6 +58,8 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
@state() private _tags: Tag[] = [];
@state() private _loading = true;
private get _canWriteTags() {
return this.hass.auth.external?.config.canWriteTag;
}
@@ -195,6 +197,7 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
.route=${this.route}
.tabs=${configSections.tags}
.columns=${this._columns(this.hass.localize)}
.loading=${this._loading}
.data=${this._data(this._tags)}
.noDataText=${this.hass.localize("ui.panel.config.tag.no_tags")}
.filter=${this._filter}
@@ -266,7 +269,11 @@ export class HaConfigTags extends SubscribeMixin(LitElement) {
}
private async _fetchTags() {
this._tags = await fetchTags(this.hass);
try {
this._tags = await fetchTags(this.hass);
} finally {
this._loading = false;
}
}
private _openWrite(tag: Tag) {
+8 -1
View File
@@ -40,6 +40,8 @@ export class HaConfigUsers extends LitElement {
@state() private _users: User[] = [];
@state() private _loading = true;
@storage({ key: "users-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@@ -179,6 +181,7 @@ export class HaConfigUsers extends LitElement {
back-path="/config"
.tabs=${configSections.persons}
.columns=${this._columns(this.narrow, this.hass.localize)}
.loading=${this._loading}
.data=${this._userData(this._users, this.hass.localize)}
.columnOrder=${this._activeColumnOrder}
.hiddenColumns=${this._activeHiddenColumns}
@@ -212,7 +215,11 @@ export class HaConfigUsers extends LitElement {
);
private async _fetchUsers() {
this._users = await fetchUsers(this.hass);
try {
this._users = await fetchUsers(this.hass);
} finally {
this._loading = false;
}
this._users.forEach((user) => {
if (user.is_owner) {
+3 -1
View File
@@ -3143,7 +3143,7 @@
},
"rating": {
"title": "Security rating",
"description": "This shows the security rating of the app on a scale from 1 to {max}. Higher is better."
"description": "This shows the security rating of the app. Higher is better."
},
"host_network": {
"title": "Host network",
@@ -6948,6 +6948,8 @@
"caption": "Devices",
"description": "Manage configured devices",
"filtering_by_config_entry": "[%key:ui::panel::config::entities::picker::filtering_by_config_entry%]",
"config_entries_load_failed": "Unable to load integrations for your devices.",
"retry": "[%key:ui::panel::app::retry%]",
"device_info": "{type} info",
"edit_settings": "Edit settings",
"restore_entity_ids": "Recreate entity IDs",