Add categories, filtering, grouping to automation panel (#20197)

* Add categories and filtering to automation panel

* Update search-input-outlined.ts

* Update ha-config-entities.ts

* fix resetting area filter

* fixes

* Update ha-category-picker.ts

* Update ha-filter-blueprints.ts

* fix updating badge

* fix overflow issue
This commit is contained in:
Bram Kragten 2024-03-27 15:26:01 +01:00 committed by GitHub
parent 141c8c5192
commit 68935d46ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 3849 additions and 738 deletions

View File

@ -73,6 +73,7 @@ export class HaDemo extends HomeAssistantAppEl {
name: null,
icon: null,
labels: [],
categories: {},
platform: "co2signal",
hidden_by: null,
entity_category: null,
@ -90,6 +91,7 @@ export class HaDemo extends HomeAssistantAppEl {
name: null,
icon: null,
labels: [],
categories: {},
platform: "co2signal",
hidden_by: null,
entity_category: null,

View File

@ -200,6 +200,7 @@ const createEntityRegistryEntries = (
unique_id: "updater",
options: null,
labels: [],
categories: {},
},
];

View File

@ -4,22 +4,32 @@ import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-assist-chip")
// @ts-ignore
export class HaAssistChip extends MdAssistChip {
@property({ type: Boolean, reflect: true }) filled = false;
@property({ type: Boolean }) active = false;
static override styles = [
...super.styles,
css`
:host {
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-assist-chip-container-shape: 16px;
--md-assist-chip-container-shape: var(
--ha-assist-chip-container-shape,
16px
);
--md-assist-chip-outline-color: var(--outline-color);
--md-assist-chip-label-text-weight: 400;
--ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
--ha-assist-chip-active-container-color: rgba(
var(--rgb-primary-color),
0.15
);
}
/** Material 3 doesn't have a filled chip, so we have to make our own **/
.filled {
@ -31,10 +41,21 @@ export class HaAssistChip extends MdAssistChip {
background-color: var(--ha-assist-chip-filled-container-color);
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
::slotted([slot="icon"]),
::slotted([slot="trailingIcon"]) {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
}
.trailing.icon ::slotted(*),
.trailing.icon svg {
margin-inline-end: unset;
margin-inline-start: var(--_icon-label-space);
}
:where(.active)::before {
background: var(--ha-assist-chip-active-container-color);
opacity: var(--ha-assist-chip-active-container-opacity);
}
`,
];
@ -45,6 +66,30 @@ export class HaAssistChip extends MdAssistChip {
return super.renderOutline();
}
protected override getContainerClasses() {
return {
...super.getContainerClasses(),
active: this.active,
};
}
protected override renderPrimaryContent() {
return html`
<span class="leading icon" aria-hidden="true">
${this.renderLeadingIcon()}
</span>
<span class="label">${this.label}</span>
<span class="touch"></span>
<span class="trailing leading icon" aria-hidden="true">
${this.renderTrailingIcon()}
</span>
`;
}
protected renderTrailingIcon() {
return html`<slot name="trailing-icon"></slot>`;
}
}
declare global {

View File

@ -0,0 +1,118 @@
import { css, html, LitElement, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import "../chips/ha-assist-chip";
import { repeat } from "lit/directives/repeat";
import { LabelRegistryEntry } from "../../data/label_registry";
import { computeCssColor } from "../../common/color/compute-color";
import { fireEvent } from "../../common/dom/fire_event";
@customElement("ha-data-table-labels")
class HaDataTableLabels extends LitElement {
@property({ attribute: false }) public labels!: LabelRegistryEntry[];
protected render(): TemplateResult {
return html`
<ha-chip-set>
${repeat(
this.labels.slice(0, 2),
(label) => label.label_id,
(label) => this._renderLabel(label, true)
)}
${this.labels.length > 2
? html`<ha-button-menu
absolute
@click=${this._handleIconOverflowMenuOpened}
@closed=${this._handleIconOverflowMenuClosed}
>
<ha-assist-chip
slot="trigger"
.label=${`+${this.labels.length - 2}`}
></ha-assist-chip>
${repeat(
this.labels.slice(2),
(label) => label.label_id,
(label) =>
html`<ha-list-item
@click=${this._labelClicked}
.item=${label}
>
${this._renderLabel(label, false)}
</ha-list-item>`
)}
</ha-button-menu>`
: nothing}
</ha-chip-set>
`;
}
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
const color = label?.color ? computeCssColor(label.color) : undefined;
return html`<ha-assist-chip
.item=${label}
@click=${clickAction ? this._labelClicked : undefined}
.label=${label?.name}
active
style=${color ? `--color: ${color}` : ""}
>
${label?.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
</ha-assist-chip>`;
}
private _labelClicked(ev: Event) {
const label = (ev.currentTarget as any).item as LabelRegistryEntry;
fireEvent(this, "label-clicked", { label });
}
protected _handleIconOverflowMenuOpened(e) {
e.stopPropagation();
// If this component is used inside a data table, the z-index of the row
// needs to be increased. Otherwise the ha-button-menu would be displayed
// underneath the next row in the table.
const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null;
if (row) {
row.style.zIndex = "1";
}
}
protected _handleIconOverflowMenuClosed() {
const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null;
if (row) {
row.style.zIndex = "";
}
}
static get styles() {
return css`
:host {
display: block;
flex-grow: 1;
margin-top: 4px;
height: 22px;
}
ha-chip-set {
position: fixed;
flex-wrap: nowrap;
}
ha-assist-chip {
border: 1px solid var(--color);
--md-assist-chip-icon-size: 16px;
--md-assist-chip-container-height: 20px;
--md-assist-chip-leading-space: 12px;
--md-assist-chip-trailing-space: 12px;
--ha-assist-chip-active-container-color: var(--color);
--ha-assist-chip-active-container-opacity: 0.3;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-data-table-labels": HaDataTableLabels;
}
interface HASSDomEvents {
"label-clicked": { label: LabelRegistryEntry };
}
}

View File

@ -32,6 +32,7 @@ import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon";
import "../search-input";
import { filterData, sortData } from "./sort-filter";
import { groupBy } from "../../common/util/group-by";
declare global {
// for fire event
@ -67,13 +68,20 @@ export interface DataTableSortColumnData {
filterKey?: string;
valueColumn?: string;
direction?: SortingDirection;
groupable?: boolean;
}
export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
main?: boolean;
title: TemplateResult | string;
label?: TemplateResult | string;
type?: "numeric" | "icon" | "icon-button" | "overflow-menu" | "flex";
type?:
| "numeric"
| "icon"
| "icon-button"
| "overflow"
| "overflow-menu"
| "flex";
template?: (row: T) => TemplateResult | string | typeof nothing;
width?: string;
maxWidth?: string;
@ -95,6 +103,8 @@ export interface SortableColumnContainer {
[key: string]: ClonedDataTableColumnData;
}
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
@customElement("ha-data-table")
export class HaDataTable extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -129,14 +139,16 @@ export class HaDataTable extends LitElement {
@property({ type: String }) public filter = "";
@property() public groupColumn?: string;
@property() public sortColumn?: string;
@property() public sortDirection: SortingDirection = null;
@state() private _filterable = false;
@state() private _filter = "";
@state() private _sortColumn?: string;
@state() private _sortDirection: SortingDirection = null;
@state() private _filteredData: DataTableRowData[] = [];
@state() private _headerHeight = 0;
@ -195,8 +207,14 @@ export class HaDataTable extends LitElement {
for (const columnId in this.columns) {
if (this.columns[columnId].direction) {
this._sortDirection = this.columns[columnId].direction!;
this._sortColumn = columnId;
this.sortDirection = this.columns[columnId].direction!;
this.sortColumn = columnId;
fireEvent(this, "sorting-changed", {
column: columnId,
direction: this.sortDirection,
});
break;
}
}
@ -226,11 +244,16 @@ export class HaDataTable extends LitElement {
properties.has("data") ||
properties.has("columns") ||
properties.has("_filter") ||
properties.has("_sortColumn") ||
properties.has("_sortDirection")
properties.has("sortColumn") ||
properties.has("sortDirection") ||
properties.has("groupColumn")
) {
this._sortFilterData();
}
if (properties.has("selectable")) {
this._items = [...this._items];
}
}
protected render() {
@ -263,6 +286,7 @@ export class HaDataTable extends LitElement {
})}
>
<div class="mdc-data-table__header-row" role="row" aria-rowindex="1">
<slot name="header-row">
${this.selectable
? html`
<div
@ -285,7 +309,7 @@ export class HaDataTable extends LitElement {
if (column.hidden) {
return "";
}
const sorted = key === this._sortColumn;
const sorted = key === this.sortColumn;
const classes = {
"mdc-data-table__header-cell--numeric":
column.type === "numeric",
@ -294,6 +318,8 @@ export class HaDataTable extends LitElement {
column.type === "icon-button",
"mdc-data-table__header-cell--overflow-menu":
column.type === "overflow-menu",
"mdc-data-table__header-cell--overflow":
column.type === "overflow",
sortable: Boolean(column.sortable),
"not-sorted": Boolean(column.sortable && !sorted),
grows: Boolean(column.grows),
@ -311,7 +337,7 @@ export class HaDataTable extends LitElement {
role="columnheader"
aria-sort=${ifDefined(
sorted
? this._sortDirection === "desc"
? this.sortDirection === "desc"
? "descending"
: "ascending"
: undefined
@ -322,7 +348,7 @@ export class HaDataTable extends LitElement {
${column.sortable
? html`
<ha-svg-icon
.path=${sorted && this._sortDirection === "desc"
.path=${sorted && this.sortDirection === "desc"
? mdiArrowDown
: mdiArrowUp}
></ha-svg-icon>
@ -332,6 +358,7 @@ export class HaDataTable extends LitElement {
</div>
`;
})}
</slot>
</div>
${!this._filteredData.length
? html`
@ -408,7 +435,7 @@ export class HaDataTable extends LitElement {
: ""}
${Object.entries(this.columns).map(([key, column]) => {
if (column.hidden) {
return "";
return nothing;
}
return html`
<div
@ -421,6 +448,7 @@ export class HaDataTable extends LitElement {
column.type === "icon-button",
"mdc-data-table__cell--overflow-menu":
column.type === "overflow-menu",
"mdc-data-table__cell--overflow": column.type === "overflow",
grows: Boolean(column.grows),
forceLTR: Boolean(column.forceLTR),
})}"
@ -453,12 +481,12 @@ export class HaDataTable extends LitElement {
);
}
const prom = this._sortColumn
const prom = this.sortColumn
? sortData(
filteredData,
this._sortColumns[this._sortColumn],
this._sortDirection,
this._sortColumn,
this._sortColumns[this.sortColumn],
this.sortDirection,
this.sortColumn,
this.hass.locale.language
)
: filteredData;
@ -477,7 +505,7 @@ export class HaDataTable extends LitElement {
return;
}
if (this.appendRow || this.hasFab) {
if (this.appendRow || this.hasFab || this.groupColumn) {
const items = [...data];
if (this.appendRow) {
@ -487,7 +515,41 @@ export class HaDataTable extends LitElement {
if (this.hasFab) {
items.push({ empty: true });
}
if (this.groupColumn) {
const grouped = groupBy(items, (item) => item[this.groupColumn!]);
if (grouped.undefined) {
// make sure ungrouped items are at the bottom
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
delete grouped.undefined;
}
const sorted: {
[key: string]: DataTableRowData[];
} = Object.keys(grouped)
.sort()
.reduce((obj, key) => {
obj[key] = grouped[key];
return obj;
}, {});
const groupedItems: DataTableRowData[] = [];
Object.entries(sorted).forEach(([groupName, rows]) => {
groupedItems.push({
append: true,
content: html`<div
class="mdc-data-table__cell group-header"
role="cell"
>
${groupName === UNDEFINED_GROUP_KEY ? "" : groupName || ""}
</div>`,
});
groupedItems.push(...rows);
});
this._items = groupedItems;
} else {
this._items = items;
}
} else {
this._items = data;
}
@ -507,19 +569,19 @@ export class HaDataTable extends LitElement {
if (!this.columns[columnId].sortable) {
return;
}
if (!this._sortDirection || this._sortColumn !== columnId) {
this._sortDirection = "asc";
} else if (this._sortDirection === "asc") {
this._sortDirection = "desc";
if (!this.sortDirection || this.sortColumn !== columnId) {
this.sortDirection = "asc";
} else if (this.sortDirection === "asc") {
this.sortDirection = "desc";
} else {
this._sortDirection = null;
this.sortDirection = null;
}
this._sortColumn = this._sortDirection === null ? undefined : columnId;
this.sortColumn = this.sortDirection === null ? undefined : columnId;
fireEvent(this, "sorting-changed", {
column: columnId,
direction: this._sortDirection,
direction: this.sortDirection,
});
}
@ -552,8 +614,15 @@ export class HaDataTable extends LitElement {
};
private _handleRowClick = (ev: Event) => {
const target = ev.target as HTMLElement;
if (["HA-CHECKBOX", "MWC-BUTTON"].includes(target.tagName)) {
if (
ev
.composedPath()
.find((el) =>
["ha-checkbox", "mwc-button", "ha-button", "ha-assist-chip"].includes(
(el as HTMLElement).localName
)
)
) {
return;
}
const rowId = (ev.currentTarget as any).rowId;
@ -629,7 +698,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__row {
display: flex;
width: 100%;
height: 52px;
height: var(--data-table-row-height, 52px);
}
.mdc-data-table__row ~ .mdc-data-table__row {
@ -655,7 +724,6 @@ export class HaDataTable extends LitElement {
display: flex;
width: 100%;
border-bottom: 1px solid var(--divider-color);
overflow-x: auto;
}
.mdc-data-table__header-row::-webkit-scrollbar {
@ -809,7 +877,9 @@ export class HaDataTable extends LitElement {
padding-inline-start: initial;
}
.mdc-data-table__cell--overflow-menu,
.mdc-data-table__header-cell--overflow-menu {
.mdc-data-table__cell--overflow,
.mdc-data-table__header-cell--overflow-menu,
.mdc-data-table__header-cell--overflow {
overflow: initial;
}
.mdc-data-table__cell--icon-button a {
@ -839,6 +909,12 @@ export class HaDataTable extends LitElement {
/* custom from here */
.group-header {
padding-top: 12px;
width: 100%;
font-weight: 500;
}
:host {
display: block;
}

View File

@ -39,19 +39,15 @@ interface FloorAreaEntry {
icon: string | null;
strings: string[];
type: "floor" | "area";
hasFloor?: boolean;
}
const rowRenderer: ComboBoxLitRenderer<FloorAreaEntry> = (item) =>
item.type === "floor"
? html`<ha-list-item graphic="icon" class="floor">
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`
: html`<ha-list-item
html`<ha-list-item
graphic="icon"
style="--mdc-list-side-padding-left: 48px;"
style=${item.type === "area" && item.hasFloor
? "--mdc-list-side-padding-left: 48px;"
: ""}
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
@ -363,6 +359,7 @@ export class HaAreaFloorPicker extends SubscribeMixin(LitElement) {
name: area.name,
icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name],
hasFloor: true,
}))
);
});

View File

@ -1,221 +0,0 @@
import type { Corner } from "@material/mwc-menu";
import "@material/mwc-menu/mwc-menu-surface";
import { mdiFilterVariant } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { computeStateName } from "../common/entity/compute_state_name";
import { computeDeviceName } from "../data/device_registry";
import { findRelated, RelatedResult } from "../data/search";
import type { HomeAssistant } from "../types";
import "./device/ha-device-picker";
import "./entity/ha-entity-picker";
import "./ha-area-picker";
import "./ha-icon-button";
declare global {
// for fire event
interface HASSDomEvents {
"related-changed": {
value?: FilterValue;
items?: RelatedResult;
filter?: string;
};
}
}
interface FilterValue {
area?: string;
device?: string;
entity?: string;
}
@customElement("ha-button-related-filter-menu")
export class HaRelatedFilterButtonMenu extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public corner: Corner = "BOTTOM_START";
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ type: Boolean }) public disabled = false;
@property({ attribute: false }) public value?: FilterValue;
/**
* Show no entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
@state() private _open = false;
protected render(): TemplateResult {
return html`
<ha-icon-button
@click=${this._handleClick}
.label=${this.hass.localize("ui.components.related-filter-menu.filter")}
.path=${mdiFilterVariant}
></ha-icon-button>
<mwc-menu-surface
.open=${this._open}
.anchor=${this}
.fullwidth=${this.narrow}
.corner=${this.corner}
@closed=${this._onClosed}
@input=${stopPropagation}
>
<ha-area-picker
.label=${this.hass.localize(
"ui.components.related-filter-menu.filter_by_area"
)}
.hass=${this.hass}
.value=${this.value?.area}
no-add
@value-changed=${this._areaPicked}
@click=${this._preventDefault}
></ha-area-picker>
<ha-device-picker
.label=${this.hass.localize(
"ui.components.related-filter-menu.filter_by_device"
)}
.hass=${this.hass}
.value=${this.value?.device}
@value-changed=${this._devicePicked}
@click=${this._preventDefault}
></ha-device-picker>
<ha-entity-picker
.label=${this.hass.localize(
"ui.components.related-filter-menu.filter_by_entity"
)}
.hass=${this.hass}
.value=${this.value?.entity}
.excludeDomains=${this.excludeDomains}
@value-changed=${this._entityPicked}
@click=${this._preventDefault}
></ha-entity-picker>
</mwc-menu-surface>
`;
}
private _handleClick(): void {
if (this.disabled) {
return;
}
this._open = true;
}
private _onClosed(ev): void {
ev.stopPropagation();
this._open = false;
}
private _preventDefault(ev) {
ev.preventDefault();
}
private async _entityPicked(ev: CustomEvent) {
ev.stopPropagation();
const entityId = ev.detail.value;
if (!entityId) {
fireEvent(this, "related-changed", { value: undefined });
return;
}
const filter = this.hass.localize(
"ui.components.related-filter-menu.filtered_by_entity",
{
entity_name: computeStateName(
(ev.currentTarget as any).comboBox.selectedItem
),
}
);
const items = await findRelated(this.hass, "entity", entityId);
fireEvent(this, "related-changed", {
value: { entity: entityId },
filter,
items,
});
}
private async _devicePicked(ev: CustomEvent) {
ev.stopPropagation();
const deviceId = ev.detail.value;
if (!deviceId) {
fireEvent(this, "related-changed", { value: undefined });
return;
}
const filter = this.hass.localize(
"ui.components.related-filter-menu.filtered_by_device",
{
device_name: computeDeviceName(
(ev.currentTarget as any).comboBox.selectedItem,
this.hass
),
}
);
const items = await findRelated(this.hass, "device", deviceId);
fireEvent(this, "related-changed", {
value: { device: deviceId },
filter,
items,
});
}
private async _areaPicked(ev: CustomEvent) {
ev.stopPropagation();
const areaId = ev.detail.value;
if (!areaId) {
fireEvent(this, "related-changed", { value: undefined });
return;
}
const filter = this.hass.localize(
"ui.components.related-filter-menu.filtered_by_area",
{ area_name: (ev.currentTarget as any).comboBox.selectedItem.name }
);
const items = await findRelated(this.hass, "area", areaId);
fireEvent(this, "related-changed", {
value: { area: areaId },
filter,
items,
});
}
static get styles(): CSSResultGroup {
return css`
:host {
display: inline-block;
position: relative;
--mdc-menu-min-width: 250px;
}
ha-area-picker,
ha-device-picker,
ha-entity-picker {
display: block;
width: 300px;
padding: 4px 16px;
box-sizing: border-box;
}
ha-area-picker {
padding-top: 16px;
}
ha-entity-picker {
padding-bottom: 16px;
}
:host([narrow]) ha-area-picker,
:host([narrow]) ha-device-picker,
:host([narrow]) ha-entity-picker {
width: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-button-related-filter-menu": HaRelatedFilterButtonMenu;
}
}

View File

@ -83,13 +83,11 @@ export class HaExpansionPanel extends LitElement {
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (changedProps.has("expanded") && this.expanded) {
if (changedProps.has("expanded")) {
this._showContent = this.expanded;
setTimeout(() => {
// Verify we're still expanded
if (this.expanded) {
this._container.style.overflow = "initial";
}
this._container.style.overflow = this.expanded ? "initial" : "hidden";
}, 300);
}
}

View File

@ -0,0 +1,175 @@
import { SelectedDetail } from "@material/mwc-list";
import "@material/mwc-menu/mwc-menu-surface";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { findRelated, RelatedResult } from "../data/search";
import type { HomeAssistant } from "../types";
import { haStyleScrollbar } from "../resources/styles";
import { Blueprints, fetchBlueprints } from "../data/blueprint";
@customElement("ha-filter-blueprints")
export class HaFilterBlueprints extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public type?: "automation" | "script";
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _blueprints?: Blueprints;
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.blueprint.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._blueprints && this._shouldRender
? html`
<mwc-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${this.value?.includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected async firstUpdated() {
if (!this.type) {
return;
}
this._blueprints = await fetchBlueprints(this.hass, this.type);
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (this.narrow || !this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _blueprintsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
const blueprints = this._blueprints!;
const relatedPromises: Promise<RelatedResult>[] = [];
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const blueprintId = Object.keys(blueprints)[index];
value.push(blueprintId);
if (this.type) {
relatedPromises.push(
findRelated(this.hass, `${this.type}_blueprint`, blueprintId)
);
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-blueprints": HaFilterBlueprints;
}
}

View File

@ -0,0 +1,284 @@
import { ActionDetail, SelectedDetail } from "@material/mwc-list";
import { mdiDelete, mdiDotsVertical, mdiPencil, mdiPlus } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import {
CategoryRegistryEntry,
deleteCategoryRegistryEntry,
subscribeCategoryRegistry,
} from "../data/category_registry";
import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showCategoryRegistryDetailDialog } from "../panels/config/category/show-dialog-category-registry-detail";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-list-item";
@customElement("ha-filter-categories")
export class HaFilterCategories extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public scope?: string;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _categories: CategoryRegistryEntry[] = [];
@state() private _shouldRender = false;
protected hassSubscribeRequiredHostProps = ["scope"];
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeCategoryRegistry(
this.hass.connection,
this.scope!,
(categories) => {
this._categories = categories;
}
),
];
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.category.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list
@selected=${this._categorySelected}
class="ha-scrollbar"
activatable
>
<ha-list-item
.selected=${!this.value?.length}
.activated=${!this.value?.length}
>${this.hass.localize(
"ui.panel.config.category.filter.show_all"
)}</ha-list-item
>
${this._categories.map(
(category) =>
html`<ha-list-item
.value=${category.category_id}
.selected=${this.value?.includes(category.category_id)}
.activated=${this.value?.includes(category.category_id)}
graphic="icon"
hasMeta
>
${category.icon
? html`<ha-icon
slot="graphic"
.icon=${category.icon}
></ha-icon>`
: nothing}
${category.name}
<ha-button-menu
@action=${this._handleAction}
slot="meta"
fixed
.categoryId=${category.category_id}
>
<ha-icon-button
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
<mwc-list-item graphic="icon"
><ha-svg-icon
.path=${mdiPencil}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.category.editor.edit"
)}</mwc-list-item
>
<mwc-list-item graphic="icon" class="warning"
><ha-svg-icon
class="warning"
.path=${mdiDelete}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.category.editor.delete"
)}</mwc-list-item
>
</ha-button-menu>
</ha-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
${this.expanded
? html`<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>`
: nothing}
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
private _handleAction(ev: CustomEvent<ActionDetail>) {
const categoryId = (ev.currentTarget as any).categoryId;
switch (ev.detail.index) {
case 0:
this._editCategory(categoryId);
break;
case 1:
this._deleteCategory(categoryId);
break;
}
}
private _editCategory(id: string) {
showCategoryRegistryDetailDialog(this, {
scope: this.scope!,
entry: this._categories.find((cat) => cat.category_id === id),
});
}
private async _deleteCategory(id: string) {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.category.editor.confirm_delete"
),
text: this.hass.localize(
"ui.panel.config.category.editor.confirm_delete_text"
),
confirmText: this.hass.localize("ui.common.delete"),
destructive: true,
});
if (!confirm) {
return;
}
try {
await deleteCategoryRegistryEntry(this.hass, this.scope!, id);
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
} catch (err: any) {
alert(`Failed to delete: ${err.message}`);
}
}
private _addCategory() {
if (!this.scope) {
return;
}
showCategoryRegistryDetailDialog(this, { scope: this.scope });
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _categorySelected(ev: CustomEvent<SelectedDetail<number>>) {
if (!ev.detail.index) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const index = ev.detail.index - 1;
const val = this._categories![index]?.category_id;
if (!val) {
return;
}
this.value = [val];
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
mwc-list {
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: 4px;
--mdc-icon-button-size: 36px;
}
.warning {
color: var(--error-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-categories": HaFilterCategories;
}
}

View File

@ -0,0 +1,206 @@
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { computeDeviceName } from "../data/device_registry";
import { findRelated, RelatedResult } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-check-list-item";
import { loadVirtualizer } from "../resources/virtualizer";
@customElement("ha-filter-devices")
export class HaFilterDevices extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public type?: keyof RelatedResult;
@property({ type: Boolean, reflect: true }) public expanded = false;
@property({ type: Boolean }) public narrow = false;
@state() private _shouldRender = false;
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.devices.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`<mwc-list class="ha-scrollbar">
<lit-virtualizer
.items=${this._devices(this.hass.devices)}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
>
</lit-virtualizer>
</mwc-list>`
: nothing}
</ha-expansion-panel>
`;
}
private _renderItem = (device) =>
html`<ha-check-list-item
.value=${device.id}
.selected=${this.value?.includes(device.id)}
>
${computeDeviceName(device, this.hass)}
</ha-check-list-item>`;
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this._findRelated();
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _devices = memoizeOne((devices: HomeAssistant["devices"]) => {
const values = Object.values(devices);
return values.sort((a, b) =>
stringCompare(
a.name_by_user || a.name || "",
b.name_by_user || b.name || "",
this.hass.locale.language
)
);
});
private async _findRelated() {
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
relatedPromises.push(findRelated(this.hass, "device", deviceId));
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
ha-check-list-item {
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-devices": HaFilterDevices;
}
}

View File

@ -0,0 +1,220 @@
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { stringCompare } from "../common/string/compare";
import { findRelated, RelatedResult } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-state-icon";
import "./ha-check-list-item";
import { loadVirtualizer } from "../resources/virtualizer";
@customElement("ha-filter-entities")
export class HaFilterEntities extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property() public type?: keyof RelatedResult;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
public willUpdate(properties: PropertyValues) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.entities.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list class="ha-scrollbar">
<lit-virtualizer
.items=${this._entities(this.hass.states, this.type)}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
>
</lit-virtualizer>
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _renderItem = (entity) =>
html`<ha-check-list-item
.value=${entity.entity_id}
.selected=${this.value?.includes(entity.entity_id)}
graphic="icon"
>
<ha-state-icon
slot="graphic"
.hass=${this.hass}
.stateObj=${entity}
></ha-state-icon>
${computeStateName(entity)}
</ha-check-list-item>`;
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
if (this.value?.includes(value)) {
this.value = this.value?.filter((val) => val !== value);
} else {
this.value = [...(this.value || []), value];
}
listItem.selected = this.value?.includes(value);
this._findRelated();
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _entities = memoizeOne(
(states: HomeAssistant["states"], type: this["type"]) => {
const values = Object.values(states);
return values
.filter(
(entityState) => !type || computeStateDomain(entityState) !== type
)
.sort((a, b) =>
stringCompare(
computeStateName(a),
computeStateName(b),
this.hass.locale.language
)
);
}
);
private async _findRelated() {
const relatedPromises: Promise<RelatedResult>[] = [];
if (!this.value?.length) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const entityId of this.value) {
value.push(entityId);
if (this.type) {
relatedPromises.push(findRelated(this.hass, "entity", entityId));
}
}
this.value = value;
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value,
items: this.type ? items : undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
ha-check-list-item {
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-entities": HaFilterEntities;
}
}

View File

@ -0,0 +1,287 @@
import "@material/mwc-menu/mwc-menu-surface";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import {
FloorRegistryEntry,
getFloorAreaLookup,
subscribeFloorRegistry,
} from "../data/floor_registry";
import { findRelated, RelatedResult } from "../data/search";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: {
floors?: string[];
areas?: string[];
};
@property() public type?: keyof RelatedResult;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _floors?: FloorRegistryEntry[];
protected render() {
const areas = this._areas(this.hass.areas, this._floors);
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.areas.caption")}
${this.value?.areas?.length || this.value?.floors?.length
? html`<div class="badge">
${(this.value?.areas?.length || 0) +
(this.value?.floors?.length || 0)}
</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list class="ha-scrollbar">
${repeat(
areas?.floors || [],
(floor) => floor.floor_id,
(floor) => html`
<ha-check-list-item
.value=${floor.floor_id}
.type=${"floors"}
.selected=${this.value?.floors?.includes(
floor.floor_id
) || false}
graphic="icon"
@request-selected=${this._handleItemClick}
>
${floor.icon
? html`<ha-icon
slot="graphic"
.icon=${floor.icon}
></ha-icon>`
: nothing}
${floor.name}
</ha-check-list-item>
${repeat(
floor.areas,
(area) => area.area_id,
(area) => this._renderArea(area)
)}
`
)}
${repeat(
areas?.unassisgnedAreas,
(area) => area.area_id,
(area) => this._renderArea(area)
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
private _renderArea(area) {
return html`<ha-check-list-item
.value=${area.area_id}
.selected=${this.value?.areas?.includes(area.area_id) || false}
.type=${"areas"}
graphic="icon"
class=${area.floor_id ? "floor" : ""}
@request-selected=${this._handleItemClick}
>
${area.icon
? html`<ha-icon slot="graphic" .icon=${area.icon}></ha-icon>`
: nothing}
${area.name}
</ha-check-list-item>`;
}
private _handleItemClick(ev) {
ev.stopPropagation();
const listItem = ev.currentTarget;
const type = listItem?.type;
const value = listItem?.value;
if (ev.detail.selected === listItem.selected || !value) {
return;
}
if (this.value?.[type]?.includes(value)) {
this.value = {
...this.value,
[type]: this.value[type].filter((val) => val !== value),
};
} else {
if (!this.value) {
this.value = {};
}
this.value = {
...this.value,
[type]: [...(this.value[type] || []), value],
};
}
listItem.selected = this.value[type]?.includes(value);
this._findRelated();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeFloorRegistry(this.hass.connection, (floors) => {
this._floors = floors;
}),
];
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private _areas = memoizeOne(
(areaReg: HomeAssistant["areas"], floors?: FloorRegistryEntry[]) => {
const areas = Object.values(areaReg);
const floorAreaLookup = getFloorAreaLookup(areas);
const unassisgnedAreas = areas.filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
return {
floors: floors?.map((floor) => ({
...floor,
areas: floorAreaLookup[floor.floor_id] || [],
})),
unassisgnedAreas: unassisgnedAreas,
};
}
);
private async _findRelated() {
const relatedPromises: Promise<RelatedResult>[] = [];
if (
!this.value ||
(!this.value.areas?.length && !this.value.floors?.length)
) {
fireEvent(this, "data-table-filter-changed", {
value: {},
items: undefined,
});
return;
}
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "area", areaId));
}
}
}
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "floor", floorId));
}
}
}
const results = await Promise.all(relatedPromises);
const items: Set<string> = new Set();
for (const result of results) {
if (result[this.type!]) {
result[this.type!]!.forEach((item) => items.add(item));
}
}
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: this.type ? items : undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
.floor {
padding-left: 32px;
padding-inline-start: 32px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-floor-areas": HaFilterFloorAreas;
}
interface HASSDomEvents {
"data-table-filter-changed": { value: any; items: Set<string> | undefined };
}
}

View File

@ -0,0 +1,183 @@
import { SelectedDetail } from "@material/mwc-list";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import {
fetchIntegrationManifests,
IntegrationManifest,
} from "../data/integration";
import "./ha-domain-icon";
@customElement("ha-filter-integrations")
export class HaFilterIntegrations extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _manifests?: IntegrationManifest[];
@state() private _shouldRender = false;
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.integrations.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._manifests && this._shouldRender
? html`
<mwc-list
@selected=${this._integrationsSelected}
multi
class="ha-scrollbar"
>
${this._integrations(this._manifests).map(
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${this.value?.includes(integration.domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.hass=${this.hass}
.domain=${integration.domain}
brandFallback
></ha-domain-icon>
${integration.name || integration.domain}
</ha-check-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
protected async firstUpdated() {
this._manifests = await fetchIntegrationManifests(this.hass);
}
private _integrations = memoizeOne((manifest: IntegrationManifest[]) =>
manifest
.filter(
(mnfst) =>
!mnfst.integration_type ||
!["entity", "system", "hardware"].includes(mnfst.integration_type)
)
.sort((a, b) =>
stringCompare(
a.name || a.domain,
b.name || b.domain,
this.hass.locale.language
)
)
);
private async _integrationsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
const integrations = this._integrations(this._manifests!);
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const domain = integrations[index].domain;
value.push(domain);
}
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-integrations": HaFilterIntegrations;
}
}

View File

@ -0,0 +1,190 @@
import { SelectedDetail } from "@material/mwc-list";
import "@material/mwc-menu/mwc-menu-surface";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeCssColor } from "../common/color/compute-color";
import { fireEvent } from "../common/dom/fire_event";
import {
LabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./chips/ha-assist-chip";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-check-list-item";
@customElement("ha-filter-labels")
export class HaFilterLabels extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _labels: LabelRegistryEntry[] = [];
@state() private _shouldRender = false;
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
protected render() {
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.labels.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${this._labels.map((label) => {
const color = label.color
? computeCssColor(label.color)
: undefined;
return html`<ha-check-list-item
.value=${label.label_id}
.selected=${this.value?.includes(label.label_id)}
hasMeta
>
<ha-assist-chip
.label=${label.name}
active
style=${color ? `--color: ${color}` : ""}
>
${label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
</ha-assist-chip>
</ha-check-list-item>`;
})}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _labelSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const labelId = this._labels[index].label_id;
value.push(labelId);
}
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
.warning {
color: var(--error-color);
}
ha-assist-chip {
border: 1px solid var(--color);
--md-assist-chip-icon-size: 16px;
--md-assist-chip-leading-space: 12px;
--md-assist-chip-trailing-space: 12px;
--ha-assist-chip-active-container-color: var(--color);
--ha-assist-chip-active-container-opacity: 0.3;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-labels": HaFilterLabels;
}
}

View File

@ -0,0 +1,165 @@
import { SelectedDetail } from "@material/mwc-list";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-check-list-item";
import "./ha-icon";
@customElement("ha-filter-states")
export class HaFilterStates extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[];
@property({ attribute: false }) public states?: {
value: any;
label?: string;
icon?: string;
}[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
protected render() {
if (!this.states) {
return nothing;
}
const hasIcon = this.states.find((item) => item.icon);
return html`
<ha-expansion-panel
leftChevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.label}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>`
: nothing}
</div>
${this._shouldRender
? html`
<mwc-list
@selected=${this._statesSelected}
multi
class="ha-scrollbar"
>
${this.states.map(
(item) =>
html`<ha-check-list-item
.value=${item.value}
.selected=${this.value?.includes(item.value)}
.graphic=${hasIcon ? "icon" : undefined}
>
${item.icon
? html`<ha-icon
slot="graphic"
.icon=${item.icon}
></ha-icon>`
: nothing}
${item.label}
</ha-check-list-item>`
)}
</mwc-list>
`
: nothing}
</ha-expansion-panel>
`;
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("mwc-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _statesSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
for (const index of ev.detail.index) {
const val = this.states![index].value;
value.push(val);
}
this.value = value;
fireEvent(this, "data-table-filter-changed", {
value,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: 0;
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-states": HaFilterStates;
}
}

View File

@ -0,0 +1,112 @@
import "@material/web/textfield/outlined-text-field";
import type { MdOutlinedTextField } from "@material/web/textfield/outlined-text-field";
import { mdiMagnify } from "@mdi/js";
import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { HomeAssistant } from "../types";
import "./ha-icon-button";
import "./ha-svg-icon";
@customElement("search-input-outlined")
class SearchInputOutlined extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public filter?: string;
@property({ type: Boolean })
public suffix = false;
@property({ type: Boolean })
public autofocus = false;
@property({ type: String })
public label?: string;
@property({ type: String })
public placeholder?: string;
public focus() {
this._input?.focus();
}
@query("md-outlined-text-field", true) private _input!: MdOutlinedTextField;
protected render(): TemplateResult {
return html`
<md-outlined-text-field
.autofocus=${this.autofocus}
.aria-label=${this.label || this.hass.localize("ui.common.search")}
.placeholder=${this.placeholder ||
this.hass.localize("ui.common.search")}
.value=${this.filter || ""}
icon
.iconTrailing=${this.filter || this.suffix}
@input=${this._filterInputChanged}
>
<slot name="prefix" slot="leading-icon">
<ha-svg-icon
tabindex="-1"
class="prefix"
.path=${mdiMagnify}
></ha-svg-icon>
</slot>
</md-outlined-text-field>
`;
}
private async _filterChanged(value: string) {
fireEvent(this, "value-changed", { value: String(value) });
}
private async _filterInputChanged(e) {
this._filterChanged(e.target.value);
}
static get styles(): CSSResultGroup {
return css`
:host {
display: inline-flex;
}
md-outlined-text-field {
display: block;
width: 100%;
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-primary: var(--primary-text-color);
--md-outlined-text-field-input-text-color: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-outlined-field-top-space: 5.5px;
--md-outlined-field-bottom-space: 5.5px;
--md-outlined-field-outline-color: var(--outline-color);
--md-outlined-field-container-shape-start-start: 10px;
--md-outlined-field-container-shape-start-end: 10px;
--md-outlined-field-container-shape-end-end: 10px;
--md-outlined-field-container-shape-end-start: 10px;
--md-outlined-field-focus-outline-width: 1px;
--md-outlined-field-focus-outline-color: var(--primary-color);
}
ha-svg-icon,
ha-icon-button {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
color: var(--primary-text-color);
}
ha-svg-icon {
outline: none;
}
.clear-button {
--mdc-icon-size: 20px;
}
.trailing {
display: flex;
align-items: center;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"search-input-outlined": SearchInputOutlined;
}
}

View File

@ -0,0 +1,86 @@
import { Connection, createCollection } from "home-assistant-js-websocket";
import { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce";
export interface CategoryRegistryEntry {
category_id: string;
name: string;
icon: string | null;
}
export interface CategoryRegistryEntryMutableParams {
name: string;
icon?: string | null;
}
export const fetchCategoryRegistry = (conn: Connection, scope: string) =>
conn
.sendMessagePromise<CategoryRegistryEntry[]>({
type: "config/category_registry/list",
scope,
})
.then((categories) =>
categories.sort((ent1, ent2) => stringCompare(ent1.name, ent2.name))
);
export const subscribeCategoryRegistry = (
conn: Connection,
scope: string,
onChange: (floors: CategoryRegistryEntry[]) => void
) =>
createCollection<CategoryRegistryEntry[]>(
`_categoryRegistry_${scope}`,
(conn2: Connection) => fetchCategoryRegistry(conn2, scope),
(conn2: Connection, store: Store<CategoryRegistryEntry[]>) =>
conn2.subscribeEvents(
debounce(
() =>
fetchCategoryRegistry(conn2, scope).then(
(categories: CategoryRegistryEntry[]) =>
store.setState(categories, true)
),
500,
true
),
"category_registry_updated"
),
conn,
onChange
);
export const createCategoryRegistryEntry = (
hass: HomeAssistant,
scope: string,
values: CategoryRegistryEntryMutableParams
) =>
hass.callWS<CategoryRegistryEntry>({
type: "config/category_registry/create",
scope,
...values,
});
export const updateCategoryRegistryEntry = (
hass: HomeAssistant,
scope: string,
category_id: string,
updates: Partial<CategoryRegistryEntryMutableParams>
) =>
hass.callWS<CategoryRegistryEntry>({
type: "config/category_registry/update",
scope,
category_id,
...updates,
});
export const deleteCategoryRegistryEntry = (
hass: HomeAssistant,
scope: string,
category_id: string
) =>
hass.callWS({
type: "config/category_registry/delete",
scope,
category_id,
});

View File

@ -61,6 +61,7 @@ export interface EntityRegistryEntry {
unique_id: string;
translation_key?: string;
options: EntityRegistryOptions | null;
categories: { [scope: string]: string };
}
export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
@ -137,6 +138,7 @@ export interface EntityRegistryEntryUpdateParams {
| LightEntityOptions;
aliases?: string[];
labels?: string[];
categories?: { [scope: string]: string | null };
}
const batteryPriorities = ["sensor", "binary_sensor"];

View File

@ -26,6 +26,7 @@ export type ItemType =
| "config_entry"
| "device"
| "entity"
| "floor"
| "group"
| "scene"
| "script"

View File

@ -1,15 +1,37 @@
import "@material/mwc-button/mwc-button";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query } from "lit/decorators";
import "@material/mwc-button/mwc-button";
import {
mdiArrowDown,
mdiArrowUp,
mdiClose,
mdiFilterRemove,
mdiFilterVariant,
mdiFormatListChecks,
mdiMenuDown,
} from "@mdi/js";
import {
CSSResultGroup,
LitElement,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { LocalizeFunc } from "../common/translations/localize";
import "../components/chips/ha-assist-chip";
import "../components/chips/ha-filter-chip";
import "../components/data-table/ha-data-table";
import type {
DataTableColumnContainer,
DataTableRowData,
HaDataTable,
SortingDirection,
} from "../components/data-table/ha-data-table";
import "../components/ha-dialog";
import "../components/search-input-outlined";
import type { HomeAssistant, Route } from "../types";
import "./hass-tabs-subpage";
import type { PageNavigation } from "./hass-tabs-subpage";
@ -87,22 +109,16 @@ export class HaTabsSubpageDataTable extends LitElement {
@property() public searchLabel?: string;
/**
* List of strings that show what the data is currently filtered by.
* @type {Array}
*/
@property({ type: Array }) public activeFilters?;
/**
* Text to how how many items are hidden.
* @type {String}
*/
@property() public hiddenLabel?: string;
/**
* How many items are hidden because of active filters.
* Number of active filters.
* @type {Number}
*/
@property({ type: Number }) public numHidden = 0;
@property({ type: Number }) public filters?;
/**
* Number of current selections.
* @type {Number}
*/
@property({ type: Number }) public selected?;
/**
* What path to use when the back button is pressed.
@ -138,57 +154,146 @@ export class HaTabsSubpageDataTable extends LitElement {
@property({ attribute: false }) public tabs: PageNavigation[] = [];
/**
* Force hides the filter menu.
* Show the filter menu.
* @type {Boolean}
*/
@property({ type: Boolean }) public hideFilterMenu = false;
@property({ type: Boolean }) public hasFilters = false;
@property({ type: Boolean }) public showFilters = false;
@property() public initialGroupColumn?: string;
@state() private _sortColumn?: string;
@state() private _sortDirection: SortingDirection = null;
@state() private _groupColumn?: string;
@state() private _selectMode = false;
@query("ha-data-table", true) private _dataTable!: HaDataTable;
private _showPaneController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width > 750,
});
public clearSelection() {
this._dataTable.clearSelection();
}
protected firstUpdated() {
if (this.initialGroupColumn) {
this._groupColumn = this.initialGroupColumn;
}
}
protected render(): TemplateResult {
const hiddenLabel = this.numHidden
? this.hiddenLabel ||
this.hass.localize("ui.components.data-table.hidden", {
number: this.numHidden,
}) ||
this.numHidden
: undefined;
const localize = this.localizeFunc || this.hass.localize;
const showPane = this._showPaneController.value ?? !this.narrow;
const filterButton = this.hasFilters
? html`<div class="relative">
<ha-assist-chip
.label=${localize("ui.components.subpage-data-table.filters")}
.active=${this.filters}
@click=${this._toggleFilters}
>
<ha-svg-icon slot="icon" .path=${mdiFilterVariant}></ha-svg-icon>
</ha-assist-chip>
${this.filters
? html`<div class="badge">${this.filters}</div>`
: nothing}
</div>`
: nothing;
const filterInfo = this.activeFilters
? html`${this.hass.localize("ui.components.data-table.filtering_by")}
${this.activeFilters.join(", ")}
${hiddenLabel ? `(${hiddenLabel})` : ""}`
: hiddenLabel;
const selectModeBtn =
this.selectable && !this._selectMode
? html`<ha-assist-chip
class="has-dropdown select-mode-chip"
.active=${this._selectMode}
@click=${this._enableSelectMode}
>
<ha-svg-icon slot="icon" .path=${mdiFormatListChecks}></ha-svg-icon>
</ha-assist-chip>`
: nothing;
const headerToolbar = html`<search-input
const searchBar = html`<search-input-outlined
.hass=${this.hass}
.filter=${this.filter}
.suffix=${!this.narrow}
@value-changed=${this._handleSearchChange}
.label=${this.searchLabel}
.placeholder=${this.searchLabel}
>
${!this.narrow
? html`<div
class="filters"
slot="suffix"
@click=${this._preventDefault}
</search-input-outlined>`;
const sortByMenu = Object.values(this.columns).find((col) => col.sortable)
? html`<ha-button-menu fixed>
<ha-assist-chip
.label=${localize("ui.components.subpage-data-table.sort_by", {
sortColumn: this._sortColumn
? ` ${this.columns[this._sortColumn].title || this.columns[this._sortColumn].label}`
: "",
})}
slot="trigger"
>
${filterInfo
? html`<div class="active-filters">
${filterInfo}
<mwc-button @click=${this._clearFilter}>
${this.hass.localize("ui.components.data-table.clear")}
</mwc-button>
</div>`
: ""}
<slot name="filter-menu"></slot>
</div>`
: ""}
</search-input>`;
<ha-svg-icon slot="trailing-icon" .path=${mdiMenuDown}></ha-svg-icon
></ha-assist-chip>
${Object.entries(this.columns).map(([id, column]) =>
column.sortable
? html`<ha-list-item
.value=${id}
@request-selected=${this._handleSortBy}
hasMeta
.activated=${id === this._sortColumn}
>
${this._sortColumn === id
? html`<ha-svg-icon
slot="meta"
.path=${this._sortDirection === "desc"
? mdiArrowDown
: mdiArrowUp}
></ha-svg-icon>`
: nothing}
${column.title || column.label}
</ha-list-item>`
: nothing
)}
</ha-button-menu>`
: nothing;
const groupByMenu = Object.values(this.columns).find((col) => col.groupable)
? html`<ha-button-menu fixed>
<ha-assist-chip
.label=${localize("ui.components.subpage-data-table.group_by", {
groupColumn: this._groupColumn
? ` ${this.columns[this._groupColumn].title || this.columns[this._groupColumn].label}`
: "",
})}
slot="trigger"
>
<ha-svg-icon slot="trailing-icon" .path=${mdiMenuDown}></ha-svg-icon
></ha-assist-chip>
${Object.entries(this.columns).map(([id, column]) =>
column.groupable
? html`<ha-list-item
.value=${id}
@request-selected=${this._handleGroupBy}
.activated=${id === this._groupColumn}
>
${column.title || column.label}
</ha-list-item> `
: nothing
)}
<li divider role="separator"></li>
<ha-list-item
.value=${undefined}
@request-selected=${this._handleGroupBy}
.activated=${this._groupColumn === undefined}
>${localize(
"ui.components.subpage-data-table.dont_group_by"
)}</ha-list-item
>
</ha-button-menu>`
: nothing;
return html`
<hass-tabs-subpage
@ -202,34 +307,89 @@ export class HaTabsSubpageDataTable extends LitElement {
.tabs=${this.tabs}
.mainPage=${this.mainPage}
.supervisor=${this.supervisor}
.pane=${showPane && this.showFilters}
@sorting-changed=${this._sortingChanged}
>
${this._selectMode
? html`<div class="selection-bar" slot="toolbar">
<div class="center-vertical">
<ha-icon-button
.path=${mdiClose}
@click=${this._disableSelectMode}
></ha-icon-button>
<p>
${localize("ui.components.subpage-data-table.selected", {
selected: this.selected || "0",
})}
</p>
</div>
<div class="center-vertical">
<slot name="selection-bar"></slot>
</div>
</div>`
: nothing}
${this.showFilters
? !showPane
? html`<ha-dialog
open
hideActions
.heading=${localize("ui.components.subpage-data-table.filters")}
>
<ha-dialog-header slot="heading">
<ha-icon-button
slot="navigationIcon"
.path=${mdiClose}
@click=${this._toggleFilters}
></ha-icon-button>
<span slot="title"
>${localize(
"ui.components.subpage-data-table.filters"
)}</span
>
<ha-icon-button
slot="actionItems"
.path=${mdiFilterRemove}
></ha-icon-button>
</ha-dialog-header>
<div class="filter-dialog-content">
<slot name="filter-pane"></slot></div
></ha-dialog>`
: html`<div class="pane" slot="pane">
<div class="table-header">
<ha-assist-chip
.label=${localize(
"ui.components.subpage-data-table.filters"
)}
active
@click=${this._toggleFilters}
>
<ha-svg-icon
slot="icon"
.path=${mdiFilterVariant}
></ha-svg-icon>
</ha-assist-chip>
<ha-icon-button
.path=${mdiFilterRemove}
@click=${this._clearFilters}
></ha-icon-button>
</div>
<div class="pane-content">
<slot name="filter-pane"></slot>
</div>
</div>`
: nothing}
${this.empty
? html`<div class="center">
<slot name="empty">${this.noDataText}</slot>
</div>`
: html`${!this.hideFilterMenu
? html`
<div slot="toolbar-icon">
${this.narrow
? html`
<div class="filter-menu">
${this.numHidden || this.activeFilters
? html`<span class="badge"
>${this.numHidden || "!"}</span
>`
: ""}
<slot name="filter-menu"></slot>
: html`<div slot="toolbar-icon">
<slot name="toolbar-icon"></slot>
</div>
`
: ""}<slot name="toolbar-icon"></slot>
</div>
`
: ""}
${this.narrow
? html`
<div slot="header">
<slot name="header">
<div class="search-toolbar">${headerToolbar}</div>
<div class="search-toolbar">${searchBar}</div>
</slot>
</div>
`
@ -240,30 +400,76 @@ export class HaTabsSubpageDataTable extends LitElement {
.data=${this.data}
.noDataText=${this.noDataText}
.filter=${this.filter}
.selectable=${this.selectable}
.selectable=${this._selectMode}
.hasFab=${this.hasFab}
.id=${this.id}
.clickable=${this.clickable}
.appendRow=${this.appendRow}
.sortColumn=${this._sortColumn}
.sortDirection=${this._sortDirection}
.groupColumn=${this._groupColumn}
>
${!this.narrow
? html`
<div slot="header">
<slot name="header">
<div class="table-header">${headerToolbar}</div>
<div class="table-header">
${this.hasFilters && !this.showFilters
? html`${filterButton}`
: nothing}${selectModeBtn}${searchBar}${groupByMenu}${sortByMenu}
</div>
</slot>
</div>
`
: html` <div slot="header"></div> `}
: html`<div slot="header"></div>
<div slot="header-row" class="narrow-header-row">
${this.hasFilters && !this.showFilters
? html`${filterButton}`
: nothing}
${selectModeBtn}${groupByMenu}${sortByMenu}
</div>`}
</ha-data-table>`}
<div slot="fab"><slot name="fab"></slot></div>
</hass-tabs-subpage>
`;
}
private _preventDefault(ev) {
ev.preventDefault();
private _clearFilters() {
fireEvent(this, "clear-filter");
}
private _toggleFilters() {
this.showFilters = !this.showFilters;
}
private _sortingChanged(ev) {
this._sortDirection = ev.detail.direction;
this._sortColumn = this._sortDirection ? ev.detail.column : undefined;
}
private _handleSortBy(ev) {
ev.stopPropagation();
const columnId = ev.currentTarget.value;
if (!this._sortDirection || this._sortColumn !== columnId) {
this._sortDirection = "asc";
} else if (this._sortDirection === "asc") {
this._sortDirection = "desc";
} else {
this._sortDirection = null;
}
this._sortColumn = this._sortDirection === null ? undefined : columnId;
}
private _handleGroupBy(ev) {
this._groupColumn = ev.currentTarget.value;
}
private _enableSelectMode() {
this._selectMode = true;
}
private _disableSelectMode() {
this._selectMode = false;
}
private _handleSearchChange(ev: CustomEvent) {
@ -274,54 +480,56 @@ export class HaTabsSubpageDataTable extends LitElement {
fireEvent(this, "search-changed", { value: this.filter });
}
private _clearFilter() {
fireEvent(this, "clear-filter");
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
}
ha-data-table {
width: 100%;
height: 100%;
--data-table-border-width: 0;
}
:host(:not([narrow])) ha-data-table {
:host(:not([narrow])) ha-data-table,
.pane {
height: calc(100vh - 1px - var(--header-height));
display: block;
}
.pane-content {
height: calc(100vh - 1px - var(--header-height) - var(--header-height));
display: flex;
flex-direction: column;
}
:host([narrow]) hass-tabs-subpage {
--main-title-margin: 0;
}
:host([narrow]) {
--expansion-panel-summary-padding: 0 16px;
}
.table-header {
display: flex;
align-items: center;
--mdc-shape-small: 0;
height: 56px;
width: 100%;
justify-content: space-between;
padding: 0 16px;
gap: 16px;
box-sizing: border-box;
background: var(--primary-background-color);
border-bottom: 1px solid var(--divider-color);
}
search-input-outlined {
flex: 1;
}
.search-toolbar {
display: flex;
align-items: center;
color: var(--secondary-text-color);
}
search-input {
--mdc-text-field-fill-color: var(--sidebar-background-color);
--mdc-text-field-idle-line-color: var(--divider-color);
--text-field-overflow: visible;
z-index: 5;
}
.table-header search-input {
display: block;
position: absolute;
top: 0;
right: 0;
left: 0;
}
.search-toolbar search-input {
display: block;
width: 100%;
color: var(--secondary-text-color);
--mdc-ripple-color: transparant;
}
.filters {
--mdc-text-field-fill-color: var(--input-fill-color);
--mdc-text-field-idle-line-color: var(--input-idle-line-color);
@ -382,9 +590,6 @@ export class HaTabsSubpageDataTable extends LitElement {
top: 4px;
font-size: 0.65em;
}
.filter-menu {
position: relative;
}
.center {
display: flex;
align-items: center;
@ -395,6 +600,92 @@ export class HaTabsSubpageDataTable extends LitElement {
width: 100%;
padding: 16px;
}
.badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
background-color: var(--accent-color);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-accent-color, var(--text-primary-color));
}
.narrow-header-row {
display: flex;
align-items: center;
gap: 16px;
padding: 0 16px;
overflow-x: scroll;
-ms-overflow-style: none;
scrollbar-width: none;
}
.selection-bar {
background: rgba(var(--rgb-primary-color), 0.1);
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
box-sizing: border-box;
font-size: 14px;
}
.center-vertical {
display: flex;
align-items: center;
}
.relative {
position: relative;
}
.selection-bar p {
margin-left: 16px;
}
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
}
ha-button-menu {
--mdc-list-item-meta-size: 16px;
--mdc-list-item-meta-display: flex;
}
ha-button-menu ha-assist-chip {
--md-assist-chip-trailing-space: 8px;
}
.select-mode-chip {
--md-assist-chip-icon-label-space: 0;
}
ha-dialog {
--mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left)
);
--mdc-dialog-max-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left)
);
--mdc-dialog-min-height: 100%;
--mdc-dialog-max-height: 100%;
--vertical-align-dialog: flex-end;
--ha-dialog-border-radius: 0;
--dialog-content-padding: 0;
}
.filter-dialog-content {
height: calc(100vh - 1px - var(--header-height));
display: flex;
flex-direction: column;
}
`;
}
}

View File

@ -4,6 +4,7 @@ import {
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
TemplateResult,
} from "lit";
@ -57,6 +58,8 @@ class HassTabsSubpage extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "is-wide" })
public isWide = false;
@property({ type: Boolean }) public pane = false;
@state() private _activeTab?: PageNavigation;
// @ts-ignore
@ -128,6 +131,8 @@ class HassTabsSubpage extends LitElement {
const showTabs = tabs.length > 1;
return html`
<div class="toolbar">
<slot name="toolbar">
<div class="toolbar-content">
${this.mainPage || (!this.backPath && history.state?.root)
? html`
<ha-menu-button
@ -155,23 +160,34 @@ class HassTabsSubpage extends LitElement {
<slot name="header">${!showTabs ? tabs[0] : ""}</slot>
</div>`
: ""}
${showTabs
? html`
<div id="tabbar" class=${classMap({ "bottom-bar": this.narrow })}>
${tabs}
</div>
`
${showTabs && !this.narrow
? html`<div id="tabbar">${tabs}</div>`
: ""}
<div id="toolbar-icon">
<slot name="toolbar-icon"></slot>
</div>
</div>
</slot>
${showTabs && this.narrow
? html`<div id="tabbar" class="bottom-bar">${tabs}</div>`
: ""}
</div>
<div class="container">
${this.pane
? html`<div class="pane">
<div class="shadow-container"></div>
<div class="ha-scrollbar">
<slot name="pane"></slot>
</div>
</div>`
: nothing}
<div
class="content ha-scrollbar ${classMap({ tabs: showTabs })}"
@scroll=${this._saveScrollPos}
>
<slot></slot>
</div>
</div>
<div id="fab" class=${classMap({ tabs: showTabs })}>
<slot name="fab"></slot>
</div>
@ -206,6 +222,15 @@ class HassTabsSubpage extends LitElement {
position: fixed;
}
.container {
display: flex;
height: calc(100% - var(--header-height));
}
:host([narrow]) .container {
height: 100%;
}
ha-menu-button {
margin-right: 24px;
margin-inline-end: 24px;
@ -213,18 +238,22 @@ class HassTabsSubpage extends LitElement {
}
.toolbar {
display: flex;
align-items: center;
font-size: 20px;
height: var(--header-height);
background-color: var(--sidebar-background-color);
font-weight: 400;
border-bottom: 1px solid var(--divider-color);
box-sizing: border-box;
}
.toolbar-content {
padding: 8px 12px;
display: flex;
align-items: center;
height: 100%;
box-sizing: border-box;
}
@media (max-width: 599px) {
.toolbar {
.toolbar-content {
padding: 4px;
}
}
@ -297,10 +326,6 @@ class HassTabsSubpage extends LitElement {
margin-right: env(safe-area-inset-right);
margin-inline-start: env(safe-area-inset-left);
margin-inline-end: env(safe-area-inset-right);
height: calc(100% - 1px - var(--header-height));
height: calc(
100% - 1px - var(--header-height) - env(safe-area-inset-bottom)
);
overflow: auto;
-webkit-overflow-scrolling: touch;
}
@ -329,6 +354,21 @@ class HassTabsSubpage extends LitElement {
inset-inline-end: 24px;
inset-inline-start: initial;
}
.pane {
border-right: 1px solid var(--divider-color);
border-inline-end: 1px solid var(--divider-color);
border-inline-start: initial;
box-sizing: border-box;
display: flex;
flex: 0 0 var(--sidepane-width, 250px);
width: var(--sidepane-width, 250px);
flex-direction: column;
position: relative;
}
.pane .ha-scrollbar {
flex: 1;
}
`,
];
}

View File

@ -1,4 +1,3 @@
import "@material/mwc-list/mwc-list";
import "@material/web/divider/divider";
import { mdiClose, mdiContentPaste, mdiPlus } from "@mdi/js";
import Fuse, { IFuseOptions } from "fuse.js";

View File

@ -1,5 +1,6 @@
import { consume } from "@lit-labs/context";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import {
mdiCancel,
mdiContentDuplicate,
mdiDelete,
mdiHelpCircle,
@ -9,34 +10,41 @@ import {
mdiPlus,
mdiRobotHappy,
mdiStopCircleOutline,
mdiTag,
mdiTransitConnection,
} from "@mdi/js";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import { differenceInDays } from "date-fns/esm";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
TemplateResult,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { differenceInDays } from "date-fns/esm";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { relativeTime } from "../../../common/datetime/relative_time";
import { fireEvent, HASSDomEvent } from "../../../common/dom/fire_event";
import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/chips/ha-assist-chip";
import type {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button-related-filter-menu";
import "../../../components/ha-label";
import "../../../components/entity/ha-entity-toggle";
import "../../../components/ha-fab";
import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-blueprints";
import "../../../components/ha-filter-categories";
import "../../../components/ha-filter-devices";
import "../../../components/ha-filter-entities";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-svg-icon";
@ -49,28 +57,43 @@ import {
showAutomationEditor,
triggerAutomationActions,
} from "../../../data/automation";
import {
CategoryRegistryEntry,
subscribeCategoryRegistry,
} from "../../../data/category_registry";
import { fullEntitiesContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity";
import { EntityRegistryEntry } from "../../../data/entity_registry";
import { findRelated } from "../../../data/search";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { configSections } from "../ha-panel-config";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
import { findRelated } from "../../../data/search";
import { fetchBlueprints } from "../../../data/blueprint";
import { UNAVAILABLE } from "../../../data/entity";
import "../../../components/data-table/ha-data-table-labels";
import {
LabelRegistryEntry,
subscribeLabelRegistry,
} from "../../../data/label_registry";
import "../../../components/ha-filter-labels";
type AutomationItem = AutomationEntity & {
name: string;
last_triggered?: string | undefined;
disabled: boolean;
formatted_state: string;
category: string | undefined;
labels: LabelRegistryEntry[];
};
@customElement("ha-automation-picker")
class HaAutomationPicker extends LitElement {
class HaAutomationPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@ -81,17 +104,33 @@ class HaAutomationPicker extends LitElement {
@property({ attribute: false }) public automations!: AutomationEntity[];
@state() private _activeFilters?: string[];
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _filteredAutomations?: string[] | null;
@state() private _filterValue?;
@state() private _filters: Record<
string,
{ value: string[] | undefined; items: Set<string> | undefined }
> = {};
@state() private _expandedFilter?: string;
@state()
_categories!: CategoryRegistryEntry[];
@state()
_labels!: LabelRegistryEntry[];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
private _automations = memoizeOne(
(
automations: AutomationEntity[],
entityReg: EntityRegistryEntry[],
categoryReg?: CategoryRegistryEntry[],
labelReg?: LabelRegistryEntry[],
filteredAutomations?: string[] | null
): AutomationItem[] => {
if (filteredAutomations === null) {
@ -103,23 +142,38 @@ class HaAutomationPicker extends LitElement {
filteredAutomations!.includes(automation.entity_id)
)
: automations
).map((automation) => ({
).map((automation) => {
const entityRegEntry = entityReg.find(
(reg) => reg.entity_id === automation.entity_id
);
const category = entityRegEntry?.categories.automation;
const labels = labelReg && entityRegEntry?.labels;
return {
...automation,
name: computeStateName(automation),
last_triggered: automation.attributes.last_triggered || undefined,
disabled: automation.state === "off",
}));
formatted_state: this.hass.formatEntityState(automation),
category: category
? categoryReg?.find((cat) => cat.category_id === category)?.name
: undefined,
labels: (labels || []).map(
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
),
};
});
}
);
private _columns = memoizeOne(
(narrow: boolean, _locale): DataTableColumnContainer => {
(
narrow: boolean,
localize: LocalizeFunc,
locale: HomeAssistant["locale"]
): DataTableColumnContainer => {
const columns: DataTableColumnContainer<AutomationItem> = {
icon: {
title: "",
label: this.hass.localize(
"ui.panel.config.automation.picker.headers.state"
),
label: localize("ui.panel.config.automation.picker.headers.state"),
type: "icon",
template: (automation) =>
html`<ha-state-icon
@ -134,43 +188,57 @@ class HaAutomationPicker extends LitElement {
></ha-state-icon>`,
},
name: {
title: this.hass.localize(
"ui.panel.config.automation.picker.headers.name"
),
title: localize("ui.panel.config.automation.picker.headers.name"),
main: true,
sortable: true,
filterable: true,
direction: "asc",
grows: true,
template: narrow
? (automation) => {
template: (automation) => {
const date = new Date(automation.attributes.last_triggered);
const now = new Date();
const dayDifference = differenceInDays(now, date);
return html`
${automation.name}
<div class="secondary">
<div style="font-size: 14px;">${automation.name}</div>
${narrow
? html`<div class="secondary">
${this.hass.localize("ui.card.automation.last_triggered")}:
${automation.attributes.last_triggered
? dayDifference > 3
? formatShortDateTime(
date,
this.hass.locale,
this.hass.config
)
: relativeTime(date, this.hass.locale)
: this.hass.localize("ui.components.relative_time.never")}
</div>
? formatShortDateTime(date, locale, this.hass.config)
: relativeTime(date, locale)
: localize("ui.components.relative_time.never")}
</div>`
: nothing}
${automation.labels.length
? html`<ha-data-table-labels
@label-clicked=${this._labelClicked}
.labels=${automation.labels}
></ha-data-table-labels>`
: nothing}
`;
}
: undefined,
},
},
category: {
title: localize("ui.panel.config.automation.picker.headers.category"),
hidden: true,
groupable: true,
filterable: true,
sortable: true,
},
labels: {
title: "",
hidden: true,
filterable: true,
template: (automation) =>
automation.labels.map((lbl) => lbl.name).join(" "),
},
};
if (!narrow) {
columns.last_triggered = {
sortable: true,
width: "20%",
title: this.hass.localize("ui.card.automation.last_triggered"),
width: "130px",
title: localize("ui.card.automation.last_triggered"),
hidden: narrow,
template: (automation) => {
if (!automation.last_triggered) {
return this.hass.localize("ui.components.relative_time.never");
@ -180,49 +248,31 @@ class HaAutomationPicker extends LitElement {
const dayDifference = differenceInDays(now, date);
return html`
${dayDifference > 3
? formatShortDateTime(date, this.hass.locale, this.hass.config)
: relativeTime(date, this.hass.locale)}
? formatShortDateTime(date, locale, this.hass.config)
: relativeTime(date, locale)}
`;
},
};
}
columns.disabled = this.narrow
? {
if (!this.narrow) {
columns.formatted_state = {
width: "82px",
sortable: true,
groupable: true,
title: "",
template: (automation) =>
automation.disabled
? html`
<simple-tooltip animation-delay="0" position="left">
${this.hass.localize(
"ui.panel.config.automation.picker.disabled"
)}
</simple-tooltip>
<ha-svg-icon
.path=${mdiCancel}
style="color: var(--secondary-text-color)"
></ha-svg-icon>
`
: "",
}
: {
width: "20%",
title: "",
template: (automation) =>
automation.disabled
? html`
<ha-label>
${this.hass.localize(
"ui.panel.config.automation.picker.disabled"
)}
</ha-label>
`
: "",
label: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) => html`
<ha-entity-toggle
.stateObj=${automation}
.hass=${this.hass}
></ha-entity-toggle>
`,
};
}
columns.actions = {
title: "",
width: this.narrow ? undefined : "10%",
width: "64px",
type: "overflow-menu",
template: (automation) => html`
<ha-icon-overflow-menu
@ -236,6 +286,13 @@ class HaAutomationPicker extends LitElement {
),
action: () => this._showInfo(automation),
},
{
path: mdiTag,
label: this.hass.localize(
`ui.panel.config.automation.picker.${automation.category ? "edit_category" : "assign_category"}`
),
action: () => this._editCategory(automation),
},
{
path: mdiPlay,
label: this.hass.localize(
@ -292,6 +349,21 @@ class HaAutomationPicker extends LitElement {
}
);
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeCategoryRegistry(
this.hass.connection,
"automation",
(categories) => {
this._categories = categories;
}
),
subscribeLabelRegistry(this.hass.connection, (labels) => {
this._labels = labels;
}),
];
}
protected render(): TemplateResult {
return html`
<hass-tabs-subpage-data-table
@ -301,9 +373,23 @@ class HaAutomationPicker extends LitElement {
id="entity_id"
.route=${this.route}
.tabs=${configSections.automations}
.activeFilters=${this._activeFilters}
.columns=${this._columns(this.narrow, this.hass.locale)}
.data=${this._automations(this.automations, this._filteredAutomations)}
hasFilters
.filters=${Object.values(this._filters).filter(
(filter) => filter.value?.length
).length}
.columns=${this._columns(
this.narrow,
this.hass.localize,
this.hass.locale
)}
initialGroupColumn="category"
.data=${this._automations(
this.automations,
this._entityReg,
this._categories,
this._labels,
this._filteredAutomations
)}
.empty=${!this.automations.length}
@row-click=${this._handleRowClicked}
.noDataText=${this.hass.localize(
@ -312,6 +398,7 @@ class HaAutomationPicker extends LitElement {
@clear-filter=${this._clearFilter}
hasFab
clickable
class=${this.narrow ? "narrow" : ""}
>
<ha-icon-button
slot="toolbar-icon"
@ -319,15 +406,65 @@ class HaAutomationPicker extends LitElement {
.path=${mdiHelpCircle}
@click=${this._showHelp}
></ha-icon-button>
<ha-button-related-filter-menu
slot="filter-menu"
.narrow=${this.narrow}
<ha-filter-floor-areas
.hass=${this.hass}
.value=${this._filterValue}
exclude-domains='["automation"]'
@related-changed=${this._relatedFilterChanged}
>
</ha-button-related-filter-menu>
.type=${"automation"}
.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=${"automation"}
.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-entities
.hass=${this.hass}
.type=${"automation"}
.value=${this._filters["ha-filter-entities"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-entities"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-entities>
<ha-filter-labels
.hass=${this.hass}
.value=${this._filters["ha-filter-labels"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-labels"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-labels>
<ha-filter-categories
.hass=${this.hass}
scope="automation"
.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>
<ha-filter-blueprints
.hass=${this.hass}
.type=${"automation"}
.value=${this._filters["ha-filter-blueprints"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
.expanded=${this._expandedFilter === "ha-filter-blueprints"}
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-blueprints>
${!this.automations.length
? html`<div class="empty" slot="empty">
<ha-svg-icon .path=${mdiRobotHappy}></ha-svg-icon>
@ -378,44 +515,114 @@ class HaAutomationPicker extends LitElement {
}
}
private _filterExpanded(ev) {
if (ev.detail.expanded) {
this._expandedFilter = ev.target.localName;
} else if (this._expandedFilter === ev.target.localName) {
this._expandedFilter = undefined;
}
}
private _labelClicked = (ev: CustomEvent) => {
const label = ev.detail.label;
this._filters = {
...this._filters,
"ha-filter-labels": {
value: [label.label_id],
items: undefined,
},
};
this._applyFilters();
};
private _filterChanged(ev) {
const type = ev.target.localName;
this._filters[type] = ev.detail;
this._applyFilters();
}
private _applyFilters() {
const filters = Object.entries(this._filters);
let items: Set<string> | undefined;
for (const [key, filter] of filters) {
if (filter.items) {
if (!items) {
items = filter.items;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(filter.items)
: new Set([...items].filter((x) => filter.items!.has(x)));
}
if (key === "ha-filter-categories" && filter.value?.length) {
const categoryItems: Set<string> = new Set();
this.automations
.filter(
(automation) =>
filter.value![0] ===
this._entityReg.find(
(reg) => reg.entity_id === automation.entity_id
)?.categories.automation
)
.forEach((automation) => categoryItems.add(automation.entity_id));
if (!items) {
items = categoryItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(categoryItems)
: new Set([...items].filter((x) => categoryItems!.has(x)));
}
if (key === "ha-filter-labels" && filter.value?.length) {
const labelItems: Set<string> = new Set();
this.automations
.filter((automation) =>
this._entityReg
.find((reg) => reg.entity_id === automation.entity_id)
?.labels.some((lbl) => filter.value!.includes(lbl))
)
.forEach((automation) => labelItems.add(automation.entity_id));
if (!items) {
items = labelItems;
continue;
}
items =
"intersection" in items
? // @ts-ignore
items.intersection(labelItems)
: new Set([...items].filter((x) => labelItems!.has(x)));
}
}
this._filteredAutomations = items ? [...items] : undefined;
}
private async _filterBlueprint() {
const blueprint = this._searchParms.get("blueprint");
if (!blueprint) {
return;
}
const [related, blueprints] = await Promise.all([
findRelated(this.hass, "automation_blueprint", blueprint),
fetchBlueprints(this.hass, "automation"),
]);
this._filteredAutomations = related.automation || [];
const blueprintMeta = blueprints[blueprint];
this._activeFilters = [
this.hass.localize(
"ui.panel.config.automation.picker.filtered_by_blueprint",
{
name:
!blueprintMeta || "error" in blueprintMeta
? blueprint
: blueprintMeta.metadata.name || blueprint,
}
),
];
}
private _relatedFilterChanged(ev: CustomEvent) {
this._filterValue = ev.detail.value;
if (!this._filterValue) {
this._clearFilter();
return;
}
this._activeFilters = [ev.detail.filter];
this._filteredAutomations = ev.detail.items.automation || null;
const related = await findRelated(
this.hass,
"automation_blueprint",
blueprint
);
this._filters = {
...this._filters,
"ha-filter-blueprints": {
value: [blueprint],
items: new Set(related.automation || []),
},
};
this._applyFilters();
}
private _clearFilter() {
this._filteredAutomations = undefined;
this._activeFilters = undefined;
this._filterValue = undefined;
this._filters = {};
this._applyFilters();
}
private _showInfo(automation: any) {
@ -426,6 +633,27 @@ class HaAutomationPicker extends LitElement {
triggerAutomationActions(this.hass, automation.entity_id);
}
private _editCategory(automation: any) {
const entityReg = this._entityReg.find(
(reg) => reg.entity_id === automation.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: "automation",
entityReg,
});
}
private _showTrace(automation: any) {
if (!automation.attributes.id) {
showAlertDialog(this, {
@ -552,6 +780,12 @@ class HaAutomationPicker extends LitElement {
return [
haStyle,
css`
hass-tabs-subpage-data-table {
--data-table-row-height: 60px;
}
hass-tabs-subpage-data-table.narrow {
--data-table-row-height: 72px;
}
.empty {
--paper-font-headline_-_font-size: 28px;
--mdc-icon-size: 80px;

View File

@ -0,0 +1,132 @@
import "@material/mwc-button";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-icon-picker";
import "../../../components/ha-settings-row";
import "../../../components/ha-textfield";
import { updateEntityRegistryEntry } from "../../../data/entity_registry";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import "./ha-category-picker";
import { AssignCategoryDialogParams } from "./show-dialog-assign-category";
@customElement("dialog-assign-category")
class DialogAssignCategory extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _scope?: string;
@state() private _category?: string;
@state() private _error?: string;
@state() private _params?: AssignCategoryDialogParams;
@state() private _submitting?: boolean;
public showDialog(params: AssignCategoryDialogParams): void {
this._params = params;
this._scope = params.scope;
this._category = params.entityReg.categories[params.scope];
this._error = undefined;
}
public closeDialog(): void {
this._error = "";
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
const entry = this._params.entityReg.categories[this._params.scope];
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
entry
? this.hass.localize("ui.panel.config.category.assign.edit")
: this.hass.localize("ui.panel.config.category.assign.assign")
)}
>
<div>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="form">
<ha-category-picker
.hass=${this.hass}
.scope=${this._scope}
.value=${this._category}
@value-changed=${this._categoryChanged}
></ha-category-picker>
</div>
</div>
<mwc-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</mwc-button>
<mwc-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.save")}
</mwc-button>
</ha-dialog>
`;
}
private _categoryChanged(ev: CustomEvent): void {
if (!ev.detail.value) {
return;
}
this._category = ev.detail.value;
}
private async _updateEntry() {
this._submitting = true;
this._error = undefined;
try {
await updateEntityRegistryEntry(
this.hass,
this._params!.entityReg.entity_id,
{
categories: { [this._scope!]: this._category || null },
}
);
this.closeDialog();
} catch (err: any) {
this._error =
err.message ||
this.hass.localize("ui.panel.config.category.assign.unknown_error");
} finally {
this._submitting = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-textfield,
ha-icon-picker {
display: block;
margin-bottom: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-assign-category": DialogAssignCategory;
}
}

View File

@ -0,0 +1,175 @@
import "@material/mwc-button";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-icon-picker";
import "../../../components/ha-settings-row";
import "../../../components/ha-textfield";
import {
CategoryRegistryEntryMutableParams,
createCategoryRegistryEntry,
updateCategoryRegistryEntry,
} from "../../../data/category_registry";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { CategoryRegistryDetailDialogParams } from "./show-dialog-category-registry-detail";
class DialogCategoryDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@state() private _icon!: string | null;
@state() private _error?: string;
@state() private _params?: CategoryRegistryDetailDialogParams;
@state() private _submitting?: boolean;
public async showDialog(
params: CategoryRegistryDetailDialogParams
): Promise<void> {
this._params = params;
this._error = undefined;
this._name = this._params.entry ? this._params.entry.name : "";
this._icon = this._params.entry?.icon || null;
await this.updateComplete;
}
public closeDialog(): void {
this._error = "";
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
const entry = this._params.entry;
const nameInvalid = !this._isNameValid();
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
entry
? this.hass.localize("ui.panel.config.category.editor.edit")
: this.hass.localize("ui.panel.config.category.editor.create")
)}
>
<div>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="form">
<ha-textfield
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass.localize(
"ui.panel.config.category.editor.name"
)}
.validationMessage=${this.hass.localize(
"ui.panel.config.category.editor.required_error_msg"
)}
required
dialogInitialFocus
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize(
"ui.panel.config.category.editor.icon"
)}
></ha-icon-picker>
</div>
</div>
<mwc-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</mwc-button>
<mwc-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
: this.hass.localize("ui.common.add")}
</mwc-button>
</ha-dialog>
`;
}
private _isNameValid() {
return this._name.trim() !== "";
}
private _nameChanged(ev) {
this._error = undefined;
this._name = ev.target.value;
}
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
}
private async _updateEntry() {
const create = !this._params!.entry;
this._submitting = true;
try {
const values: CategoryRegistryEntryMutableParams = {
name: this._name.trim(),
icon: this._icon || (create ? undefined : null),
};
if (create) {
await createCategoryRegistryEntry(
this.hass,
this._params!.scope,
values
);
} else {
await updateCategoryRegistryEntry(
this.hass,
this._params!.scope,
this._params!.entry!.category_id,
values
);
}
this.closeDialog();
} catch (err: any) {
this._error =
err.message ||
this.hass.localize("ui.panel.config.category.editor.unknown_error");
} finally {
this._submitting = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-textfield,
ha-icon-picker {
display: block;
margin-bottom: 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-category-registry-detail": DialogCategoryDetail;
}
}
customElements.define("dialog-category-registry-detail", DialogCategoryDetail);

View File

@ -0,0 +1,281 @@
import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { html, LitElement, nothing, PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import {
fuzzyFilterSort,
ScorableTextItem,
} from "../../../common/string/filter/sequence-matching";
import "../../../components/ha-combo-box";
import type { HaComboBox } from "../../../components/ha-combo-box";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import {
CategoryRegistryEntry,
createCategoryRegistryEntry,
subscribeCategoryRegistry,
} from "../../../data/category_registry";
import {
showAlertDialog,
showPromptDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant, ValueChangedEvent } from "../../../types";
type ScorableCategoryRegistryEntry = ScorableTextItem & CategoryRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<CategoryRegistryEntry> = (item) =>
html`<ha-list-item
graphic="icon"
class=${classMap({ "add-new": item.category_id === "add_new" })}
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`;
@customElement("ha-category-picker")
export class HaCategoryPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public scope?: string;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _categories?: CategoryRegistryEntry[];
@query("ha-combo-box", true) public comboBox!: HaComboBox;
protected hassSubscribeRequiredHostProps = ["scope"];
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
return [
subscribeCategoryRegistry(
this.hass.connection,
this.scope!,
(categories) => {
this._categories = categories;
}
),
];
}
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _getCategories = memoizeOne(
(
categories: CategoryRegistryEntry[] | undefined,
noAdd: this["noAdd"]
): CategoryRegistryEntry[] => {
const result = categories ? [...categories] : [];
if (!result?.length) {
result.push({
category_id: "no_categories",
name: this.hass.localize(
"ui.components.category-picker.no_categories"
),
icon: null,
});
}
return noAdd
? result
: [
...result,
{
category_id: "add_new",
name: this.hass.localize("ui.components.category-picker.add_new"),
icon: "mdi:plus",
},
];
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass && this._categories) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const categories = this._getCategories(this._categories, this.noAdd);
this.comboBox.items = categories;
this.comboBox.filteredItems = categories;
}
}
protected render() {
if (!this._categories) {
return nothing;
}
return html`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="category_id"
item-id-path="category_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.category-picker.category")
: this.label}
.placeholder=${this.placeholder}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._categoryChanged}
>
</ha-combo-box>
`;
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableCategoryRegistryEntry>(
filterString,
target.items || []
);
if (!this.noAdd && filteredItems?.length === 0) {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
category_id: "add_new_suggestion",
name: this.hass.localize(
"ui.components.category-picker.add_new_sugestion",
{ name: this._suggestion }
),
picture: null,
},
];
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _categoryChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === "no_categories") {
newValue = "";
}
if (!["add_new_suggestion", "add_new"].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(ev.target as any).value = this._value;
showPromptDialog(this, {
title: this.hass.localize(
"ui.components.category-picker.add_dialog.title"
),
text: this.hass.localize("ui.components.category-picker.add_dialog.text"),
confirmText: this.hass.localize(
"ui.components.category-picker.add_dialog.add"
),
inputLabel: this.hass.localize(
"ui.components.category-picker.add_dialog.name"
),
defaultValue:
newValue === "add_new_suggestion" ? this._suggestion : undefined,
confirm: async (name) => {
if (!name) {
return;
}
try {
const category = await createCategoryRegistryEntry(
this.hass,
this.scope!,
{
name,
}
);
this._categories = [...this._categories!, category];
this.comboBox.filteredItems = this._getCategories(
this._categories,
this.noAdd
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(category.category_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.category-picker.add_dialog.failed_create_category"
),
text: err.message,
});
}
},
cancel: () => {
this._setValue(undefined);
this._suggestion = undefined;
this.comboBox.setInputValue("");
},
});
}
private _setValue(value?: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-category-picker": HaCategoryPicker;
}
}

View File

@ -0,0 +1,21 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { EntityRegistryEntry } from "../../../data/entity_registry";
export interface AssignCategoryDialogParams {
entityReg: EntityRegistryEntry;
scope: string;
}
export const loadAssignCategoryDialog = () =>
import("./dialog-assign-category");
export const showAssignCategoryDialog = (
element: HTMLElement,
dialogParams: AssignCategoryDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-assign-category",
dialogImport: loadAssignCategoryDialog,
dialogParams,
});
};

View File

@ -0,0 +1,21 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { CategoryRegistryEntry } from "../../../data/category_registry";
export interface CategoryRegistryDetailDialogParams {
entry?: CategoryRegistryEntry;
scope: string;
}
export const loadCategoryRegistryDetailDialog = () =>
import("./dialog-category-registry-detail");
export const showCategoryRegistryDetailDialog = (
element: HTMLElement,
dialogParams: CategoryRegistryDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-category-registry-detail",
dialogImport: loadCategoryRegistryDetailDialog,
dialogParams,
});
};

View File

@ -737,6 +737,7 @@ export class HaConfigEntities extends LitElement {
has_entity_name: false,
options: null,
labels: [],
categories: {},
});
}
if (changed) {

View File

@ -166,6 +166,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
sortable: true,
width: "25%",
filterable: true,
groupable: true,
};
columns.editable = {
title: "",

View File

@ -70,6 +70,7 @@ import "./ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
import "./ha-disabled-config-entry-card";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/search-input-outlined";
export interface ConfigEntryExtended extends ConfigEntry {
localized_domain_name?: string;
@ -327,15 +328,16 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
${this.narrow
? html`
<div slot="header">
<search-input
<search-input-outlined
class="header"
.hass=${this.hass}
.filter=${this._filter}
class="header"
@value-changed=${this._handleSearchChange}
.label=${this.hass.localize(
"ui.panel.config.integrations.search"
)}
></search-input>
>
</search-input-outlined>
</div>
${filterMenu}
`
@ -345,16 +347,17 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
slot="toolbar-icon"
></ha-integration-overflow-menu>
<div class="search">
<search-input
<search-input-outlined
class="header"
.hass=${this.hass}
suffix
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
.label=${this.hass.localize(
"ui.panel.config.integrations.search"
)}
>
<div class="filters" slot="suffix">
</search-input-outlined>
<div class="filters">
${!this._showDisabled && disabledConfigEntries.length
? html`<div
class="active-filters"
@ -374,7 +377,6 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
: ""}
${filterMenu}
</div>
</search-input>
</div>
`}
${this._showIgnored
@ -810,36 +812,23 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
.empty-message h1 {
margin: 0;
}
search-input {
--mdc-text-field-fill-color: var(--sidebar-background-color);
--mdc-text-field-idle-line-color: var(--divider-color);
--text-field-overflow: visible;
}
search-input.header {
display: block;
color: var(--secondary-text-color);
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
direction: var(--direction);
--mdc-ripple-color: transparant;
search-input-outlined {
flex: 1;
}
.search {
display: flex;
justify-content: flex-end;
justify-content: space-between;
width: 100%;
align-items: center;
height: 56px;
position: sticky;
top: 0;
z-index: 2;
}
.search search-input {
display: block;
position: absolute;
top: 0;
right: 0;
left: 0;
background-color: var(--primary-background-color);
padding: 0 16px;
gap: 16px;
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
}
.filters {
--mdc-text-field-fill-color: var(--input-fill-color);
@ -848,6 +837,7 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
--text-field-overflow: initial;
display: flex;
justify-content: flex-end;
align-items: center;
color: var(--primary-text-color);
}
.active-filters {
@ -865,6 +855,7 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
width: max-content;
cursor: initial;
direction: var(--direction);
height: 32px;
}
.active-filters mwc-button {
margin-left: 8px;

View File

@ -27,7 +27,6 @@ import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button-related-filter-menu";
import "../../../components/ha-fab";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
@ -76,8 +75,6 @@ class HaSceneDashboard extends LitElement {
@state() private _filteredScenes?: string[] | null;
@state() private _filterValue?;
private _scenes = memoizeOne(
(scenes: SceneEntity[], filteredScenes?: string[] | null): SceneItem[] => {
if (filteredScenes === null) {
@ -242,15 +239,6 @@ class HaSceneDashboard extends LitElement {
.label=${this.hass.localize("ui.common.help")}
.path=${mdiHelpCircle}
></ha-icon-button>
<ha-button-related-filter-menu
slot="filter-menu"
.narrow=${this.narrow}
.hass=${this.hass}
.value=${this._filterValue}
exclude-domains='["scene"]'
@related-changed=${this._relatedFilterChanged}
>
</ha-button-related-filter-menu>
${!this.scenes.length
? html`<div class="empty" slot="empty">
<ha-svg-icon .path=${mdiPalette}></ha-svg-icon>
@ -295,20 +283,9 @@ class HaSceneDashboard extends LitElement {
}
}
private _relatedFilterChanged(ev: CustomEvent) {
this._filterValue = ev.detail.value;
if (!this._filterValue) {
this._clearFilter();
return;
}
this._activeFilters = [ev.detail.filter];
this._filteredScenes = ev.detail.items.scene || null;
}
private _clearFilter() {
this._filteredScenes = undefined;
this._activeFilters = undefined;
this._filterValue = undefined;
}
private _showInfo(scene: SceneEntity) {

View File

@ -30,7 +30,6 @@ import {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button-related-filter-menu";
import "../../../components/ha-fab";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
@ -83,8 +82,6 @@ class HaScriptPicker extends LitElement {
@state() private _filteredScripts?: string[] | null;
@state() private _filterValue?;
private _scripts = memoizeOne(
(
scripts: ScriptEntity[],
@ -266,15 +263,6 @@ class HaScriptPicker extends LitElement {
.path=${mdiHelpCircle}
@click=${this._showHelp}
></ha-icon-button>
<ha-button-related-filter-menu
slot="filter-menu"
.narrow=${this.narrow}
.hass=${this.hass}
.value=${this._filterValue}
exclude-domains='["script"]'
@related-changed=${this._relatedFilterChanged}
>
</ha-button-related-filter-menu>
${!this.scripts.length
? html` <div class="empty" slot="empty">
<ha-svg-icon .path=${mdiScriptText}></ha-svg-icon>
@ -345,20 +333,9 @@ class HaScriptPicker extends LitElement {
];
}
private _relatedFilterChanged(ev: CustomEvent) {
this._filterValue = ev.detail.value;
if (!this._filterValue) {
this._clearFilter();
return;
}
this._activeFilters = [ev.detail.filter];
this._filteredScripts = ev.detail.items.script || null;
}
private _clearFilter() {
this._filteredScripts = undefined;
this._activeFilters = undefined;
this._filterValue = undefined;
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {

View File

@ -542,6 +542,7 @@ export class VoiceAssistantsExpose extends LitElement {
)}
.filter=${this._filter}
selectable
.selected=${this._selectedEntities.length}
clickable
@selection-changed=${this._handleSelectionChanged}
@clear-filter=${this._clearFilter}
@ -559,12 +560,6 @@ export class VoiceAssistantsExpose extends LitElement {
})}
slot="header"
>
<p class="selected-txt">
${this.hass.localize(
"ui.panel.config.entities.picker.selected",
{ number: this._selectedEntities.length }
)}
</p>
<div class="header-btns">
${!this.narrow
? html`

View File

@ -499,6 +499,14 @@
"add_entity_id": "Choose entity",
"add_label_id": "Choose label"
},
"subpage-data-table": {
"filters": "Filters",
"sort_by": "Sort by {sortColumn}",
"group_by": "Group by {groupColumn}",
"dont_group_by": "Don't group",
"select": "Select",
"selected": "Selected {selected}"
},
"config-entry-picker": {
"config_entry": "Integration"
},
@ -547,6 +555,23 @@
"device": "Device",
"no_area": "No area"
},
"category-picker": {
"clear": "Clear",
"show_categories": "Show categories",
"categories": "Categories",
"category": "Category",
"add_category": "Add category",
"add_new_sugestion": "Add new category ''{name}''",
"add_new": "Add new category…",
"no_categories": "You don't have any categories",
"add_dialog": {
"title": "Add new category",
"text": "Enter the name of the new category.",
"name": "Name",
"add": "Add",
"failed_create_category": "Failed to create category."
}
},
"label-picker": {
"clear": "Clear",
"show_labels": "Show labels",
@ -555,14 +580,7 @@
"add_new_sugestion": "Add new label ''{name}''",
"add_new": "Add new label…",
"no_labels": "You don't have any labels",
"no_match": "No matching labels found",
"add_dialog": {
"title": "Add new label",
"text": "Enter the name of the new label.",
"name": "Name",
"add": "Add",
"failed_create_label": "Failed to create label."
}
"no_match": "No matching labels found"
},
"area-picker": {
"clear": "Clear",
@ -1924,6 +1942,29 @@
"aliases_description": "Aliases are alternative names used in voice assistants to refer to this floor."
}
},
"category": {
"caption": "Categories",
"assign": {
"edit": "Edit category",
"assign": "Assign category",
"unknown_error": "An unknown error happened when assigning the category"
},
"editor": {
"edit": "Edit category",
"delete": "Delete category",
"add": "Add category",
"create": "Create category",
"name": "Name",
"icon": "Icon",
"required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]",
"unknown_error": "An unknown error happened when saving the category",
"confirm_delete": "Are you sure you want to delete this category?",
"confirm_delete_text": "This will delete the category and unassign everything that is currently assigned to it."
},
"filter": {
"show_all": "Show all"
}
},
"labels": {
"caption": "Labels",
"description": "Group devices and entities",
@ -2632,14 +2673,20 @@
"delete_confirm_text": "{name} will be permanently deleted.",
"duplicate": "[%key:ui::common::duplicate%]",
"disabled": "Disabled",
"state": "State",
"filtered_by_blueprint": "blueprint: {name}",
"traces_not_available": "[%key:ui::panel::config::automation::editor::traces_not_available%]",
"edit_category": "Edit category",
"assign_category": "Assign category",
"no_category_support": "You can't assign an category to this automation",
"no_category_entity_reg": "To assign an category to an automation it needs to have a unique ID.",
"headers": {
"toggle": "Enable/disable",
"name": "Name",
"trigger": "Trigger",
"actions": "Actions",
"state": "State"
"state": "State",
"category": "Category"
},
"empty_header": "Start automating",
"empty_text_1": "Automations make Home Assistant automatically respond to things happening in and around your home.",
@ -3965,6 +4012,7 @@
},
"status": {
"restored": "Restored",
"available": "Available",
"unavailable": "Unavailable",
"disabled": "Disabled",
"readonly": "Read-only",