Add category and filters to helpers (#20346)

* Add category and filters to helpers

* Add support for adding label and category in multi select

* remove labels multi
This commit is contained in:
Bram Kragten 2024-04-03 13:22:40 +02:00 committed by GitHub
parent 578d3c4260
commit ccde9cceee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 460 additions and 63 deletions

View File

@ -28,6 +28,7 @@ export type ItemType =
| "entity" | "entity"
| "floor" | "floor"
| "group" | "group"
| "label"
| "scene" | "scene"
| "script" | "script"
| "automation_blueprint" | "automation_blueprint"

View File

@ -1,5 +1,15 @@
import { consume } from "@lit-labs/context";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; 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 { HassEntity } from "home-assistant-js-websocket";
import { import {
CSSResultGroup, CSSResultGroup,
@ -11,8 +21,9 @@ import {
nothing, nothing,
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { consume } from "@lit-labs/context";
import memoizeOne from "memoize-one"; 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 { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { import {
@ -23,22 +34,42 @@ import { extractSearchParam } from "../../../common/url/search-params";
import { import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SelectionChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels"; import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-fab"; 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";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-state-icon"; import "../../../components/ha-state-icon";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import {
CategoryRegistryEntry,
createCategoryRegistryEntry,
subscribeCategoryRegistry,
} from "../../../data/category_registry";
import { import {
ConfigEntry, ConfigEntry,
subscribeConfigEntries, subscribeConfigEntries,
} from "../../../data/config_entries"; } from "../../../data/config_entries";
import { getConfigFlowHandlers } from "../../../data/config_flow"; import { getConfigFlowHandlers } from "../../../data/config_flow";
import { fullEntitiesContext } from "../../../data/context";
import { import {
EntityRegistryEntry, EntityRegistryEntry,
UpdateEntityRegistryEntryResult,
subscribeEntityRegistry, subscribeEntityRegistry,
updateEntityRegistryEntry,
} from "../../../data/entity_registry"; } from "../../../data/entity_registry";
import { domainToName } from "../../../data/integration"; import { domainToName } from "../../../data/integration";
import {
LabelRegistryEntry,
createLabelRegistryEntry,
subscribeLabelRegistry,
} from "../../../data/label_registry";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import { import {
@ -49,18 +80,15 @@ import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info
import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage-data-table"; import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu"; import "../integrations/ha-integration-overflow-menu";
import { isHelperDomain } from "./const"; import { isHelperDomain } from "./const";
import { showHelperDetailDialog } from "./show-dialog-helper-detail"; import { showHelperDetailDialog } from "./show-dialog-helper-detail";
import { import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
LabelRegistryEntry, import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
subscribeLabelRegistry,
} from "../../../data/label_registry";
import { fullEntitiesContext } from "../../../data/context";
import "../../../components/ha-filter-labels";
import { haStyle } from "../../../resources/styles";
type HelperItem = { type HelperItem = {
id: string; id: string;
@ -71,6 +99,7 @@ type HelperItem = {
type: string; type: string;
configEntry?: ConfigEntry; configEntry?: ConfigEntry;
entity?: HassEntity; entity?: HassEntity;
category: string | undefined;
label_entries: LabelRegistryEntry[]; label_entries: LabelRegistryEntry[];
}; };
@ -111,6 +140,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@state() private _configEntries?: Record<string, ConfigEntry>; @state() private _configEntries?: Record<string, ConfigEntry>;
@state() private _selected: string[] = [];
@state() private _activeFilters?: string[]; @state() private _activeFilters?: string[];
@state() private _filters: Record< @state() private _filters: Record<
@ -120,6 +151,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@state() private _expandedFilter?: string; @state() private _expandedFilter?: string;
@state()
_categories!: CategoryRegistryEntry[];
@state() @state()
_labels!: LabelRegistryEntry[]; _labels!: LabelRegistryEntry[];
@ -156,12 +190,21 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
subscribeLabelRegistry(this.hass.connection, (labels) => { subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels; this._labels = labels;
}), }),
subscribeCategoryRegistry(
this.hass.connection,
"helpers",
(categories) => {
this._categories = categories;
}
),
]; ];
} }
private _columns = memoizeOne( private _columns = memoizeOne(
(narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => { (
const columns: DataTableColumnContainer<HelperItem> = { narrow: boolean,
localize: LocalizeFunc
): DataTableColumnContainer<HelperItem> => ({
icon: { icon: {
title: "", title: "",
label: localize("ui.panel.config.helpers.picker.headers.icon"), label: localize("ui.panel.config.helpers.picker.headers.icon"),
@ -198,23 +241,35 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
: nothing} : nothing}
`, `,
}, },
}; entity_id: {
if (!narrow) {
columns.entity_id = {
title: localize("ui.panel.config.helpers.picker.headers.entity_id"), title: localize("ui.panel.config.helpers.picker.headers.entity_id"),
hidden: this.narrow,
sortable: true, sortable: true,
filterable: true, filterable: true,
width: "25%", width: "25%",
}; },
} category: {
columns.localized_type = { 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"), title: localize("ui.panel.config.helpers.picker.headers.type"),
sortable: true, sortable: true,
width: "25%", width: "25%",
filterable: true, filterable: true,
groupable: true, groupable: true,
}; },
columns.editable = { editable: {
title: "", title: "",
label: this.hass.localize( label: this.hass.localize(
"ui.panel.config.helpers.picker.headers.editable" "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`
<ha-icon-overflow-menu
.hass=${this.hass}
narrow
.items=${[
{
path: mdiCog,
label: this.hass.localize(
"ui.panel.config.automation.picker.show_settings"
),
action: () => this._openSettings(helper),
},
{
path: mdiTag,
label: this.hass.localize(
`ui.panel.config.automation.picker.${helper.category ? "edit_category" : "assign_category"}`
),
action: () => this._editCategory(helper),
},
]}
>
</ha-icon-overflow-menu>
`,
},
})
); );
private _getItems = memoizeOne( private _getItems = memoizeOne(
@ -249,6 +331,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
entityEntries: Record<string, EntityRegistryEntry>, entityEntries: Record<string, EntityRegistryEntry>,
configEntries: Record<string, ConfigEntry>, configEntries: Record<string, ConfigEntry>,
entityReg: EntityRegistryEntry[], entityReg: EntityRegistryEntry[],
categoryReg?: CategoryRegistryEntry[],
labelReg?: LabelRegistryEntry[], labelReg?: LabelRegistryEntry[],
filteredStateItems?: string[] | null filteredStateItems?: string[] | null
): HelperItem[] => { ): HelperItem[] => {
@ -305,6 +388,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
(reg) => reg.entity_id === item.entity_id (reg) => reg.entity_id === item.entity_id
); );
const labels = labelReg && entityRegEntry?.labels; const labels = labelReg && entityRegEntry?.labels;
const category = entityRegEntry?.categories.helpers;
return { return {
...item, ...item,
localized_type: item.configEntry localized_type: item.configEntry
@ -315,6 +399,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
label_entries: (labels || []).map( label_entries: (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)! (lbl) => labelReg!.find((label) => label.label_id === lbl)!
), ),
category: category
? categoryReg?.find((cat) => cat.category_id === category)?.name
: undefined,
}; };
}); });
} }
@ -330,6 +417,66 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
return html` <hass-loading-screen></hass-loading-screen> `; return html` <hass-loading-screen></hass-loading-screen> `;
} }
const categoryItems = html`${this._categories?.map(
(category) =>
html`<ha-menu-item
.value=${category.category_id}
@click=${this._handleBulkCategory}
>
${category.icon
? html`<ha-icon slot="start" .icon=${category.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiTag}></ha-svg-icon>`}
<div slot="headline">${category.name}</div>
</ha-menu-item>`
)}
<ha-menu-item .value=${null} @click=${this._handleBulkCategory}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.no_category"
)}
</div>
</ha-menu-item>
<md-divider role="separator" tabindex="-1"></md-divider>
<ha-menu-item @click=${this._createCategory}>
<div slot="headline">
${this.hass.localize("ui.panel.config.category.editor.add")}
</div>
</ha-menu-item>`;
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`<ha-menu-item
.value=${label.label_id}
.action=${selected ? "remove" : "add"}
@click=${this._handleBulkLabel}
>
<ha-checkbox
slot="start"
.checked=${selected}
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label style=${color ? `--color: ${color}` : ""}>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
${label.name}
</ha-label>
</ha-menu-item> `;
})}<md-divider role="separator" tabindex="-1"></md-divider>
<ha-menu-item @click=${this._createLabel}>
<div slot="headline">
${this.hass.localize("ui.panel.config.labels.add_label")}
</div>
</ha-menu-item>`;
return html` return html`
<hass-tabs-subpage-data-table <hass-tabs-subpage-data-table
.hass=${this.hass} .hass=${this.hass}
@ -337,6 +484,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
back-path="/config" back-path="/config"
.route=${this.route} .route=${this.route}
.tabs=${configSections.devices} .tabs=${configSections.devices}
selectable
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
hasFilters hasFilters
.filters=${Object.values(this._filters).filter( .filters=${Object.values(this._filters).filter(
(filter) => filter.value?.length (filter) => filter.value?.length
@ -348,9 +498,11 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
this._entityEntries, this._entityEntries,
this._configEntries, this._configEntries,
this._entityReg, this._entityReg,
this._categories,
this._labels, this._labels,
this._filteredStateItems this._filteredStateItems
)} )}
initialGroupColumn="category"
.activeFilters=${this._activeFilters} .activeFilters=${this._activeFilters}
@clear-filter=${this._clearFilter} @clear-filter=${this._clearFilter}
@row-click=${this._openEditDialog} @row-click=${this._openEditDialog}
@ -361,6 +513,26 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
)} )}
class=${this.narrow ? "narrow" : ""} class=${this.narrow ? "narrow" : ""}
> >
<ha-filter-floor-areas
.hass=${this.hass}
.type=${"entity"}
.value=${this._filters["ha-filter-floor-areas"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-floor-areas"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-floor-areas>
<ha-filter-devices
.hass=${this.hass}
.type=${"entity"}
.value=${this._filters["ha-filter-devices"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-devices"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-devices>
<ha-filter-labels <ha-filter-labels
.hass=${this.hass} .hass=${this.hass}
.value=${this._filters["ha-filter-labels"]?.value} .value=${this._filters["ha-filter-labels"]?.value}
@ -370,6 +542,114 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
.narrow=${this.narrow} .narrow=${this.narrow}
@expanded-changed=${this._filterExpanded} @expanded-changed=${this._filterExpanded}
></ha-filter-labels> ></ha-filter-labels>
<ha-filter-categories
.hass=${this.hass}
scope="helpers"
.value=${this._filters["ha-filter-categories"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-categories"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
${!this.narrow
? html`<ha-button-menu-new slot="selection-bar">
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.move_category"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${categoryItems}
</ha-button-menu-new>
${this.hass.dockedSidebar === "docked"
? nothing
: html`<ha-button-menu-new slot="selection-bar">
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${labelItems}
</ha-button-menu-new>`}`
: nothing}
${this.narrow || this.hass.dockedSidebar === "docked"
? html`
<ha-button-menu-new has-overflow slot="selection-bar">
${
this.narrow
? html`<ha-assist-chip
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_action"
)}
slot="trigger"
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>`
: html`<ha-icon-button
.path=${mdiDotsVertical}
.label=${"ui.panel.config.automation.picker.bulk_action"}
slot="trigger"
></ha-icon-button>`
}
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon
></ha-assist-chip>
${
this.narrow
? html`<ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.move_category"
)}
</div>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-menu-item>
<ha-menu slot="menu">${categoryItems}</ha-menu>
</ha-sub-menu>`
: nothing
}
${
this.narrow || this.hass.dockedSidebar === "docked"
? html` <ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
</div>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-menu-item>
<ha-menu slot="menu">${labelItems}</ha-menu>
</ha-sub-menu>`
: nothing
}
</ha-button-menu-new>`
: nothing}
<ha-integration-overflow-menu <ha-integration-overflow-menu
.hass=${this.hass} .hass=${this.hass}
@ -437,6 +717,27 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
items.intersection(labelItems) items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x))); : new Set([...items].filter((x) => labelItems!.has(x)));
} }
if (key === "ha-filter-categories" && filter.value?.length) {
const categoryItems: Set<string> = 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; this._filteredStateItems = items ? [...items] : undefined;
} }
@ -446,6 +747,65 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
this._applyFilters(); 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<UpdateEntityRegistryEntryResult>[] = [];
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<UpdateEntityRegistryEntryResult>[] = [];
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<SelectionChangedEvent>
): void {
this._selected = ev.detail.value;
}
protected firstUpdated(changedProps: PropertyValues) { protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
if (this.route.path === "/add") { if (this.route.path === "/add") {
@ -563,10 +923,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() { private _createHelper() {
showHelperDetailDialog(this, {}); 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 { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,
@ -577,6 +962,16 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
hass-tabs-subpage-data-table.narrow { hass-tabs-subpage-data-table.narrow {
--data-table-row-height: 72px; --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;
}
`, `,
]; ];
} }

View File

@ -2263,7 +2263,8 @@
"name": "Name", "name": "Name",
"entity_id": "Entity ID", "entity_id": "Entity ID",
"type": "Type", "type": "Type",
"editable": "Editable" "editable": "Editable",
"category": "Category"
}, },
"create_helper": "Create helper", "create_helper": "Create helper",
"no_helpers": "Looks like you don't have any helpers yet!" "no_helpers": "Looks like you don't have any helpers yet!"