Compare commits

...

2 Commits

Author SHA1 Message Date
Aidan Timson 153290593b Remove option to group 2026-06-02 14:56:37 +01:00
Aidan Timson 189477a1d4 Categories data table 2026-06-02 14:52:55 +01:00
8 changed files with 445 additions and 44 deletions
+2 -12
View File
@@ -20,8 +20,8 @@ import {
subscribeCategoryRegistry,
updateCategoryRegistryEntry,
} from "../data/category_registry";
import { showConfirmationDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { confirmDeleteCategory } from "../panels/config/category/confirm-delete-category";
import { showCategoryRegistryDetailDialog } from "../panels/config/category/show-dialog-category-registry-detail";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
@@ -199,17 +199,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
}
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) {
if (!(await confirmDeleteCategory(this, this.hass))) {
return;
}
try {
+2 -1
View File
@@ -4,8 +4,9 @@ import type { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types";
import { debounce } from "../common/util/debounce";
import type { RegistryEntry } from "./registry";
export interface CategoryRegistryEntry {
export interface CategoryRegistryEntry extends RegistryEntry {
category_id: string;
name: string;
icon: string | null;
@@ -0,0 +1,13 @@
import type { HomeAssistant } from "../../../types";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
export const confirmDeleteCategory = (
element: HTMLElement,
hass: HomeAssistant
) =>
showConfirmationDialog(element, {
title: hass.localize("ui.panel.config.category.editor.confirm_delete"),
text: hass.localize("ui.panel.config.category.editor.confirm_delete_text"),
confirmText: hass.localize("ui.common.delete"),
destructive: true,
});
@@ -0,0 +1,369 @@
import {
mdiDelete,
mdiHelpCircleOutline,
mdiPalette,
mdiPencil,
mdiPlus,
mdiRobot,
mdiScriptText,
mdiTag,
mdiTools,
} from "@mdi/js";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { storage } from "../../../common/decorators/storage";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type {
DataTableColumnContainer,
DataTableRowData,
RowClickedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-svg-icon";
import type {
CategoryRegistryEntry,
CategoryRegistryEntryMutableParams,
} from "../../../data/category_registry";
import {
createCategoryRegistryEntry,
deleteCategoryRegistryEntry,
subscribeCategoryRegistry,
updateCategoryRegistryEntry,
} from "../../../data/category_registry";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../types";
import { areaConfigTabs } from "../common/area-config-tabs";
import {
getCreatedAtTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { confirmDeleteCategory } from "./confirm-delete-category";
import { showCategoryRegistryDetailDialog } from "./show-dialog-category-registry-detail";
const CATEGORY_SCOPE_CONFIGS = [
{
scope: "automation",
icon: mdiRobot,
translationKey: "ui.panel.config.automation.caption",
},
{
scope: "scene",
icon: mdiPalette,
translationKey: "ui.panel.config.scene.caption",
},
{
scope: "script",
icon: mdiScriptText,
translationKey: "ui.panel.config.script.caption",
},
{
scope: "helpers",
icon: mdiTools,
translationKey: "ui.panel.config.helpers.caption",
},
] as const;
type CategoryScope = (typeof CATEGORY_SCOPE_CONFIGS)[number]["scope"];
type CategoriesByScope = Record<CategoryScope, CategoryRegistryEntry[]>;
interface CategoryRowData extends CategoryRegistryEntry, DataTableRowData {
id: string;
scope: CategoryScope;
scopeName: string;
}
const EMPTY_CATEGORIES_BY_SCOPE: CategoriesByScope = {
automation: [],
scene: [],
script: [],
helpers: [],
};
@customElement("ha-config-categories")
export class HaConfigCategories extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _categories: CategoriesByScope = EMPTY_CATEGORIES_BY_SCOPE;
@state()
@storage({
storage: "sessionStorage",
key: "categories-table-search",
state: true,
subscribe: false,
})
private _filter = "";
private _columns = memoizeOne((localize: LocalizeFunc) => {
const columns: DataTableColumnContainer<CategoryRowData> = {
icon: {
title: "",
moveable: false,
showNarrow: true,
label: localize("ui.panel.config.category.headers.icon"),
type: "icon",
template: (category) =>
category.icon
? html`<ha-icon .icon=${category.icon}></ha-icon>`
: html`<ha-svg-icon .path=${mdiTag}></ha-svg-icon>`,
},
name: {
title: localize("ui.panel.config.category.headers.name"),
main: true,
flex: 2,
sortable: true,
filterable: true,
},
scopeName: {
title: localize("ui.panel.config.category.headers.scope"),
defaultHidden: true,
filterable: true,
sortable: true,
},
created_at: getCreatedAtTableColumn(localize, this.hass),
modified_at: getModifiedAtTableColumn(localize, this.hass),
actions: {
lastFixed: true,
title: "",
label: localize("ui.panel.config.generic.headers.actions"),
showNarrow: true,
type: "overflow-menu",
template: (category) => html`
<ha-icon-overflow-menu
narrow
.items=${[
{
label: this.hass.localize(
"ui.panel.config.category.editor.edit"
),
path: mdiPencil,
action: () => this._openDialog(category.scope, category),
},
{
label: this.hass.localize(
"ui.panel.config.category.editor.delete"
),
path: mdiDelete,
action: () => this._deleteCategory(category),
warning: true,
},
]}
></ha-icon-overflow-menu>
`,
},
};
return columns;
});
private _data = memoizeOne(
(
categories: CategoriesByScope,
localize: LocalizeFunc
): CategoryRowData[] =>
CATEGORY_SCOPE_CONFIGS.flatMap((scopeConfig) =>
categories[scopeConfig.scope].map((category) => ({
...category,
id: `${scopeConfig.scope}:${category.category_id}`,
scope: scopeConfig.scope,
scopeName: localize(scopeConfig.translationKey),
}))
)
);
private _groupOrder = memoizeOne((localize: LocalizeFunc) =>
CATEGORY_SCOPE_CONFIGS.map((scopeConfig) =>
localize(scopeConfig.translationKey)
)
);
protected hassSubscribe() {
return CATEGORY_SCOPE_CONFIGS.map((scopeConfig) =>
subscribeCategoryRegistry(
this.hass.connection,
scopeConfig.scope,
(categories) => this._setCategories(scopeConfig.scope, categories)
)
);
}
protected render() {
return html`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${areaConfigTabs}
.columns=${this._columns(this.hass.localize)}
.data=${this._data(this._categories, this.hass.localize)}
.noDataText=${this.hass.localize(
"ui.panel.config.category.no_categories"
)}
has-fab
.initialGroupColumn=${"scopeName"}
.groupOrder=${this._groupOrder(this.hass.localize)}
.filter=${this._filter}
@search-changed=${this._handleSearchChange}
@row-click=${this._editCategory}
clickable
id="id"
>
<ha-icon-button
slot="toolbar-icon"
@click=${this._showHelp}
.label=${this.hass.localize("ui.common.help")}
.path=${mdiHelpCircleOutline}
></ha-icon-button>
<ha-dropdown slot="fab" @wa-select=${this._handleCreateScope}>
<ha-button slot="trigger" id="fab" size="large">
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.create")}
</ha-button>
${CATEGORY_SCOPE_CONFIGS.map(
(scopeConfig) => html`
<ha-dropdown-item .value=${scopeConfig.scope}>
<ha-svg-icon
.path=${scopeConfig.icon}
slot="icon"
></ha-svg-icon>
${this.hass.localize(scopeConfig.translationKey)}
</ha-dropdown-item>
`
)}
</ha-dropdown>
</hass-tabs-subpage-data-table>
`;
}
private _showHelp() {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.category.caption"),
text: html`
${this.hass.localize("ui.panel.config.category.introduction")}
<p>${this.hass.localize("ui.panel.config.category.introduction2")}</p>
`,
});
}
private _setCategories(
scope: CategoryScope,
categories: CategoryRegistryEntry[]
) {
this._categories = {
...this._categories,
[scope]: categories,
};
}
private _handleCreateScope = (ev: HaDropdownSelectEvent<CategoryScope>) => {
this._openDialog(ev.detail.item.value);
};
private _editCategory(ev: CustomEvent<RowClickedEvent>) {
const category = this._data(this._categories, this.hass.localize).find(
(row) => row.id === ev.detail.id
);
if (!category) {
return;
}
this._openDialog(category.scope, category);
}
private _openDialog(scope: CategoryScope, entry?: CategoryRegistryEntry) {
showCategoryRegistryDetailDialog(this, {
scope,
entry,
createEntry: entry
? undefined
: (values) => this._createCategory(scope, values),
updateEntry: entry
? (values) => this._updateCategory(scope, entry, values)
: undefined,
});
}
private async _createCategory(
scope: CategoryScope,
values: CategoryRegistryEntryMutableParams
): Promise<CategoryRegistryEntry> {
const category = await createCategoryRegistryEntry(
this.hass,
scope,
values
);
this._setCategories(scope, [...this._categories[scope], category]);
return category;
}
private async _updateCategory(
scope: CategoryScope,
entry: CategoryRegistryEntry,
values: Partial<CategoryRegistryEntryMutableParams>
): Promise<CategoryRegistryEntry> {
const category = await updateCategoryRegistryEntry(
this.hass,
scope,
entry.category_id,
values
);
this._setCategories(
scope,
this._categories[scope].map((current) =>
current.category_id === entry.category_id ? category : current
)
);
return category;
}
private async _deleteCategory(entry: CategoryRowData) {
if (!(await confirmDeleteCategory(this, this.hass))) {
return;
}
try {
await deleteCategoryRegistryEntry(
this.hass,
entry.scope,
entry.category_id
);
this._setCategories(
entry.scope,
this._categories[entry.scope].filter(
(category) => category.category_id !== entry.category_id
)
);
} catch (err: unknown) {
showAlertDialog(this, {
text:
err instanceof Error
? err.message
: this.hass.localize(
"ui.panel.config.category.editor.unknown_error"
),
});
}
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-categories": HaConfigCategories;
}
}
@@ -0,0 +1,40 @@
import { mdiLabel, mdiMapMarkerRadius, mdiSofa, mdiTag } from "@mdi/js";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
export const areaConfigTabs: PageNavigation[] = [
{
component: "areas",
path: "/config/areas",
translationKey: "ui.panel.config.areas.caption",
iconPath: mdiSofa,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "categories",
path: "/config/categories",
translationKey: "ui.panel.config.category.caption",
iconPath: mdiTag,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "labels",
path: "/config/labels",
translationKey: "ui.panel.config.labels.caption",
iconPath: mdiLabel,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "zone",
path: "/config/zone",
translationKey: "ui.panel.config.zone.caption",
iconPath: mdiMapMarkerRadius,
iconColor: "#E48629",
adminOnly: true,
},
];
+6 -30
View File
@@ -10,9 +10,7 @@ import {
mdiFlask,
mdiHammer,
mdiInformationOutline,
mdiLabel,
mdiLightningBolt,
mdiMapMarkerRadius,
mdiMemory,
mdiMicrophone,
mdiNetwork,
@@ -47,6 +45,7 @@ import type { RouterOptions } from "../../layouts/hass-router-page";
import { HassRouterPage } from "../../layouts/hass-router-page";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../types";
import { areaConfigTabs } from "./common/area-config-tabs";
declare global {
// for fire event
@@ -417,34 +416,7 @@ export const configSections: Record<string, PageNavigation[]> = {
adminOnly: true,
},
],
areas: [
{
component: "areas",
path: "/config/areas",
translationKey: "ui.panel.config.areas.caption",
iconPath: mdiSofa,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "labels",
path: "/config/labels",
translationKey: "ui.panel.config.labels.caption",
iconPath: mdiLabel,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "zone",
path: "/config/zone",
translationKey: "ui.panel.config.zone.caption",
iconPath: mdiMapMarkerRadius,
iconColor: "#E48629",
adminOnly: true,
},
],
areas: areaConfigTabs,
general: [
{
path: "/config/general",
@@ -631,6 +603,10 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-integrations",
load: () => import("./integrations/ha-config-integrations"),
},
categories: {
tag: "ha-config-categories",
load: () => import("./category/ha-config-categories"),
},
labels: {
tag: "ha-config-labels",
load: () => import("./labels/ha-config-labels"),
+3
View File
@@ -163,6 +163,9 @@ export const getMyRedirects = (): Redirects => ({
entities: {
redirect: "/config/entities",
},
categories: {
redirect: "/config/categories",
},
labels: {
redirect: "/config/labels",
},
+10 -1
View File
@@ -2675,7 +2675,7 @@
"secondary": "Manage who can access your home"
},
"areas": {
"main": "Areas, labels & zones",
"main": "Areas, categories, labels & zones",
"secondary": "Manage locations in and around your house"
},
"companion": {
@@ -3109,6 +3109,15 @@
},
"category": {
"caption": "Categories",
"description": "Group automations, scenes, scripts, and helpers",
"headers": {
"name": "Name",
"icon": "Icon",
"scope": "Type"
},
"no_categories": "You don't have any categories",
"introduction": "Categories can help you organize your automations, scenes, scripts, and helpers. Each type has its own categories.",
"introduction2": "Create categories here, then assign them from the matching settings page.",
"assign": {
"edit": "Edit category",
"assign": "Assign category",