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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user