1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-07-12 16:56:51 +01:00

Add apps group to navigation picker with all web UI addons (#51572)

* Add apps navigation group with ingress add-on panels

Add an "Apps" section to the navigation picker that shows all add-ons
with ingress support. Uses the /ingress/panels supervisor endpoint via
a cached collection to fetch add-on titles and icons in a single call.

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Fix no-shadow lint error for panels variable

Rename subscribe callback parameter from `panels` to `data` to avoid
shadowing the outer `panels` variable in _loadNavigationItems.

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Use subscribeOne helper for ingress panels collection

Replace hand-rolled Promise/subscribe/unsub pattern with the existing
subscribeOne utility for cleaner one-shot collection consumption.

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Add explicit type parameter to subscribeOne call

TypeScript cannot infer the generic type through the collection
subscribe chain, resulting in unknown type for panel entries.

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Add subscribeOneCollection helper for collection one-shot reads

Add a new subscribeOneCollection utility that takes a collection
directly instead of requiring the (conn, onChange) function pattern.
Use it in the navigation picker for cleaner ingress panel fetching.

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Use Collection type instead of custom Subscribable interface

https://claude.ai/code/session_01F8dUzfSWj8ZwDByVZ45BNj

* Add ingress panel support to subscribeNavigationPathInfo

* Use app panel variable

* Add tests

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Paulus Schoutsen
2026-04-20 01:28:01 -04:00
committed by GitHub
parent 70ac14ed52
commit e66564ff65
6 changed files with 216 additions and 30 deletions
+13 -1
View File
@@ -1,4 +1,8 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import type {
Collection,
Connection,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
export const subscribeOne = async <T>(
conn: Connection,
@@ -13,3 +17,11 @@ export const subscribeOne = async <T>(
resolve(items);
});
});
export const subscribeOneCollection = async <T>(collection: Collection<T>) =>
new Promise<T>((resolve) => {
const unsub = collection.subscribe((data) => {
unsub();
resolve(data);
});
});
+58 -1
View File
@@ -2,9 +2,12 @@ import Fuse from "fuse.js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { fireEvent } from "../common/dom/fire_event";
import { subscribeOneCollection } from "../common/util/subscribe-one";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { getIngressPanelInfoCollection } from "../data/hassio/ingress";
import { fetchConfig } from "../data/lovelace/config/types";
import { SYSTEM_PANELS } from "../data/panel";
import {
@@ -25,7 +28,12 @@ import {
type PickerComboBoxItem,
} from "./ha-picker-combo-box";
type NavigationGroup = "related" | "dashboards" | "views" | "other_routes";
type NavigationGroup =
| "related"
| "dashboards"
| "views"
| "apps"
| "other_routes";
const RELATED_SORT_PREFIX = {
area_view: "0_area_view",
@@ -78,6 +86,7 @@ export class HaNavigationPicker extends LitElement {
related: [],
dashboards: [],
views: [],
apps: [],
other_routes: [],
};
@@ -107,6 +116,14 @@ export class HaNavigationPicker extends LitElement {
id: "views",
label: this.hass.localize("ui.components.navigation-picker.views"),
},
...(this._navigationGroups.apps.length
? [
{
id: "apps",
label: this.hass.localize("ui.components.navigation-picker.apps"),
},
]
: []),
{
id: "other_routes",
label: this.hass.localize(
@@ -199,6 +216,9 @@ export class HaNavigationPicker extends LitElement {
views: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
),
apps: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
),
other_routes: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
),
@@ -237,6 +257,7 @@ export class HaNavigationPicker extends LitElement {
const related = getGroupItems("related");
const dashboards = getGroupItems("dashboards");
const views = getGroupItems("views");
const apps = getGroupItems("apps");
const otherRoutes = getGroupItems("other_routes");
const addGroup = (group: NavigationGroup, groupItems: NavigationItem[]) => {
@@ -254,6 +275,7 @@ export class HaNavigationPicker extends LitElement {
addGroup("related", related);
addGroup("dashboards", dashboards);
addGroup("views", views);
addGroup("apps", apps);
addGroup("other_routes", otherRoutes);
return items;
@@ -292,10 +314,13 @@ export class HaNavigationPicker extends LitElement {
const related = this._navigationGroups.related;
const dashboards: NavigationItem[] = [];
const views: NavigationItem[] = [];
const apps: NavigationItem[] = [];
const otherRoutes: NavigationItem[] = [];
for (const panel of panels) {
if (SYSTEM_PANELS.includes(panel.id)) continue;
// Skip app panels — they are handled by the ingress panels fetch below
if (panel.component_name === "app") continue;
const path = `/${panel.url_path}`;
const resolved = computeNavigationPathInfo(this.hass!, path);
const isDashboardPanel =
@@ -338,6 +363,35 @@ export class HaNavigationPicker extends LitElement {
});
}
// Fetch all ingress add-on panels
if (isComponentLoaded(this.hass!.config, "hassio")) {
try {
const ingressPanels = await subscribeOneCollection(
getIngressPanelInfoCollection(this.hass!.connection)
);
for (const slug of Object.keys(ingressPanels)) {
const path = `/app/${slug}`;
const resolved = computeNavigationPathInfo(
this.hass!,
path,
undefined,
ingressPanels
);
apps.push({
id: path,
primary: resolved.label,
secondary: path,
icon: resolved.icon,
icon_path: resolved.iconPath,
sorting_label: createSortingLabel(resolved.label, path),
group: "apps",
});
}
} catch (_err) {
// Supervisor may not be available, silently ignore
}
}
for (const [subPath, route] of Object.entries(CONFIG_SUB_ROUTES)) {
const path = `/config/${subPath}`;
const label = this.hass!.localize(route.translationKey) || subPath;
@@ -355,6 +409,7 @@ export class HaNavigationPicker extends LitElement {
related,
dashboards,
views,
apps,
other_routes: otherRoutes,
};
@@ -362,6 +417,7 @@ export class HaNavigationPicker extends LitElement {
...related,
...dashboards,
...views,
...apps,
...otherRoutes,
];
@@ -384,6 +440,7 @@ export class HaNavigationPicker extends LitElement {
...relatedItems,
...this._navigationGroups.dashboards,
...this._navigationGroups.views,
...this._navigationGroups.apps,
...this._navigationGroups.other_routes,
];
};
+61 -26
View File
@@ -12,7 +12,10 @@ import {
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { getIngressPanelInfoCollection } from "./hassio/ingress";
import {
getIngressPanelInfoCollection,
type IngressPanelInfoMap,
} from "./hassio/ingress";
import { getLovelaceCollection } from "./lovelace";
import type { LovelaceRawConfig } from "./lovelace/config/types";
import { computeViewIcon, computeViewTitle } from "./lovelace/config/view";
@@ -80,7 +83,8 @@ export const CONFIG_SUB_ROUTES: Record<
export const computeNavigationPathInfo = (
hass: HomeAssistant,
path: string,
lovelaceConfig?: LovelaceRawConfig
lovelaceConfig?: LovelaceRawConfig,
ingressPanels?: IngressPanelInfoMap
): NavigationPathInfo => {
const segments = path.replace(/^\//, "").split(/[/?]/);
const panelUrlPath = segments[0];
@@ -106,6 +110,11 @@ export const computeNavigationPathInfo = (
return computeDeviceNavigationPathInfo(hass, segments[3]);
}
// /app/<slug> (ingress addon panel)
if (panelUrlPath === APP_PANEL && subPath) {
return computeIngressNavigationPathInfo(subPath, ingressPanels);
}
// /config/{subRoute} (e.g. /config/automation, /config/integrations)
if (panelUrlPath === "config" && subPath && subPath in CONFIG_SUB_ROUTES) {
const route = CONFIG_SUB_ROUTES[subPath];
@@ -174,23 +183,69 @@ const computeDeviceNavigationPathInfo = (
};
};
const computeIngressNavigationPathInfo = (
slug: string,
ingressPanels?: IngressPanelInfoMap
): NavigationPathInfo => {
const panel = ingressPanels?.[slug];
return {
label: panel?.title || slug,
icon: panel?.icon || undefined,
iconPath: mdiPuzzle,
};
};
/**
* Subscribe to navigation path info updates.
* Resolves synchronously first, then subscribes to lovelace config
* updates for view paths.
* updates for view paths and ingress panel info for app paths.
*/
export const subscribeNavigationPathInfo = (
hass: HomeAssistant,
path: string,
onChange: (info: NavigationPathInfo) => void
): UnsubscribeFunc | undefined => {
const segments = path.replace(/^\//, "").split(/[/?]/);
const panelUrlPath = segments[0];
// Subscribe to ingress panels for /app/<slug> paths
if (
panelUrlPath === APP_PANEL &&
segments[1] &&
isComponentLoaded(hass.config, "hassio")
) {
try {
const collection = getIngressPanelInfoCollection(hass.connection);
// Use cached state for immediate resolution if available
const info = computeNavigationPathInfo(
hass,
path,
undefined,
collection.state
);
onChange(info);
let current = info;
return collection.subscribe((panels) => {
const newInfo = computeNavigationPathInfo(
hass,
path,
undefined,
panels
);
if (newInfo.label !== current.label || newInfo.icon !== current.icon) {
current = newInfo;
onChange(newInfo);
}
});
} catch (_err) {
// Supervisor may not be available
}
}
const info = computeNavigationPathInfo(hass, path);
onChange(info);
const segments = path.replace(/^\//, "").split(/[/?]/);
const panelUrlPath = segments[0];
const panel = panelUrlPath ? hass.panels[panelUrlPath] : undefined;
if (segments[1] && panel?.component_name === "lovelace") {
let current = info;
const collection = getLovelaceCollection(hass.connection, panelUrlPath);
@@ -203,25 +258,5 @@ export const subscribeNavigationPathInfo = (
});
}
// /app/{addonSlug}
if (
panelUrlPath === APP_PANEL &&
segments[1] &&
isComponentLoaded(hass.config, "hassio")
) {
const addonSlug = segments[1];
const collection = getIngressPanelInfoCollection(hass.connection);
return collection.subscribe((addonMap) => {
const addon = addonMap[addonSlug];
if (addon) {
onChange({
label: addon.title,
icon: addon.icon,
iconPath: info.iconPath,
});
}
});
}
return undefined;
};
+1 -1
View File
@@ -35,7 +35,7 @@ export interface IngressPanelInfo {
icon: string;
}
type IngressPanelInfoMap = Record<string, IngressPanelInfo>;
export type IngressPanelInfoMap = Record<string, IngressPanelInfo>;
export const getIngressPanelInfoCollection = (conn: Connection) =>
getCollection<IngressPanelInfoMap>(
+1
View File
@@ -1435,6 +1435,7 @@
"related": "Related",
"views": "Views",
"area_settings": "{area} - Settings",
"apps": "[%key:panel::apps%]",
"other_routes": "Other routes",
"route": {
"automations": "[%key:ui::panel::config::automation::caption%]",
+82 -1
View File
@@ -1,6 +1,7 @@
import { mdiDevices, mdiLink, mdiTextureBox } from "@mdi/js";
import { mdiDevices, mdiLink, mdiPuzzle, mdiTextureBox } from "@mdi/js";
import { describe, expect, it } from "vitest";
import { computeNavigationPathInfo } from "../../src/data/compute-navigation-path-info";
import type { IngressPanelInfoMap } from "../../src/data/hassio/ingress";
import type { HomeAssistant } from "../../src/types";
import type { LovelaceConfig } from "../../src/data/lovelace/config/types";
@@ -262,4 +263,84 @@ describe("computeNavigationPathInfo", () => {
expect(result.label).toBe("Dashboard");
});
});
describe("ingress panel paths", () => {
const ingressPanels: IngressPanelInfoMap = {
my_addon: {
title: "My Addon",
icon: "mdi:puzzle",
},
no_icon_addon: {
title: "No Icon Addon",
icon: "",
},
};
it("resolves /app/<slug> with ingress panels data", () => {
const hass = createHass();
const result = computeNavigationPathInfo(
hass,
"/app/my_addon",
undefined,
ingressPanels
);
expect(result.label).toBe("My Addon");
expect(result.icon).toBe("mdi:puzzle");
expect(result.iconPath).toBe(mdiPuzzle);
});
it("falls back to slug when ingress panels not provided", () => {
const hass = createHass();
const result = computeNavigationPathInfo(hass, "/app/my_addon");
expect(result.label).toBe("my_addon");
expect(result.icon).toBeUndefined();
expect(result.iconPath).toBe(mdiPuzzle);
});
it("falls back to slug when addon not found in ingress panels", () => {
const hass = createHass();
const result = computeNavigationPathInfo(
hass,
"/app/unknown_addon",
undefined,
ingressPanels
);
expect(result.label).toBe("unknown_addon");
expect(result.icon).toBeUndefined();
expect(result.iconPath).toBe(mdiPuzzle);
});
it("resolves addon with empty icon as undefined", () => {
const hass = createHass();
const result = computeNavigationPathInfo(
hass,
"/app/no_icon_addon",
undefined,
ingressPanels
);
expect(result.label).toBe("No Icon Addon");
expect(result.icon).toBeUndefined();
expect(result.iconPath).toBe(mdiPuzzle);
});
it("does not resolve /app without a slug", () => {
const hass = createHass({
panels: {
app: {
url_path: "app",
title: "Apps",
component_name: "app",
},
} as unknown as HomeAssistant["panels"],
});
const result = computeNavigationPathInfo(
hass,
"/app",
undefined,
ingressPanels
);
// Falls through to panel resolution, not ingress
expect(result.label).toBe("Apps");
});
});
});