From 37d8273e7caf475f965b53d028a6fe26bc914142 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 26 Jan 2026 13:10:09 +0000 Subject: [PATCH] Navigation picker: add sections/grouping and related nav paths (#29094) * Add sections to navigation picker * Use PANEL_DASHBOARDS to assign system dashboards to the dashboards section * Clean * Add context based related section * Add integration icon for related device * Add floor and sort * Consolidate and cleanup * Reuse type * Add context check and catch findRelated errors * Remove floor from set, use area * Memoize related updates * Log error * Remove * Fix icon path usage --- src/components/ha-navigation-picker.ts | 360 +++++++++++++++++- .../ha-selector/ha-selector-navigation.ts | 4 + .../ha-selector/ha-selector-ui-action.ts | 4 + src/data/selector.ts | 7 +- .../lovelace/components/hui-action-editor.ts | 44 ++- .../elements/hui-icon-element-editor.ts | 4 + .../elements/hui-image-element-editor.ts | 4 + .../hui-state-badge-element-editor.ts | 4 + .../elements/hui-state-icon-element-editor.ts | 4 + .../hui-state-label-element-editor.ts | 4 + .../config-elements/hui-area-card-editor.ts | 3 + .../config-elements/hui-button-card-editor.ts | 4 + .../hui-entity-badge-editor.ts | 3 + .../config-elements/hui-entity-card-editor.ts | 3 + .../config-elements/hui-gauge-card-editor.ts | 7 +- .../config-elements/hui-glance-card-editor.ts | 3 + .../hui-heading-card-editor.ts | 6 +- .../config-elements/hui-light-card-editor.ts | 4 + .../hui-picture-card-editor.ts | 4 +- .../hui-picture-entity-card-editor.ts | 3 + .../hui-picture-glance-card-editor.ts | 6 + .../config-elements/hui-tile-card-editor.ts | 4 + .../hui-weather-forecast-card-editor.ts | 3 + .../hui-entity-heading-badge-editor.ts | 3 + src/translations/en.json | 6 +- 25 files changed, 474 insertions(+), 27 deletions(-) diff --git a/src/components/ha-navigation-picker.ts b/src/components/ha-navigation-picker.ts index 16f3384608..0b93ae596f 100644 --- a/src/components/ha-navigation-picker.ts +++ b/src/components/ha-navigation-picker.ts @@ -1,13 +1,38 @@ -import { html, LitElement, nothing } from "lit"; +import Fuse from "fuse.js"; +import { mdiDevices, mdiTextureBox } from "@mdi/js"; +import { html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; +import { caseInsensitiveStringCompare } from "../common/string/compare"; import { titleCase } from "../common/string/title-case"; +import { getConfigEntries, type ConfigEntry } from "../data/config_entries"; import { fetchConfig } from "../data/lovelace/config/types"; import { getPanelIcon, getPanelTitle } from "../data/panel"; +import { findRelated, type RelatedResult } from "../data/search"; +import { PANEL_DASHBOARDS } from "../panels/config/lovelace/dashboards/ha-config-lovelace-dashboards"; +import { multiTermSortedSearch } from "../resources/fuseMultiTerm"; import type { HomeAssistant, ValueChangedEvent } from "../types"; +import type { ActionRelatedContext } from "../panels/lovelace/components/hui-action-editor"; import "./ha-generic-picker"; +import "./ha-domain-icon"; import "./ha-icon"; -import type { PickerComboBoxItem } from "./ha-picker-combo-box"; +import { + DEFAULT_SEARCH_KEYS, + type PickerComboBoxItem, +} from "./ha-picker-combo-box"; + +type NavigationGroup = "related" | "dashboards" | "views" | "other_routes"; + +const RELATED_SORT_PREFIX = { + area: "0_area", + device: "1_device", +} as const; + +interface NavigationItem extends PickerComboBoxItem { + group: NavigationGroup; + domain?: string; +} @customElement("ha-navigation-picker") export class HaNavigationPicker extends LitElement { @@ -25,13 +50,57 @@ export class HaNavigationPicker extends LitElement { @state() private _loading = true; + @property({ attribute: false }) public context?: ActionRelatedContext; + protected firstUpdated() { this._loadNavigationItems(); } - private _navigationItems: PickerComboBoxItem[] = []; + private _navigationItems: NavigationItem[] = []; + + private _configEntryLookup: Record = {}; + + private _navigationGroups: Record = { + related: [], + dashboards: [], + views: [], + other_routes: [], + }; + + private _getRelatedItems = memoizeOne( + async (_cacheKey: string, context: ActionRelatedContext) => + this._fetchRelatedItems(context), + (newArgs, lastArgs) => newArgs[0] === lastArgs[0] + ); protected render() { + const sections = [ + ...(this._navigationGroups.related.length + ? [ + { + id: "related", + label: this.hass.localize( + "ui.components.navigation-picker.related" + ), + }, + ] + : []), + { + id: "dashboards", + label: this.hass.localize("ui.components.navigation-picker.dashboards"), + }, + { + id: "views", + label: this.hass.localize("ui.components.navigation-picker.views"), + }, + { + id: "other_routes", + label: this.hass.localize( + "ui.components.navigation-picker.other_routes" + ), + }, + ]; + return html` { const item = this._navigationItems.find((navItem) => navItem.id === itemId); return html` - ${item?.icon - ? html`` - : nothing} + ${item?.domain + ? html` + + ` + : item?.icon + ? html`` + : item?.icon_path + ? html`` + : nothing} ${item?.primary || itemId} ${item?.primary ? html`${itemId}` @@ -65,9 +150,106 @@ export class HaNavigationPicker extends LitElement { `; }; - private _getItems = () => this._navigationItems; + private _rowRenderer = (item: NavigationItem) => html` + + ${item.domain + ? html` + + ` + : item.icon + ? html`` + : item.icon_path + ? html`` + : nothing} + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + + `; + + private _fuseIndexes = { + related: memoizeOne((items: NavigationItem[]) => + Fuse.createIndex(DEFAULT_SEARCH_KEYS, items) + ), + dashboards: memoizeOne((items: NavigationItem[]) => + Fuse.createIndex(DEFAULT_SEARCH_KEYS, items) + ), + views: memoizeOne((items: NavigationItem[]) => + Fuse.createIndex(DEFAULT_SEARCH_KEYS, items) + ), + other_routes: memoizeOne((items: NavigationItem[]) => + Fuse.createIndex(DEFAULT_SEARCH_KEYS, items) + ), + }; + + private _getItems = (searchString?: string, section?: string) => { + const getGroupItems = (group: NavigationGroup) => { + let items = [...this._navigationGroups[group]].sort( + this._sortBySortingLabel + ); + + if (searchString) { + const fuseIndex = this._fuseIndexes[group](items); + items = multiTermSortedSearch( + items, + searchString, + DEFAULT_SEARCH_KEYS, + (item) => item.id, + fuseIndex + ); + } + + return items; + }; + + const items: (NavigationItem | string)[] = []; + + const related = getGroupItems("related"); + const dashboards = getGroupItems("dashboards"); + const views = getGroupItems("views"); + const otherRoutes = getGroupItems("other_routes"); + + const addGroup = (group: NavigationGroup, groupItems: NavigationItem[]) => { + if (section && section !== group) { + return; + } + if (!section && groupItems.length) { + items.push( + this.hass.localize(`ui.components.navigation-picker.${group}`) + ); + } + items.push(...groupItems); + }; + + addGroup("related", related); + addGroup("dashboards", dashboards); + addGroup("views", views); + addGroup("other_routes", otherRoutes); + + return items; + }; + + private _sortBySortingLabel = ( + itemA: PickerComboBoxItem, + itemB: PickerComboBoxItem + ) => + caseInsensitiveStringCompare( + itemA.sorting_label!, + itemB.sorting_label!, + this.hass.locale.language + ); private async _loadNavigationItems() { + await this._loadConfigEntries(); const panels = Object.entries(this.hass!.panels).map(([id, panel]) => ({ id, ...panel, @@ -91,13 +273,19 @@ export class HaNavigationPicker extends LitElement { const panelViewConfig = new Map(viewConfigs); - this._navigationItems = []; + const related = this._navigationGroups.related; + const dashboards: NavigationItem[] = []; + const views: NavigationItem[] = []; + const otherRoutes: NavigationItem[] = []; for (const panel of panels) { const path = `/${panel.url_path}`; const panelTitle = getPanelTitle(this.hass, panel); const primary = panelTitle || path; - this._navigationItems.push({ + const isDashboardPanel = + panel.component_name === "lovelace" || + PANEL_DASHBOARDS.includes(panel.id); + const panelItem: NavigationItem = { id: path, primary, secondary: panelTitle ? path : undefined, @@ -108,7 +296,14 @@ export class HaNavigationPicker extends LitElement { ] .filter(Boolean) .join("_"), - }); + group: isDashboardPanel ? "dashboards" : "other_routes", + }; + + if (isDashboardPanel) { + dashboards.push(panelItem); + } else { + otherRoutes.push(panelItem); + } const config = panelViewConfig.get(panel.id); @@ -118,7 +313,7 @@ export class HaNavigationPicker extends LitElement { const viewPath = `/${panel.url_path}/${view.path ?? index}`; const viewPrimary = view.title ?? (view.path ? titleCase(view.path) : `${index}`); - this._navigationItems.push({ + views.push({ id: viewPath, secondary: viewPath, icon: view.icon ?? "mdi:view-compact", @@ -127,13 +322,156 @@ export class HaNavigationPicker extends LitElement { viewPrimary.startsWith("/") ? `zzz${viewPrimary}` : viewPrimary, viewPath, ].join("_"), + group: "views", }); }); } + this._navigationGroups = { + related, + dashboards, + views, + other_routes: otherRoutes, + }; + + this._navigationItems = [ + ...related, + ...dashboards, + ...views, + ...otherRoutes, + ]; + this._loading = false; } + protected updated(changedProps: PropertyValues) { + if (changedProps.has("context")) { + this._loadRelatedItems(); + } + } + + private async _loadRelatedItems() { + const updateRelatedItems = (relatedItems: NavigationItem[]) => { + this._navigationGroups = { + ...this._navigationGroups, + related: relatedItems, + }; + this._navigationItems = [ + ...relatedItems, + ...this._navigationGroups.dashboards, + ...this._navigationGroups.views, + ...this._navigationGroups.other_routes, + ]; + }; + + if (!this.hass || (!this.context?.entity_id && !this.context?.area_id)) { + updateRelatedItems([]); + return; + } + + const context = this.context; + const contextMatches = () => + this.context?.entity_id === context?.entity_id && + this.context?.area_id === context?.area_id; + + const items = await this._getRelatedItems( + `${context.entity_id ?? ""}|${context.area_id ?? ""}`, + context + ); + if (contextMatches()) { + updateRelatedItems(items); + } + } + + private async _fetchRelatedItems( + context: ActionRelatedContext + ): Promise { + let relatedResult: RelatedResult | undefined; + try { + relatedResult = context.entity_id + ? await findRelated(this.hass, "entity", context.entity_id) + : await findRelated(this.hass, "area", context.area_id!); + } catch (err) { + // eslint-disable-next-line no-console + console.error("Error fetching related items for navigation picker", err); + return []; + } + + const relatedDeviceIds = new Set(relatedResult?.device ?? []); + const relatedAreaIds = new Set(relatedResult?.area ?? []); + if (context.area_id) { + relatedAreaIds.add(context.area_id); + } + + const createSortingLabel = ( + prefix: string, + primary: string, + path: string + ) => + [prefix, primary.startsWith("/") ? `zzz${primary}` : primary, path] + .filter(Boolean) + .join("_"); + + const relatedItems: NavigationItem[] = []; + for (const deviceId of relatedDeviceIds) { + const device = this.hass.devices[deviceId]; + const primary = device?.name_by_user ?? device?.name ?? deviceId; + const path = `/config/devices/device/${deviceId}`; + relatedItems.push({ + id: path, + primary, + secondary: path, + icon_path: mdiDevices, + sorting_label: createSortingLabel( + RELATED_SORT_PREFIX.device, + primary, + path + ), + group: "related", + domain: device?.primary_config_entry + ? this._configEntryLookup[device.primary_config_entry]?.domain + : undefined, + }); + } + + for (const areaId of relatedAreaIds) { + const area = this.hass.areas[areaId]; + const primary = area?.name ?? areaId; + const path = `/config/areas/area/${areaId}`; + relatedItems.push({ + id: path, + primary, + secondary: path, + icon: area?.icon ?? undefined, + icon_path: area?.icon ? undefined : mdiTextureBox, + sorting_label: createSortingLabel( + RELATED_SORT_PREFIX.area, + primary, + path + ), + group: "related", + }); + } + + return relatedItems; + } + + private async _loadConfigEntries() { + if (Object.keys(this._configEntryLookup).length) { + return; + } + + try { + const configEntries = await getConfigEntries(this.hass); + this._configEntryLookup = Object.fromEntries( + configEntries.map((entry) => [entry.entry_id, entry]) + ); + } catch (err) { + // eslint-disable-next-line no-console + console.error("Error fetching config entries for navigation picker", err); + } + } + private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); this._setValue(ev.detail.value); diff --git a/src/components/ha-selector/ha-selector-navigation.ts b/src/components/ha-selector/ha-selector-navigation.ts index 437c6019a6..a35f13ad06 100644 --- a/src/components/ha-selector/ha-selector-navigation.ts +++ b/src/components/ha-selector/ha-selector-navigation.ts @@ -3,6 +3,7 @@ import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import type { NavigationSelector } from "../../data/selector"; import type { HomeAssistant } from "../../types"; +import type { ActionRelatedContext } from "../../panels/lovelace/components/hui-action-editor"; import "../ha-navigation-picker"; @customElement("ha-selector-navigation") @@ -21,6 +22,8 @@ export class HaNavigationSelector extends LitElement { @property({ type: Boolean }) public required = true; + @property({ attribute: false }) public context?: ActionRelatedContext; + protected render() { return html` `; diff --git a/src/components/ha-selector/ha-selector-ui-action.ts b/src/components/ha-selector/ha-selector-ui-action.ts index 4a3331a81c..6d2b24f986 100644 --- a/src/components/ha-selector/ha-selector-ui-action.ts +++ b/src/components/ha-selector/ha-selector-ui-action.ts @@ -5,6 +5,7 @@ import type { ActionConfig } from "../../data/lovelace/config/action"; import type { UiActionSelector } from "../../data/selector"; import "../../panels/lovelace/components/hui-action-editor"; import type { HomeAssistant } from "../../types"; +import type { ActionRelatedContext } from "../../panels/lovelace/components/hui-action-editor"; @customElement("ha-selector-ui_action") export class HaSelectorUiAction extends LitElement { @@ -14,6 +15,8 @@ export class HaSelectorUiAction extends LitElement { @property({ attribute: false }) public value?: ActionConfig; + @property({ attribute: false }) public context?: ActionRelatedContext; + @property() public label?: string; @property() public helper?: string; @@ -24,6 +27,7 @@ export class HaSelectorUiAction extends LitElement { .label=${this.label} .hass=${this.hass} .config=${this.value} + .context=${this.context} .actions=${this.selector.ui_action?.actions} .defaultAction=${this.selector.ui_action?.default_action} .tooltipText=${this.helper} diff --git a/src/data/selector.ts b/src/data/selector.ts index 2a087c24a5..c98ea94a74 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -7,7 +7,10 @@ import type { EntityNameItem } from "../common/entity/compute_entity_name_displa import { computeStateDomain } from "../common/entity/compute_state_domain"; import { supportsFeature } from "../common/entity/supports-feature"; import { isHelperDomain } from "../panels/config/helpers/const"; -import type { UiAction } from "../panels/lovelace/components/hui-action-editor"; +import type { + ActionRelatedContext, + UiAction, +} from "../panels/lovelace/components/hui-action-editor"; import type { HomeAssistant } from "../types"; import { type DeviceRegistryEntry, @@ -332,7 +335,7 @@ export interface MediaSelectorValue { } export interface NavigationSelector { - navigation: {} | null; + navigation: ActionRelatedContext | null; } export interface NumberSelector { diff --git a/src/panels/lovelace/components/hui-action-editor.ts b/src/panels/lovelace/components/hui-action-editor.ts index 75a250fcbd..dbac50d2d0 100644 --- a/src/panels/lovelace/components/hui-action-editor.ts +++ b/src/panels/lovelace/components/hui-action-editor.ts @@ -27,6 +27,16 @@ import type { EditorTarget } from "../editor/types"; export type UiAction = Exclude; +export interface ActionRelatedContext { + entity_id?: string; + area_id?: string; +} + +export const ACTION_RELATED_CONTEXT = { + entity_id: "entity", + area_id: "area", +} as const satisfies HaFormSchema["context"] & ActionRelatedContext; + const DEFAULT_ACTIONS: UiAction[] = [ "more-info", "toggle", @@ -42,15 +52,6 @@ export const supportedActions = (struct: any, supported_actions: UiAction[]) => supported_actions.includes(value.action) ); -const NAVIGATE_SCHEMA = [ - { - name: "navigation_path", - selector: { - navigation: {}, - }, - }, -] as const satisfies readonly HaFormSchema[]; - const ASSIST_SCHEMA = [ { type: "grid", @@ -88,6 +89,9 @@ export class HuiActionEditor extends LitElement { @property({ attribute: false }) public hass?: HomeAssistant; + @property({ attribute: false }) + public context?: ActionRelatedContext; + @query("ha-select") private _select!: HaSelect; get _navigation_path(): string { @@ -115,6 +119,23 @@ export class HuiActionEditor extends LitElement { }) ); + private _navigateSchema = memoizeOne( + ( + relatedEntityId?: string, + relatedAreaId?: string + ): readonly HaFormSchema[] => [ + { + name: "navigation_path", + selector: { + navigation: { + ...(relatedEntityId ? { entity_id: relatedEntityId } : {}), + ...(relatedAreaId ? { area_id: relatedAreaId } : {}), + }, + }, + }, + ] + ); + protected updated(changedProperties: PropertyValues) { super.updated(changedProperties); if (changedProperties.has("defaultAction")) { @@ -178,7 +199,10 @@ export class HuiActionEditor extends LitElement { ? html`