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

View File

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

View File

@ -4,22 +4,32 @@ import { css, html } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
@customElement("ha-assist-chip") @customElement("ha-assist-chip")
// @ts-ignore
export class HaAssistChip extends MdAssistChip { export class HaAssistChip extends MdAssistChip {
@property({ type: Boolean, reflect: true }) filled = false; @property({ type: Boolean, reflect: true }) filled = false;
@property({ type: Boolean }) active = false;
static override styles = [ static override styles = [
...super.styles, ...super.styles,
css` css`
:host { :host {
--md-sys-color-primary: var(--primary-text-color); --md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: 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-outline-color: var(--outline-color);
--md-assist-chip-label-text-weight: 400; --md-assist-chip-label-text-weight: 400;
--ha-assist-chip-filled-container-color: rgba( --ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color), var(--rgb-primary-text-color),
0.15 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 **/ /** Material 3 doesn't have a filled chip, so we have to make our own **/
.filled { .filled {
@ -31,10 +41,21 @@ export class HaAssistChip extends MdAssistChip {
background-color: var(--ha-assist-chip-filled-container-color); background-color: var(--ha-assist-chip-filled-container-color);
} }
/** Set the size of mdc icons **/ /** Set the size of mdc icons **/
::slotted([slot="icon"]) { ::slotted([slot="icon"]),
::slotted([slot="trailingIcon"]) {
display: flex; display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px); --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(); 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 { 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 "../ha-svg-icon";
import "../search-input"; import "../search-input";
import { filterData, sortData } from "./sort-filter"; import { filterData, sortData } from "./sort-filter";
import { groupBy } from "../../common/util/group-by";
declare global { declare global {
// for fire event // for fire event
@ -67,13 +68,20 @@ export interface DataTableSortColumnData {
filterKey?: string; filterKey?: string;
valueColumn?: string; valueColumn?: string;
direction?: SortingDirection; direction?: SortingDirection;
groupable?: boolean;
} }
export interface DataTableColumnData<T = any> extends DataTableSortColumnData { export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
main?: boolean; main?: boolean;
title: TemplateResult | string; title: TemplateResult | string;
label?: 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; template?: (row: T) => TemplateResult | string | typeof nothing;
width?: string; width?: string;
maxWidth?: string; maxWidth?: string;
@ -95,6 +103,8 @@ export interface SortableColumnContainer {
[key: string]: ClonedDataTableColumnData; [key: string]: ClonedDataTableColumnData;
} }
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
@customElement("ha-data-table") @customElement("ha-data-table")
export class HaDataTable extends LitElement { export class HaDataTable extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -129,14 +139,16 @@ export class HaDataTable extends LitElement {
@property({ type: String }) public filter = ""; @property({ type: String }) public filter = "";
@property() public groupColumn?: string;
@property() public sortColumn?: string;
@property() public sortDirection: SortingDirection = null;
@state() private _filterable = false; @state() private _filterable = false;
@state() private _filter = ""; @state() private _filter = "";
@state() private _sortColumn?: string;
@state() private _sortDirection: SortingDirection = null;
@state() private _filteredData: DataTableRowData[] = []; @state() private _filteredData: DataTableRowData[] = [];
@state() private _headerHeight = 0; @state() private _headerHeight = 0;
@ -195,8 +207,14 @@ export class HaDataTable extends LitElement {
for (const columnId in this.columns) { for (const columnId in this.columns) {
if (this.columns[columnId].direction) { if (this.columns[columnId].direction) {
this._sortDirection = this.columns[columnId].direction!; this.sortDirection = this.columns[columnId].direction!;
this._sortColumn = columnId; this.sortColumn = columnId;
fireEvent(this, "sorting-changed", {
column: columnId,
direction: this.sortDirection,
});
break; break;
} }
} }
@ -226,11 +244,16 @@ export class HaDataTable extends LitElement {
properties.has("data") || properties.has("data") ||
properties.has("columns") || properties.has("columns") ||
properties.has("_filter") || properties.has("_filter") ||
properties.has("_sortColumn") || properties.has("sortColumn") ||
properties.has("_sortDirection") properties.has("sortDirection") ||
properties.has("groupColumn")
) { ) {
this._sortFilterData(); this._sortFilterData();
} }
if (properties.has("selectable")) {
this._items = [...this._items];
}
} }
protected render() { protected render() {
@ -263,75 +286,79 @@ export class HaDataTable extends LitElement {
})} })}
> >
<div class="mdc-data-table__header-row" role="row" aria-rowindex="1"> <div class="mdc-data-table__header-row" role="row" aria-rowindex="1">
${this.selectable <slot name="header-row">
? html` ${this.selectable
<div ? html`
class="mdc-data-table__header-cell mdc-data-table__header-cell--checkbox" <div
role="columnheader" class="mdc-data-table__header-cell mdc-data-table__header-cell--checkbox"
> role="columnheader"
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${this._checkedRows.length &&
this._checkedRows.length === this._checkableRowsCount}
> >
</ha-checkbox> <ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleHeaderRowCheckboxClick}
.indeterminate=${this._checkedRows.length &&
this._checkedRows.length !== this._checkableRowsCount}
.checked=${this._checkedRows.length &&
this._checkedRows.length === this._checkableRowsCount}
>
</ha-checkbox>
</div>
`
: ""}
${Object.entries(this.columns).map(([key, column]) => {
if (column.hidden) {
return "";
}
const sorted = key === this.sortColumn;
const classes = {
"mdc-data-table__header-cell--numeric":
column.type === "numeric",
"mdc-data-table__header-cell--icon": column.type === "icon",
"mdc-data-table__header-cell--icon-button":
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),
};
return html`
<div
aria-label=${ifDefined(column.label)}
class="mdc-data-table__header-cell ${classMap(classes)}"
style=${column.width
? styleMap({
[column.grows ? "minWidth" : "width"]: column.width,
maxWidth: column.maxWidth || "",
})
: ""}
role="columnheader"
aria-sort=${ifDefined(
sorted
? this.sortDirection === "desc"
? "descending"
: "ascending"
: undefined
)}
@click=${this._handleHeaderClick}
.columnId=${key}
>
${column.sortable
? html`
<ha-svg-icon
.path=${sorted && this.sortDirection === "desc"
? mdiArrowDown
: mdiArrowUp}
></ha-svg-icon>
`
: ""}
<span>${column.title}</span>
</div> </div>
` `;
: ""} })}
${Object.entries(this.columns).map(([key, column]) => { </slot>
if (column.hidden) {
return "";
}
const sorted = key === this._sortColumn;
const classes = {
"mdc-data-table__header-cell--numeric":
column.type === "numeric",
"mdc-data-table__header-cell--icon": column.type === "icon",
"mdc-data-table__header-cell--icon-button":
column.type === "icon-button",
"mdc-data-table__header-cell--overflow-menu":
column.type === "overflow-menu",
sortable: Boolean(column.sortable),
"not-sorted": Boolean(column.sortable && !sorted),
grows: Boolean(column.grows),
};
return html`
<div
aria-label=${ifDefined(column.label)}
class="mdc-data-table__header-cell ${classMap(classes)}"
style=${column.width
? styleMap({
[column.grows ? "minWidth" : "width"]: column.width,
maxWidth: column.maxWidth || "",
})
: ""}
role="columnheader"
aria-sort=${ifDefined(
sorted
? this._sortDirection === "desc"
? "descending"
: "ascending"
: undefined
)}
@click=${this._handleHeaderClick}
.columnId=${key}
>
${column.sortable
? html`
<ha-svg-icon
.path=${sorted && this._sortDirection === "desc"
? mdiArrowDown
: mdiArrowUp}
></ha-svg-icon>
`
: ""}
<span>${column.title}</span>
</div>
`;
})}
</div> </div>
${!this._filteredData.length ${!this._filteredData.length
? html` ? html`
@ -408,7 +435,7 @@ export class HaDataTable extends LitElement {
: ""} : ""}
${Object.entries(this.columns).map(([key, column]) => { ${Object.entries(this.columns).map(([key, column]) => {
if (column.hidden) { if (column.hidden) {
return ""; return nothing;
} }
return html` return html`
<div <div
@ -421,6 +448,7 @@ export class HaDataTable extends LitElement {
column.type === "icon-button", column.type === "icon-button",
"mdc-data-table__cell--overflow-menu": "mdc-data-table__cell--overflow-menu":
column.type === "overflow-menu", column.type === "overflow-menu",
"mdc-data-table__cell--overflow": column.type === "overflow",
grows: Boolean(column.grows), grows: Boolean(column.grows),
forceLTR: Boolean(column.forceLTR), forceLTR: Boolean(column.forceLTR),
})}" })}"
@ -453,12 +481,12 @@ export class HaDataTable extends LitElement {
); );
} }
const prom = this._sortColumn const prom = this.sortColumn
? sortData( ? sortData(
filteredData, filteredData,
this._sortColumns[this._sortColumn], this._sortColumns[this.sortColumn],
this._sortDirection, this.sortDirection,
this._sortColumn, this.sortColumn,
this.hass.locale.language this.hass.locale.language
) )
: filteredData; : filteredData;
@ -477,7 +505,7 @@ export class HaDataTable extends LitElement {
return; return;
} }
if (this.appendRow || this.hasFab) { if (this.appendRow || this.hasFab || this.groupColumn) {
const items = [...data]; const items = [...data];
if (this.appendRow) { if (this.appendRow) {
@ -487,7 +515,41 @@ export class HaDataTable extends LitElement {
if (this.hasFab) { if (this.hasFab) {
items.push({ empty: true }); items.push({ empty: true });
} }
this._items = items;
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 { } else {
this._items = data; this._items = data;
} }
@ -507,19 +569,19 @@ export class HaDataTable extends LitElement {
if (!this.columns[columnId].sortable) { if (!this.columns[columnId].sortable) {
return; return;
} }
if (!this._sortDirection || this._sortColumn !== columnId) { if (!this.sortDirection || this.sortColumn !== columnId) {
this._sortDirection = "asc"; this.sortDirection = "asc";
} else if (this._sortDirection === "asc") { } else if (this.sortDirection === "asc") {
this._sortDirection = "desc"; this.sortDirection = "desc";
} else { } 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", { fireEvent(this, "sorting-changed", {
column: columnId, column: columnId,
direction: this._sortDirection, direction: this.sortDirection,
}); });
} }
@ -552,8 +614,15 @@ export class HaDataTable extends LitElement {
}; };
private _handleRowClick = (ev: Event) => { private _handleRowClick = (ev: Event) => {
const target = ev.target as HTMLElement; if (
if (["HA-CHECKBOX", "MWC-BUTTON"].includes(target.tagName)) { ev
.composedPath()
.find((el) =>
["ha-checkbox", "mwc-button", "ha-button", "ha-assist-chip"].includes(
(el as HTMLElement).localName
)
)
) {
return; return;
} }
const rowId = (ev.currentTarget as any).rowId; const rowId = (ev.currentTarget as any).rowId;
@ -629,7 +698,7 @@ export class HaDataTable extends LitElement {
.mdc-data-table__row { .mdc-data-table__row {
display: flex; display: flex;
width: 100%; width: 100%;
height: 52px; height: var(--data-table-row-height, 52px);
} }
.mdc-data-table__row ~ .mdc-data-table__row { .mdc-data-table__row ~ .mdc-data-table__row {
@ -655,7 +724,6 @@ export class HaDataTable extends LitElement {
display: flex; display: flex;
width: 100%; width: 100%;
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
overflow-x: auto;
} }
.mdc-data-table__header-row::-webkit-scrollbar { .mdc-data-table__header-row::-webkit-scrollbar {
@ -809,7 +877,9 @@ export class HaDataTable extends LitElement {
padding-inline-start: initial; padding-inline-start: initial;
} }
.mdc-data-table__cell--overflow-menu, .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; overflow: initial;
} }
.mdc-data-table__cell--icon-button a { .mdc-data-table__cell--icon-button a {
@ -839,6 +909,12 @@ export class HaDataTable extends LitElement {
/* custom from here */ /* custom from here */
.group-header {
padding-top: 12px;
width: 100%;
font-weight: 500;
}
:host { :host {
display: block; display: block;
} }

View File

@ -39,25 +39,21 @@ interface FloorAreaEntry {
icon: string | null; icon: string | null;
strings: string[]; strings: string[];
type: "floor" | "area"; type: "floor" | "area";
hasFloor?: boolean;
} }
const rowRenderer: ComboBoxLitRenderer<FloorAreaEntry> = (item) => const rowRenderer: ComboBoxLitRenderer<FloorAreaEntry> = (item) =>
item.type === "floor" html`<ha-list-item
? html`<ha-list-item graphic="icon" class="floor"> graphic="icon"
${item.icon style=${item.type === "area" && item.hasFloor
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>` ? "--mdc-list-side-padding-left: 48px;"
: nothing} : ""}
${item.name} >
</ha-list-item>` ${item.icon
: html`<ha-list-item ? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
graphic="icon" : nothing}
style="--mdc-list-side-padding-left: 48px;" ${item.name}
> </ha-list-item>`;
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`;
@customElement("ha-area-floor-picker") @customElement("ha-area-floor-picker")
export class HaAreaFloorPicker extends SubscribeMixin(LitElement) { export class HaAreaFloorPicker extends SubscribeMixin(LitElement) {
@ -363,6 +359,7 @@ export class HaAreaFloorPicker extends SubscribeMixin(LitElement) {
name: area.name, name: area.name,
icon: area.icon, icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name], 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) { protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps); super.willUpdate(changedProps);
if (changedProps.has("expanded") && this.expanded) { if (changedProps.has("expanded")) {
this._showContent = this.expanded; this._showContent = this.expanded;
setTimeout(() => { setTimeout(() => {
// Verify we're still expanded // Verify we're still expanded
if (this.expanded) { this._container.style.overflow = this.expanded ? "initial" : "hidden";
this._container.style.overflow = "initial";
}
}, 300); }, 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; unique_id: string;
translation_key?: string; translation_key?: string;
options: EntityRegistryOptions | null; options: EntityRegistryOptions | null;
categories: { [scope: string]: string };
} }
export interface ExtEntityRegistryEntry extends EntityRegistryEntry { export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
@ -137,6 +138,7 @@ export interface EntityRegistryEntryUpdateParams {
| LightEntityOptions; | LightEntityOptions;
aliases?: string[]; aliases?: string[];
labels?: string[]; labels?: string[];
categories?: { [scope: string]: string | null };
} }
const batteryPriorities = ["sensor", "binary_sensor"]; const batteryPriorities = ["sensor", "binary_sensor"];

View File

@ -26,6 +26,7 @@ export type ItemType =
| "config_entry" | "config_entry"
| "device" | "device"
| "entity" | "entity"
| "floor"
| "group" | "group"
| "scene" | "scene"
| "script" | "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 "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import "@material/mwc-button/mwc-button";
import { customElement, property, query } from "lit/decorators"; 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 { fireEvent } from "../common/dom/fire_event";
import { LocalizeFunc } from "../common/translations/localize"; 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 "../components/data-table/ha-data-table";
import type { import type {
DataTableColumnContainer, DataTableColumnContainer,
DataTableRowData, DataTableRowData,
HaDataTable, HaDataTable,
SortingDirection,
} from "../components/data-table/ha-data-table"; } from "../components/data-table/ha-data-table";
import "../components/ha-dialog";
import "../components/search-input-outlined";
import type { HomeAssistant, Route } from "../types"; import type { HomeAssistant, Route } from "../types";
import "./hass-tabs-subpage"; import "./hass-tabs-subpage";
import type { PageNavigation } from "./hass-tabs-subpage"; import type { PageNavigation } from "./hass-tabs-subpage";
@ -87,22 +109,16 @@ export class HaTabsSubpageDataTable extends LitElement {
@property() public searchLabel?: string; @property() public searchLabel?: string;
/** /**
* List of strings that show what the data is currently filtered by. * Number of active filters.
* @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.
* @type {Number} * @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. * 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[] = []; @property({ attribute: false }) public tabs: PageNavigation[] = [];
/** /**
* Force hides the filter menu. * Show the filter menu.
* @type {Boolean} * @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; @query("ha-data-table", true) private _dataTable!: HaDataTable;
private _showPaneController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width > 750,
});
public clearSelection() { public clearSelection() {
this._dataTable.clearSelection(); this._dataTable.clearSelection();
} }
protected firstUpdated() {
if (this.initialGroupColumn) {
this._groupColumn = this.initialGroupColumn;
}
}
protected render(): TemplateResult { protected render(): TemplateResult {
const hiddenLabel = this.numHidden const localize = this.localizeFunc || this.hass.localize;
? this.hiddenLabel || const showPane = this._showPaneController.value ?? !this.narrow;
this.hass.localize("ui.components.data-table.hidden", { const filterButton = this.hasFilters
number: this.numHidden, ? html`<div class="relative">
}) || <ha-assist-chip
this.numHidden .label=${localize("ui.components.subpage-data-table.filters")}
: undefined; .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 const selectModeBtn =
? html`${this.hass.localize("ui.components.data-table.filtering_by")} this.selectable && !this._selectMode
${this.activeFilters.join(", ")} ? html`<ha-assist-chip
${hiddenLabel ? `(${hiddenLabel})` : ""}` class="has-dropdown select-mode-chip"
: hiddenLabel; .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} .hass=${this.hass}
.filter=${this.filter} .filter=${this.filter}
.suffix=${!this.narrow}
@value-changed=${this._handleSearchChange} @value-changed=${this._handleSearchChange}
.label=${this.searchLabel} .label=${this.searchLabel}
.placeholder=${this.searchLabel}
> >
${!this.narrow </search-input-outlined>`;
? html`<div
class="filters" const sortByMenu = Object.values(this.columns).find((col) => col.sortable)
slot="suffix" ? html`<ha-button-menu fixed>
@click=${this._preventDefault} <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 <ha-svg-icon slot="trailing-icon" .path=${mdiMenuDown}></ha-svg-icon
? html`<div class="active-filters"> ></ha-assist-chip>
${filterInfo} ${Object.entries(this.columns).map(([id, column]) =>
<mwc-button @click=${this._clearFilter}> column.sortable
${this.hass.localize("ui.components.data-table.clear")} ? html`<ha-list-item
</mwc-button> .value=${id}
</div>` @request-selected=${this._handleSortBy}
: ""} hasMeta
<slot name="filter-menu"></slot> .activated=${id === this._sortColumn}
</div>` >
: ""} ${this._sortColumn === id
</search-input>`; ? 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` return html`
<hass-tabs-subpage <hass-tabs-subpage
@ -202,34 +307,89 @@ export class HaTabsSubpageDataTable extends LitElement {
.tabs=${this.tabs} .tabs=${this.tabs}
.mainPage=${this.mainPage} .mainPage=${this.mainPage}
.supervisor=${this.supervisor} .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 ${this.empty
? html`<div class="center"> ? html`<div class="center">
<slot name="empty">${this.noDataText}</slot> <slot name="empty">${this.noDataText}</slot>
</div>` </div>`
: html`${!this.hideFilterMenu : html`<div slot="toolbar-icon">
? html` <slot name="toolbar-icon"></slot>
<div slot="toolbar-icon"> </div>
${this.narrow
? html`
<div class="filter-menu">
${this.numHidden || this.activeFilters
? html`<span class="badge"
>${this.numHidden || "!"}</span
>`
: ""}
<slot name="filter-menu"></slot>
</div>
`
: ""}<slot name="toolbar-icon"></slot>
</div>
`
: ""}
${this.narrow ${this.narrow
? html` ? html`
<div slot="header"> <div slot="header">
<slot name="header"> <slot name="header">
<div class="search-toolbar">${headerToolbar}</div> <div class="search-toolbar">${searchBar}</div>
</slot> </slot>
</div> </div>
` `
@ -240,30 +400,76 @@ export class HaTabsSubpageDataTable extends LitElement {
.data=${this.data} .data=${this.data}
.noDataText=${this.noDataText} .noDataText=${this.noDataText}
.filter=${this.filter} .filter=${this.filter}
.selectable=${this.selectable} .selectable=${this._selectMode}
.hasFab=${this.hasFab} .hasFab=${this.hasFab}
.id=${this.id} .id=${this.id}
.clickable=${this.clickable} .clickable=${this.clickable}
.appendRow=${this.appendRow} .appendRow=${this.appendRow}
.sortColumn=${this._sortColumn}
.sortDirection=${this._sortDirection}
.groupColumn=${this._groupColumn}
> >
${!this.narrow ${!this.narrow
? html` ? html`
<div slot="header"> <div slot="header">
<slot name="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> </slot>
</div> </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>`} </ha-data-table>`}
<div slot="fab"><slot name="fab"></slot></div> <div slot="fab"><slot name="fab"></slot></div>
</hass-tabs-subpage> </hass-tabs-subpage>
`; `;
} }
private _preventDefault(ev) { private _clearFilters() {
ev.preventDefault(); 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) { private _handleSearchChange(ev: CustomEvent) {
@ -274,54 +480,56 @@ export class HaTabsSubpageDataTable extends LitElement {
fireEvent(this, "search-changed", { value: this.filter }); fireEvent(this, "search-changed", { value: this.filter });
} }
private _clearFilter() {
fireEvent(this, "clear-filter");
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
:host {
display: block;
}
ha-data-table { ha-data-table {
width: 100%; width: 100%;
height: 100%; height: 100%;
--data-table-border-width: 0; --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)); height: calc(100vh - 1px - var(--header-height));
display: block; display: block;
} }
.pane-content {
height: calc(100vh - 1px - var(--header-height) - var(--header-height));
display: flex;
flex-direction: column;
}
:host([narrow]) hass-tabs-subpage { :host([narrow]) hass-tabs-subpage {
--main-title-margin: 0; --main-title-margin: 0;
} }
:host([narrow]) {
--expansion-panel-summary-padding: 0 16px;
}
.table-header { .table-header {
display: flex; display: flex;
align-items: center; align-items: center;
--mdc-shape-small: 0; --mdc-shape-small: 0;
height: 56px; 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 { .search-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
color: var(--secondary-text-color); 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 { .filters {
--mdc-text-field-fill-color: var(--input-fill-color); --mdc-text-field-fill-color: var(--input-fill-color);
--mdc-text-field-idle-line-color: var(--input-idle-line-color); --mdc-text-field-idle-line-color: var(--input-idle-line-color);
@ -382,9 +590,6 @@ export class HaTabsSubpageDataTable extends LitElement {
top: 4px; top: 4px;
font-size: 0.65em; font-size: 0.65em;
} }
.filter-menu {
position: relative;
}
.center { .center {
display: flex; display: flex;
align-items: center; align-items: center;
@ -395,6 +600,92 @@ export class HaTabsSubpageDataTable extends LitElement {
width: 100%; width: 100%;
padding: 16px; 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, CSSResultGroup,
html, html,
LitElement, LitElement,
nothing,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
} from "lit"; } from "lit";
@ -57,6 +58,8 @@ class HassTabsSubpage extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "is-wide" }) @property({ type: Boolean, reflect: true, attribute: "is-wide" })
public isWide = false; public isWide = false;
@property({ type: Boolean }) public pane = false;
@state() private _activeTab?: PageNavigation; @state() private _activeTab?: PageNavigation;
// @ts-ignore // @ts-ignore
@ -128,49 +131,62 @@ class HassTabsSubpage extends LitElement {
const showTabs = tabs.length > 1; const showTabs = tabs.length > 1;
return html` return html`
<div class="toolbar"> <div class="toolbar">
${this.mainPage || (!this.backPath && history.state?.root) <slot name="toolbar">
? html` <div class="toolbar-content">
<ha-menu-button ${this.mainPage || (!this.backPath && history.state?.root)
.hassio=${this.supervisor} ? html`
.hass=${this.hass} <ha-menu-button
.narrow=${this.narrow} .hassio=${this.supervisor}
></ha-menu-button>
`
: this.backPath
? html`
<a href=${this.backPath}>
<ha-icon-button-arrow-prev
.hass=${this.hass} .hass=${this.hass}
></ha-icon-button-arrow-prev> .narrow=${this.narrow}
</a> ></ha-menu-button>
` `
: html` : this.backPath
<ha-icon-button-arrow-prev ? html`
.hass=${this.hass} <a href=${this.backPath}>
@click=${this._backTapped} <ha-icon-button-arrow-prev
></ha-icon-button-arrow-prev> .hass=${this.hass}
`} ></ha-icon-button-arrow-prev>
${this.narrow || !showTabs </a>
? html`<div class="main-title"> `
<slot name="header">${!showTabs ? tabs[0] : ""}</slot> : html`
</div>` <ha-icon-button-arrow-prev
.hass=${this.hass}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`}
${this.narrow || !showTabs
? html`<div class="main-title">
<slot name="header">${!showTabs ? tabs[0] : ""}</slot>
</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>`
: ""} : ""}
${showTabs
? html`
<div id="tabbar" class=${classMap({ "bottom-bar": this.narrow })}>
${tabs}
</div>
`
: ""}
<div id="toolbar-icon">
<slot name="toolbar-icon"></slot>
</div>
</div> </div>
<div <div class="container">
class="content ha-scrollbar ${classMap({ tabs: showTabs })}" ${this.pane
@scroll=${this._saveScrollPos} ? html`<div class="pane">
> <div class="shadow-container"></div>
<slot></slot> <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>
<div id="fab" class=${classMap({ tabs: showTabs })}> <div id="fab" class=${classMap({ tabs: showTabs })}>
<slot name="fab"></slot> <slot name="fab"></slot>
@ -206,6 +222,15 @@ class HassTabsSubpage extends LitElement {
position: fixed; position: fixed;
} }
.container {
display: flex;
height: calc(100% - var(--header-height));
}
:host([narrow]) .container {
height: 100%;
}
ha-menu-button { ha-menu-button {
margin-right: 24px; margin-right: 24px;
margin-inline-end: 24px; margin-inline-end: 24px;
@ -213,18 +238,22 @@ class HassTabsSubpage extends LitElement {
} }
.toolbar { .toolbar {
display: flex;
align-items: center;
font-size: 20px; font-size: 20px;
height: var(--header-height); height: var(--header-height);
background-color: var(--sidebar-background-color); background-color: var(--sidebar-background-color);
font-weight: 400; font-weight: 400;
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
box-sizing: border-box;
}
.toolbar-content {
padding: 8px 12px; padding: 8px 12px;
display: flex;
align-items: center;
height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@media (max-width: 599px) { @media (max-width: 599px) {
.toolbar { .toolbar-content {
padding: 4px; padding: 4px;
} }
} }
@ -297,10 +326,6 @@ class HassTabsSubpage extends LitElement {
margin-right: env(safe-area-inset-right); margin-right: env(safe-area-inset-right);
margin-inline-start: env(safe-area-inset-left); margin-inline-start: env(safe-area-inset-left);
margin-inline-end: env(safe-area-inset-right); 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; overflow: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
} }
@ -329,6 +354,21 @@ class HassTabsSubpage extends LitElement {
inset-inline-end: 24px; inset-inline-end: 24px;
inset-inline-start: initial; 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 "@material/web/divider/divider";
import { mdiClose, mdiContentPaste, mdiPlus } from "@mdi/js"; import { mdiClose, mdiContentPaste, mdiPlus } from "@mdi/js";
import Fuse, { IFuseOptions } from "fuse.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 { import {
mdiCancel,
mdiContentDuplicate, mdiContentDuplicate,
mdiDelete, mdiDelete,
mdiHelpCircle, mdiHelpCircle,
@ -9,34 +10,41 @@ import {
mdiPlus, mdiPlus,
mdiRobotHappy, mdiRobotHappy,
mdiStopCircleOutline, mdiStopCircleOutline,
mdiTag,
mdiTransitConnection, mdiTransitConnection,
} from "@mdi/js"; } from "@mdi/js";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; import { differenceInDays } from "date-fns/esm";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
css,
CSSResultGroup, CSSResultGroup,
html,
LitElement, LitElement,
nothing,
TemplateResult, TemplateResult,
css,
html,
nothing,
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; 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 { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { relativeTime } from "../../../common/datetime/relative_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 { computeStateName } from "../../../common/entity/compute_state_name";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/chips/ha-assist-chip";
import type { import type {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button-related-filter-menu"; import "../../../components/entity/ha-entity-toggle";
import "../../../components/ha-label";
import "../../../components/ha-fab"; 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-button";
import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
@ -49,28 +57,43 @@ import {
showAutomationEditor, showAutomationEditor,
triggerAutomationActions, triggerAutomationActions,
} from "../../../data/automation"; } 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 { import {
showAlertDialog, showAlertDialog,
showConfirmationDialog, showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box"; } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table"; import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url"; import { documentationUrl } from "../../../util/documentation-url";
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { showNewAutomationDialog } from "./show-dialog-new-automation"; import { showNewAutomationDialog } from "./show-dialog-new-automation";
import { findRelated } from "../../../data/search"; import "../../../components/data-table/ha-data-table-labels";
import { fetchBlueprints } from "../../../data/blueprint"; import {
import { UNAVAILABLE } from "../../../data/entity"; LabelRegistryEntry,
subscribeLabelRegistry,
} from "../../../data/label_registry";
import "../../../components/ha-filter-labels";
type AutomationItem = AutomationEntity & { type AutomationItem = AutomationEntity & {
name: string; name: string;
last_triggered?: string | undefined; last_triggered?: string | undefined;
disabled: boolean; formatted_state: string;
category: string | undefined;
labels: LabelRegistryEntry[];
}; };
@customElement("ha-automation-picker") @customElement("ha-automation-picker")
class HaAutomationPicker extends LitElement { class HaAutomationPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false; @property({ type: Boolean }) public isWide = false;
@ -81,17 +104,33 @@ class HaAutomationPicker extends LitElement {
@property({ attribute: false }) public automations!: AutomationEntity[]; @property({ attribute: false }) public automations!: AutomationEntity[];
@state() private _activeFilters?: string[];
@state() private _searchParms = new URLSearchParams(window.location.search); @state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _filteredAutomations?: string[] | null; @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( private _automations = memoizeOne(
( (
automations: AutomationEntity[], automations: AutomationEntity[],
entityReg: EntityRegistryEntry[],
categoryReg?: CategoryRegistryEntry[],
labelReg?: LabelRegistryEntry[],
filteredAutomations?: string[] | null filteredAutomations?: string[] | null
): AutomationItem[] => { ): AutomationItem[] => {
if (filteredAutomations === null) { if (filteredAutomations === null) {
@ -103,23 +142,38 @@ class HaAutomationPicker extends LitElement {
filteredAutomations!.includes(automation.entity_id) filteredAutomations!.includes(automation.entity_id)
) )
: automations : automations
).map((automation) => ({ ).map((automation) => {
...automation, const entityRegEntry = entityReg.find(
name: computeStateName(automation), (reg) => reg.entity_id === automation.entity_id
last_triggered: automation.attributes.last_triggered || undefined, );
disabled: automation.state === "off", const category = entityRegEntry?.categories.automation;
})); const labels = labelReg && entityRegEntry?.labels;
return {
...automation,
name: computeStateName(automation),
last_triggered: automation.attributes.last_triggered || undefined,
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( private _columns = memoizeOne(
(narrow: boolean, _locale): DataTableColumnContainer => { (
narrow: boolean,
localize: LocalizeFunc,
locale: HomeAssistant["locale"]
): DataTableColumnContainer => {
const columns: DataTableColumnContainer<AutomationItem> = { const columns: DataTableColumnContainer<AutomationItem> = {
icon: { icon: {
title: "", title: "",
label: this.hass.localize( label: localize("ui.panel.config.automation.picker.headers.state"),
"ui.panel.config.automation.picker.headers.state"
),
type: "icon", type: "icon",
template: (automation) => template: (automation) =>
html`<ha-state-icon html`<ha-state-icon
@ -134,95 +188,91 @@ class HaAutomationPicker extends LitElement {
></ha-state-icon>`, ></ha-state-icon>`,
}, },
name: { name: {
title: this.hass.localize( title: localize("ui.panel.config.automation.picker.headers.name"),
"ui.panel.config.automation.picker.headers.name"
),
main: true, main: true,
sortable: true, sortable: true,
filterable: true, filterable: true,
direction: "asc", direction: "asc",
grows: true, grows: true,
template: narrow
? (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">
${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>
`;
}
: undefined,
},
};
if (!narrow) {
columns.last_triggered = {
sortable: true,
width: "20%",
title: this.hass.localize("ui.card.automation.last_triggered"),
template: (automation) => { template: (automation) => {
if (!automation.last_triggered) { const date = new Date(automation.attributes.last_triggered);
return this.hass.localize("ui.components.relative_time.never");
}
const date = new Date(automation.last_triggered);
const now = new Date(); const now = new Date();
const dayDifference = differenceInDays(now, date); const dayDifference = differenceInDays(now, date);
return html` return html`
${dayDifference > 3 <div style="font-size: 14px;">${automation.name}</div>
? formatShortDateTime(date, this.hass.locale, this.hass.config) ${narrow
: relativeTime(date, this.hass.locale)} ? html`<div class="secondary">
${this.hass.localize("ui.card.automation.last_triggered")}:
${automation.attributes.last_triggered
? dayDifference > 3
? 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}
`; `;
}, },
},
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(" "),
},
};
columns.last_triggered = {
sortable: true,
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");
}
const date = new Date(automation.last_triggered);
const now = new Date();
const dayDifference = differenceInDays(now, date);
return html`
${dayDifference > 3
? formatShortDateTime(date, locale, this.hass.config)
: relativeTime(date, locale)}
`;
},
};
if (!this.narrow) {
columns.formatted_state = {
width: "82px",
sortable: true,
groupable: true,
title: "",
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.disabled = this.narrow
? {
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>
`
: "",
};
columns.actions = { columns.actions = {
title: "", title: "",
width: this.narrow ? undefined : "10%", width: "64px",
type: "overflow-menu", type: "overflow-menu",
template: (automation) => html` template: (automation) => html`
<ha-icon-overflow-menu <ha-icon-overflow-menu
@ -236,6 +286,13 @@ class HaAutomationPicker extends LitElement {
), ),
action: () => this._showInfo(automation), 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, path: mdiPlay,
label: this.hass.localize( 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 { protected render(): TemplateResult {
return html` return html`
<hass-tabs-subpage-data-table <hass-tabs-subpage-data-table
@ -301,9 +373,23 @@ class HaAutomationPicker extends LitElement {
id="entity_id" id="entity_id"
.route=${this.route} .route=${this.route}
.tabs=${configSections.automations} .tabs=${configSections.automations}
.activeFilters=${this._activeFilters} hasFilters
.columns=${this._columns(this.narrow, this.hass.locale)} .filters=${Object.values(this._filters).filter(
.data=${this._automations(this.automations, this._filteredAutomations)} (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} .empty=${!this.automations.length}
@row-click=${this._handleRowClicked} @row-click=${this._handleRowClicked}
.noDataText=${this.hass.localize( .noDataText=${this.hass.localize(
@ -312,6 +398,7 @@ class HaAutomationPicker extends LitElement {
@clear-filter=${this._clearFilter} @clear-filter=${this._clearFilter}
hasFab hasFab
clickable clickable
class=${this.narrow ? "narrow" : ""}
> >
<ha-icon-button <ha-icon-button
slot="toolbar-icon" slot="toolbar-icon"
@ -319,15 +406,65 @@ class HaAutomationPicker extends LitElement {
.path=${mdiHelpCircle} .path=${mdiHelpCircle}
@click=${this._showHelp} @click=${this._showHelp}
></ha-icon-button> ></ha-icon-button>
<ha-button-related-filter-menu <ha-filter-floor-areas
slot="filter-menu"
.narrow=${this.narrow}
.hass=${this.hass} .hass=${this.hass}
.value=${this._filterValue} .type=${"automation"}
exclude-domains='["automation"]' .value=${this._filters["ha-filter-floor-areas"]?.value}
@related-changed=${this._relatedFilterChanged} @data-table-filter-changed=${this._filterChanged}
> slot="filter-pane"
</ha-button-related-filter-menu> .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 ${!this.automations.length
? html`<div class="empty" slot="empty"> ? html`<div class="empty" slot="empty">
<ha-svg-icon .path=${mdiRobotHappy}></ha-svg-icon> <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() { private async _filterBlueprint() {
const blueprint = this._searchParms.get("blueprint"); const blueprint = this._searchParms.get("blueprint");
if (!blueprint) { if (!blueprint) {
return; return;
} }
const [related, blueprints] = await Promise.all([ const related = await findRelated(
findRelated(this.hass, "automation_blueprint", blueprint), this.hass,
fetchBlueprints(this.hass, "automation"), "automation_blueprint",
]); blueprint
this._filteredAutomations = related.automation || []; );
const blueprintMeta = blueprints[blueprint]; this._filters = {
this._activeFilters = [ ...this._filters,
this.hass.localize( "ha-filter-blueprints": {
"ui.panel.config.automation.picker.filtered_by_blueprint", value: [blueprint],
{ items: new Set(related.automation || []),
name: },
!blueprintMeta || "error" in blueprintMeta };
? blueprint this._applyFilters();
: 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;
} }
private _clearFilter() { private _clearFilter() {
this._filteredAutomations = undefined; this._filters = {};
this._activeFilters = undefined; this._applyFilters();
this._filterValue = undefined;
} }
private _showInfo(automation: any) { private _showInfo(automation: any) {
@ -426,6 +633,27 @@ class HaAutomationPicker extends LitElement {
triggerAutomationActions(this.hass, automation.entity_id); 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) { private _showTrace(automation: any) {
if (!automation.attributes.id) { if (!automation.attributes.id) {
showAlertDialog(this, { showAlertDialog(this, {
@ -552,6 +780,12 @@ class HaAutomationPicker extends LitElement {
return [ return [
haStyle, haStyle,
css` css`
hass-tabs-subpage-data-table {
--data-table-row-height: 60px;
}
hass-tabs-subpage-data-table.narrow {
--data-table-row-height: 72px;
}
.empty { .empty {
--paper-font-headline_-_font-size: 28px; --paper-font-headline_-_font-size: 28px;
--mdc-icon-size: 80px; --mdc-icon-size: 80px;

View File

@ -261,7 +261,7 @@ class HaBlueprintOverview extends LitElement {
hasFab hasFab
clickable clickable
@row-click=${this._handleRowClicked} @row-click=${this._handleRowClicked}
.appendRow=${html` <div .appendRow=${html`<div
class="mdc-data-table__cell" class="mdc-data-table__cell"
style="width: 100%; text-align: center;" style="width: 100%; text-align: center;"
role="cell" role="cell"

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, has_entity_name: false,
options: null, options: null,
labels: [], labels: [],
categories: {},
}); });
} }
if (changed) { if (changed) {

View File

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

View File

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

View File

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

View File

@ -30,7 +30,6 @@ import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button-related-filter-menu";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-icon-overflow-menu";
@ -83,8 +82,6 @@ class HaScriptPicker extends LitElement {
@state() private _filteredScripts?: string[] | null; @state() private _filteredScripts?: string[] | null;
@state() private _filterValue?;
private _scripts = memoizeOne( private _scripts = memoizeOne(
( (
scripts: ScriptEntity[], scripts: ScriptEntity[],
@ -266,15 +263,6 @@ class HaScriptPicker extends LitElement {
.path=${mdiHelpCircle} .path=${mdiHelpCircle}
@click=${this._showHelp} @click=${this._showHelp}
></ha-icon-button> ></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 ${!this.scripts.length
? html` <div class="empty" slot="empty"> ? html` <div class="empty" slot="empty">
<ha-svg-icon .path=${mdiScriptText}></ha-svg-icon> <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() { private _clearFilter() {
this._filteredScripts = undefined; this._filteredScripts = undefined;
this._activeFilters = undefined; this._activeFilters = undefined;
this._filterValue = undefined;
} }
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) { private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {

View File

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

View File

@ -499,6 +499,14 @@
"add_entity_id": "Choose entity", "add_entity_id": "Choose entity",
"add_label_id": "Choose label" "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-picker": {
"config_entry": "Integration" "config_entry": "Integration"
}, },
@ -547,6 +555,23 @@
"device": "Device", "device": "Device",
"no_area": "No area" "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": { "label-picker": {
"clear": "Clear", "clear": "Clear",
"show_labels": "Show labels", "show_labels": "Show labels",
@ -555,14 +580,7 @@
"add_new_sugestion": "Add new label ''{name}''", "add_new_sugestion": "Add new label ''{name}''",
"add_new": "Add new label…", "add_new": "Add new label…",
"no_labels": "You don't have any labels", "no_labels": "You don't have any labels",
"no_match": "No matching labels found", "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."
}
}, },
"area-picker": { "area-picker": {
"clear": "Clear", "clear": "Clear",
@ -1924,6 +1942,29 @@
"aliases_description": "Aliases are alternative names used in voice assistants to refer to this floor." "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": { "labels": {
"caption": "Labels", "caption": "Labels",
"description": "Group devices and entities", "description": "Group devices and entities",
@ -2632,14 +2673,20 @@
"delete_confirm_text": "{name} will be permanently deleted.", "delete_confirm_text": "{name} will be permanently deleted.",
"duplicate": "[%key:ui::common::duplicate%]", "duplicate": "[%key:ui::common::duplicate%]",
"disabled": "Disabled", "disabled": "Disabled",
"state": "State",
"filtered_by_blueprint": "blueprint: {name}", "filtered_by_blueprint": "blueprint: {name}",
"traces_not_available": "[%key:ui::panel::config::automation::editor::traces_not_available%]", "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": { "headers": {
"toggle": "Enable/disable", "toggle": "Enable/disable",
"name": "Name", "name": "Name",
"trigger": "Trigger", "trigger": "Trigger",
"actions": "Actions", "actions": "Actions",
"state": "State" "state": "State",
"category": "Category"
}, },
"empty_header": "Start automating", "empty_header": "Start automating",
"empty_text_1": "Automations make Home Assistant automatically respond to things happening in and around your home.", "empty_text_1": "Automations make Home Assistant automatically respond to things happening in and around your home.",
@ -3965,6 +4012,7 @@
}, },
"status": { "status": {
"restored": "Restored", "restored": "Restored",
"available": "Available",
"unavailable": "Unavailable", "unavailable": "Unavailable",
"disabled": "Disabled", "disabled": "Disabled",
"readonly": "Read-only", "readonly": "Read-only",