mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-18 14:09:05 +01:00
e66564ff65
* 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>
347 lines
9.6 KiB
TypeScript
347 lines
9.6 KiB
TypeScript
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";
|
|
|
|
const createHass = (overrides: Partial<HomeAssistant> = {}): HomeAssistant =>
|
|
({
|
|
panels: {},
|
|
areas: {},
|
|
devices: {},
|
|
localize: () => "",
|
|
...overrides,
|
|
}) as unknown as HomeAssistant;
|
|
|
|
describe("computeNavigationPathInfo", () => {
|
|
describe("panel paths", () => {
|
|
it("resolves a panel with icon", () => {
|
|
const hass = createHass({
|
|
panels: {
|
|
"my-panel": {
|
|
url_path: "my-panel",
|
|
title: "My Panel",
|
|
icon: "mdi:star",
|
|
component_name: "custom",
|
|
},
|
|
} as unknown as HomeAssistant["panels"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(hass, "/my-panel");
|
|
expect(result.label).toBe("My Panel");
|
|
expect(result.icon).toBe("mdi:star");
|
|
expect(result.iconPath).toBe(mdiLink);
|
|
});
|
|
|
|
it("falls back to url path for unknown panel", () => {
|
|
const hass = createHass();
|
|
const result = computeNavigationPathInfo(hass, "/unknown");
|
|
expect(result.label).toBe("unknown");
|
|
expect(result.icon).toBeUndefined();
|
|
expect(result.iconPath).toBe(mdiLink);
|
|
});
|
|
});
|
|
|
|
describe("area paths", () => {
|
|
it("resolves /config/areas/area/{areaId}", () => {
|
|
const hass = createHass({
|
|
areas: {
|
|
living_room: {
|
|
area_id: "living_room",
|
|
name: "Living Room",
|
|
icon: "mdi:sofa",
|
|
},
|
|
} as unknown as HomeAssistant["areas"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(
|
|
hass,
|
|
"/config/areas/area/living_room"
|
|
);
|
|
expect(result.label).toBe("Living Room");
|
|
expect(result.icon).toBe("mdi:sofa");
|
|
expect(result.iconPath).toBe(mdiTextureBox);
|
|
});
|
|
|
|
it("resolves /home/areas-{areaId}", () => {
|
|
const hass = createHass({
|
|
areas: {
|
|
kitchen: {
|
|
area_id: "kitchen",
|
|
name: "Kitchen",
|
|
icon: null,
|
|
},
|
|
} as unknown as HomeAssistant["areas"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(hass, "/home/areas-kitchen");
|
|
expect(result.label).toBe("Kitchen");
|
|
expect(result.icon).toBeUndefined();
|
|
expect(result.iconPath).toBe(mdiTextureBox);
|
|
});
|
|
|
|
it("falls back to area id for unknown area", () => {
|
|
const hass = createHass();
|
|
const result = computeNavigationPathInfo(
|
|
hass,
|
|
"/config/areas/area/unknown_area"
|
|
);
|
|
expect(result.label).toBe("unknown_area");
|
|
expect(result.iconPath).toBe(mdiTextureBox);
|
|
});
|
|
});
|
|
|
|
describe("device paths", () => {
|
|
it("resolves /config/devices/device/{deviceId}", () => {
|
|
const hass = createHass({
|
|
devices: {
|
|
abc123: {
|
|
id: "abc123",
|
|
name: "Smart Light",
|
|
name_by_user: null,
|
|
},
|
|
} as unknown as HomeAssistant["devices"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(
|
|
hass,
|
|
"/config/devices/device/abc123"
|
|
);
|
|
expect(result.label).toBe("Smart Light");
|
|
expect(result.iconPath).toBe(mdiDevices);
|
|
});
|
|
|
|
it("prefers user-defined device name", () => {
|
|
const hass = createHass({
|
|
devices: {
|
|
abc123: {
|
|
id: "abc123",
|
|
name: "Smart Light",
|
|
name_by_user: "My Light",
|
|
},
|
|
} as unknown as HomeAssistant["devices"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(
|
|
hass,
|
|
"/config/devices/device/abc123"
|
|
);
|
|
expect(result.label).toBe("My Light");
|
|
});
|
|
|
|
it("falls back to device id for unknown device", () => {
|
|
const hass = createHass();
|
|
const result = computeNavigationPathInfo(
|
|
hass,
|
|
"/config/devices/device/unknown_device"
|
|
);
|
|
expect(result.label).toBe("unknown_device");
|
|
expect(result.iconPath).toBe(mdiDevices);
|
|
});
|
|
});
|
|
|
|
describe("lovelace view paths", () => {
|
|
const lovelaceConfig: LovelaceConfig = {
|
|
views: [
|
|
{ title: "Overview", path: "overview", icon: "mdi:home" },
|
|
{ title: "Lights", path: "lights" },
|
|
{ path: "my-view" },
|
|
{},
|
|
],
|
|
};
|
|
|
|
it("resolves view with title and icon", () => {
|
|
const hass = createHass({
|
|
panels: {
|
|
lovelace: {
|
|
url_path: "lovelace",
|
|
title: "Dashboard",
|
|
component_name: "lovelace",
|
|
},
|
|
} as unknown as HomeAssistant["panels"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(
|
|
hass,
|
|
"/lovelace/overview",
|
|
lovelaceConfig
|
|
);
|
|
expect(result.label).toBe("Overview");
|
|
expect(result.icon).toBe("mdi:home");
|
|
});
|
|
|
|
it("resolves view without icon using default", () => {
|
|
const hass = createHass({
|
|
panels: {
|
|
lovelace: {
|
|
url_path: "lovelace",
|
|
title: "Dashboard",
|
|
component_name: "lovelace",
|
|
},
|
|
} as unknown as HomeAssistant["panels"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(
|
|
hass,
|
|
"/lovelace/lights",
|
|
lovelaceConfig
|
|
);
|
|
expect(result.label).toBe("Lights");
|
|
expect(result.icon).toBe("mdi:view-compact");
|
|
});
|
|
|
|
it("uses titleCase of path when view has no title", () => {
|
|
const hass = createHass({
|
|
panels: {
|
|
lovelace: {
|
|
url_path: "lovelace",
|
|
title: "Dashboard",
|
|
component_name: "lovelace",
|
|
},
|
|
} as unknown as HomeAssistant["panels"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(
|
|
hass,
|
|
"/lovelace/my-view",
|
|
lovelaceConfig
|
|
);
|
|
expect(result.label).toBe("My-view");
|
|
});
|
|
|
|
it("uses index as name when view has no title or path", () => {
|
|
const hass = createHass({
|
|
panels: {
|
|
lovelace: {
|
|
url_path: "lovelace",
|
|
title: "Dashboard",
|
|
component_name: "lovelace",
|
|
},
|
|
} as unknown as HomeAssistant["panels"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(
|
|
hass,
|
|
"/lovelace/3",
|
|
lovelaceConfig
|
|
);
|
|
expect(result.label).toBe("3");
|
|
});
|
|
|
|
it("falls back to panel info when view not found", () => {
|
|
const hass = createHass({
|
|
panels: {
|
|
lovelace: {
|
|
url_path: "lovelace",
|
|
title: "Dashboard",
|
|
component_name: "lovelace",
|
|
},
|
|
} as unknown as HomeAssistant["panels"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(
|
|
hass,
|
|
"/lovelace/nonexistent",
|
|
lovelaceConfig
|
|
);
|
|
expect(result.label).toBe("Dashboard");
|
|
});
|
|
|
|
it("falls back to panel info when no lovelace config provided", () => {
|
|
const hass = createHass({
|
|
panels: {
|
|
lovelace: {
|
|
url_path: "lovelace",
|
|
title: "Dashboard",
|
|
component_name: "lovelace",
|
|
},
|
|
} as unknown as HomeAssistant["panels"],
|
|
});
|
|
|
|
const result = computeNavigationPathInfo(hass, "/lovelace/overview");
|
|
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");
|
|
});
|
|
});
|
|
});
|