Dashboard strategy generating the views (#175)
This commit was merged in pull request #175.
This commit is contained in:
@@ -174,8 +174,23 @@ is simply a Home Assistant client.
|
||||
Assistant user it may also reach other permitted Home Assistant entities
|
||||
and services.
|
||||
|
||||
2. **Build a dashboard with only the NestQuest panel cards.** Create a
|
||||
Lovelace dashboard whose **URL slug is `nestquest`** (Settings →
|
||||
2. **Build a dashboard with only the NestQuest panel cards.** The
|
||||
recommended path is the **zero-config dashboard strategy**: create a
|
||||
Lovelace dashboard whose URL slug is `nestquest` with
|
||||
|
||||
```yaml
|
||||
strategy:
|
||||
type: custom:nestquest-party
|
||||
```
|
||||
|
||||
and no views of your own — the strategy generates the party-board and
|
||||
quest-log views (each already in Panel layout) from the household
|
||||
roster at render time. Optional `url_path` and `weather_entity` keys
|
||||
may sit beside `strategy:`. The manual instructions below describe
|
||||
building the two views by hand if you prefer not to use the strategy.
|
||||
|
||||
**Manual alternative:** create a Lovelace dashboard whose **URL slug
|
||||
is `nestquest`** (Settings →
|
||||
Dashboards → Edit → URL slug). A functional panel needs **two** views
|
||||
in that dashboard, each with its own card config from
|
||||
`design/ENTITIES-AND-SERVICES.md` §4. Give the views these exact URL
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import "./cards/party-board";
|
||||
import "./cards/quest-log";
|
||||
import "./cards/admin";
|
||||
import "./strategy";
|
||||
|
||||
const FONTS_HREF = "/nestquest-static/nestquest-fonts.css";
|
||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { HassLike } from "./types";
|
||||
|
||||
type StateObject = {
|
||||
state: unknown;
|
||||
attributes?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type RosterEntry = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type View = {
|
||||
path?: string;
|
||||
title?: string;
|
||||
type: string;
|
||||
cards: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
type LovelaceConfig = {
|
||||
views: View[];
|
||||
};
|
||||
|
||||
type StrategyConfig = Record<string, unknown>;
|
||||
|
||||
type StrategyClass = {
|
||||
generate: (
|
||||
config: StrategyConfig,
|
||||
hass: HassLike | undefined
|
||||
) => Promise<LovelaceConfig>;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
customStrategies?: Record<string, StrategyClass>;
|
||||
}
|
||||
}
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/** The slug a discovered roster entry carries, or "" when the entry is
|
||||
* not an object with a non-empty slug string — the same shape check
|
||||
* the party board applies to the same attribute. */
|
||||
function asRosterSlug(entry: unknown): string {
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return "";
|
||||
}
|
||||
const slug = (entry as Record<string, unknown>).slug;
|
||||
return typeof slug === "string" ? slug.trim() : "";
|
||||
}
|
||||
|
||||
/** The dashboard's URL path segment, used to COMPUTE the card paths the
|
||||
* navigation needs. A dashboard lives at a single top-level segment
|
||||
* (/<url_path>), so the first pathname segment is the dashboard root —
|
||||
* this is where the strategy renders, and navigating back to it shows
|
||||
* the first view (the party board). An explicit ``url_path`` in the
|
||||
* strategy configuration wins for setups that need to pin it. */
|
||||
function dashboardUrlPath(config: StrategyConfig): string {
|
||||
const configured = asString(config.url_path).replace(/^\/+|\/+$/g, "");
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
const segment = window.location.pathname.split("/").filter(Boolean)[0];
|
||||
return segment ?? "";
|
||||
}
|
||||
|
||||
/** The display name a roster entry carries, or "" when it is not a
|
||||
* non-empty string — the roster's ``name`` field (sensor.py
|
||||
* ``_child_roster``), used for the quest-log view titles. */
|
||||
function asRosterName(entry: unknown): string {
|
||||
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return "";
|
||||
}
|
||||
const name = (entry as Record<string, unknown>).name;
|
||||
return typeof name === "string" ? name.trim() : "";
|
||||
}
|
||||
|
||||
/** The ordered child roster the integration publishes on the household
|
||||
* rollup (custom_components/nestquest/sensor.py — ``child_roster``,
|
||||
* the snapshot's sort_order, then id). The same source the party
|
||||
* board's plate discovery reads, consumed here at render time so the
|
||||
* quest-log views exist for exactly the active children. */
|
||||
function rosterEntries(hass: HassLike | undefined): RosterEntry[] {
|
||||
const states = hass?.states as Record<string, StateObject> | undefined;
|
||||
const household = states?.["sensor.nestquest_household_quests_due_today"];
|
||||
const roster = household?.attributes?.child_roster;
|
||||
if (!Array.isArray(roster)) {
|
||||
return [];
|
||||
}
|
||||
return roster
|
||||
.map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) }))
|
||||
.filter((entry) => entry.slug.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-config NestQuest dashboard strategy (Feature 20).
|
||||
*
|
||||
* A dashboard configured with ``strategy: { type: custom:nestquest-party }``
|
||||
* gets its views GENERATED at render time from the household roster: one
|
||||
* party-board view (the dashboard root) and one quest-log view per child,
|
||||
* ordered by the roster's sort_order. The paths the cards navigate by are
|
||||
* COMPUTED, not configured:
|
||||
*
|
||||
* - the board's ``quest_log_path`` is the dashboard root; tapping a crest
|
||||
* pushes ``<root>/<slug>`` (party-board.ts ``_openQuestLog``);
|
||||
* - each log view's ``path`` IS the child's slug, so the log resolves its
|
||||
* child from the last pathname segment (quest-log.ts
|
||||
* ``childSlugFromPath``) and its ``board_path`` back to the dashboard
|
||||
* root (quest-log.ts ``_returnToBoard``).
|
||||
*
|
||||
* With zero children the board view still renders — the board card shows
|
||||
* its own "not set up yet" notice for an empty roster, so no log views are
|
||||
* needed. ``url_path`` and ``weather_entity`` may still be set explicitly
|
||||
* in the strategy configuration; everything else is derived.
|
||||
*/
|
||||
export class NestQuestPartyStrategy {
|
||||
static async generate(
|
||||
config: StrategyConfig,
|
||||
hass: HassLike | undefined
|
||||
): Promise<LovelaceConfig> {
|
||||
const root = `/${dashboardUrlPath(config)}`;
|
||||
const weatherEntity = asString(config.weather_entity);
|
||||
const boardCard: Record<string, unknown> = {
|
||||
type: "custom:nestquest-party-board-card",
|
||||
quest_log_path: root,
|
||||
};
|
||||
if (weatherEntity) {
|
||||
boardCard.weather_entity = weatherEntity;
|
||||
}
|
||||
const logCard: Record<string, unknown> = {
|
||||
type: "custom:nestquest-quest-log-card",
|
||||
board_path: root,
|
||||
};
|
||||
if (weatherEntity) {
|
||||
logCard.weather_entity = weatherEntity;
|
||||
}
|
||||
return {
|
||||
views: [
|
||||
{
|
||||
path: "party",
|
||||
title: "The Party",
|
||||
type: "panel",
|
||||
cards: [boardCard],
|
||||
},
|
||||
...rosterEntries(hass).map<View>((entry) => ({
|
||||
path: entry.slug,
|
||||
title: entry.name
|
||||
? `${entry.name}'s Quest Log`
|
||||
: `${entry.slug}'s Quest Log`,
|
||||
type: "panel",
|
||||
cards: [{ ...logCard }],
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
window.customStrategies = window.customStrategies ?? {};
|
||||
window.customStrategies["nestquest-party"] = NestQuestPartyStrategy;
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Import the built bundle into jsdom and generate the dashboard strategy's
|
||||
* views.
|
||||
*
|
||||
* Usage: node render-strategy.mjs < spec.json
|
||||
*
|
||||
* The spec JSON carries {bundle, url, config, hass}; stdout receives one
|
||||
* JSON object {views} — the strategy output's views array with the paths
|
||||
* and card configs the generated dashboard renders. The Python panel
|
||||
* tests drive this harness so the assertions run against the exact bundle
|
||||
* HACS ships.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const spec = JSON.parse(readFileSync(0, "utf8"));
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: spec.url ?? "http://homeassistant.local/nestquest/party",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
|
||||
// Expose the jsdom globals the Lit bundle expects. Node's own intrinsics
|
||||
// (Object, Array, ...) are kept — only what globalThis lacks is copied.
|
||||
for (const key of Object.getOwnPropertyNames(dom.window)) {
|
||||
if (!(key in globalThis)) {
|
||||
try {
|
||||
globalThis[key] = dom.window[key];
|
||||
} catch {
|
||||
// jsdom getters may throw on access; the strategy does not need them.
|
||||
}
|
||||
}
|
||||
}
|
||||
globalThis.window = dom.window;
|
||||
|
||||
await import(pathToFileURL(spec.bundle).href);
|
||||
|
||||
const strategy = window.customStrategies?.["nestquest-party"];
|
||||
if (!strategy) {
|
||||
console.error("nestquest-party strategy is not registered on the bundle");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const lovelace = await strategy.generate(spec.config, spec.hass);
|
||||
const payload = JSON.stringify({ views: lovelace.views });
|
||||
await new Promise((resolve) => process.stdout.write(payload, () => resolve()));
|
||||
process.exit(0);
|
||||
+160
-1
@@ -24,6 +24,8 @@ PARTY_BOARD = FRONTEND_CARDS_DIR / "party-board.ts"
|
||||
QUEST_LOG = FRONTEND_CARDS_DIR / "quest-log.ts"
|
||||
BUNDLE_PATH = REPO_ROOT / "custom_components" / "nestquest" / "www" / "nestquest-cards.js"
|
||||
RENDER_HARNESS = FRONTEND_DIR / "tests" / "render-party-board.mjs"
|
||||
STRATEGY_HARNESS = FRONTEND_DIR / "tests" / "render-strategy.mjs"
|
||||
STRATEGY_SOURCE = FRONTEND_DIR / "src" / "strategy.ts"
|
||||
|
||||
PANEL_SOURCES = (PARTY_BOARD, QUEST_LOG)
|
||||
|
||||
@@ -286,4 +288,161 @@ def test_explicit_child_order_still_wins_over_discovery() -> None:
|
||||
_three_child_states(),
|
||||
)
|
||||
assert [plate["name"] for plate in result["plates"]] == ["Bo", "Ada"]
|
||||
assert "Cory" not in [plate["name"] for plate in result["plates"]]
|
||||
assert "Cory" not in [plate["name"] for plate in result["plates"]]
|
||||
|
||||
# --- Dashboard strategy tests -------------------------------------------------
|
||||
|
||||
STRATEGY_NAME = "nestquest-party"
|
||||
|
||||
|
||||
def _generate_dashboard(config: dict, states: dict, url: str | None = None) -> dict:
|
||||
"""Generate the strategy's dashboard from the built bundle.
|
||||
|
||||
Drives frontend/tests/render-strategy.mjs: the harness imports the
|
||||
committed bundle into a jsdom window at the given dashboard URL and
|
||||
prints the views ``window.customStrategies["nestquest-party"].
|
||||
generate(config, hass)`` returns.
|
||||
"""
|
||||
assert BUNDLE_PATH.is_file(), "bundle missing: run cd frontend && npm run build"
|
||||
assert STRATEGY_HARNESS.is_file()
|
||||
spec = {
|
||||
"bundle": str(BUNDLE_PATH),
|
||||
"url": url or "http://homeassistant.local/nestquest/party",
|
||||
"config": config,
|
||||
"hass": {"config": {"time_zone": "UTC"}, "states": states},
|
||||
}
|
||||
completed = subprocess.run(
|
||||
["node", str(STRATEGY_HARNESS)],
|
||||
input=json.dumps(spec),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
assert completed.returncode == 0, (
|
||||
f"strategy harness failed: {completed.stderr}"
|
||||
)
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def test_strategy_is_registered_on_the_bundle() -> None:
|
||||
"""The strategy ships in the same bundle as the cards and registers
|
||||
itself under window.customStrategies so a dashboard can use
|
||||
strategy: { type: custom:nestquest-party }."""
|
||||
source = _read(STRATEGY_SOURCE)
|
||||
assert "customStrategies" in source
|
||||
assert f'["{STRATEGY_NAME}"]' in source or f'"{STRATEGY_NAME}"' in source
|
||||
bundle = _read(BUNDLE_PATH)
|
||||
assert "nestquest-party" in bundle
|
||||
|
||||
|
||||
def test_strategy_generates_views_for_three_children() -> None:
|
||||
"""For the seeded three-child household the strategy generates one
|
||||
party-board view plus one quest-log view per child in roster order,
|
||||
with COMPUTED paths: the board view at the dashboard root, each log
|
||||
view at the child's slug so quest-log.ts's childSlugFromPath reads
|
||||
the child from the URL's last segment."""
|
||||
result = _generate_dashboard({}, _three_child_states())
|
||||
views = result["views"]
|
||||
assert len(views) == 4
|
||||
assert [view["path"] for view in views] == [
|
||||
"party",
|
||||
"ada",
|
||||
"bo",
|
||||
"cory",
|
||||
]
|
||||
assert [view["title"] for view in views] == [
|
||||
"The Party",
|
||||
"Ada's Quest Log",
|
||||
"Bo's Quest Log",
|
||||
"Cory's Quest Log",
|
||||
]
|
||||
|
||||
|
||||
def test_strategy_every_view_is_panel_type() -> None:
|
||||
"""Every generated view carries ``type: "panel"``: Home Assistant
|
||||
defaults an unspecified view to Masonry, which constrains the
|
||||
1080px-tall NestQuest card to a narrow column (README's TouchHub
|
||||
setup warns of exactly this); with a generated dashboard there is
|
||||
no editor step left to set Panel by hand."""
|
||||
result = _generate_dashboard({}, _three_child_states())
|
||||
for view in result["views"]:
|
||||
assert view["type"] == "panel"
|
||||
|
||||
|
||||
def test_strategy_log_view_titles_use_the_roster_display_name() -> None:
|
||||
"""Quest-log view titles come from the roster's ``name`` field (the
|
||||
child's display name), not the lowercased entity-id slug; the slug
|
||||
stays the view's path."""
|
||||
result = _generate_dashboard(
|
||||
{},
|
||||
{
|
||||
"sensor.nestquest_household_quests_due_today": _state(
|
||||
"0",
|
||||
child_roster=[
|
||||
{"child_id": 7, "name": "Milo", "slug": "milo"},
|
||||
],
|
||||
),
|
||||
},
|
||||
)
|
||||
views = result["views"]
|
||||
assert len(views) == 2
|
||||
assert views[1]["path"] == "milo"
|
||||
assert views[1]["title"] == "Milo's Quest Log"
|
||||
|
||||
|
||||
def test_strategy_passes_weather_entity_through_to_the_cards() -> None:
|
||||
"""An explicit ``weather_entity`` in the strategy configuration is
|
||||
passed through to the generated card configs (both the board and
|
||||
the quest log render the weather dock when it is set)."""
|
||||
result = _generate_dashboard(
|
||||
{"weather_entity": "weather.home"},
|
||||
_three_child_states(),
|
||||
)
|
||||
views = result["views"]
|
||||
for view in views:
|
||||
assert view["cards"][0]["weather_entity"] == "weather.home"
|
||||
# And without it, no weather_entity key is emitted at all.
|
||||
unset = _generate_dashboard({}, _three_child_states())
|
||||
for view in unset["views"]:
|
||||
assert "weather_entity" not in view["cards"][0]
|
||||
|
||||
|
||||
def test_strategy_wires_navigation_between_the_views() -> None:
|
||||
"""The board's quest_log_path and each log's board_path are computed
|
||||
to the dashboard root the strategy renders at, so party-board.ts's
|
||||
crest tap pushes <root>/<slug> and quest-log.ts's idle return goes
|
||||
back to the board — neither path is configured by hand."""
|
||||
dashboard_url = "http://homeassistant.local/nestquest"
|
||||
root = "/nestquest"
|
||||
result = _generate_dashboard(
|
||||
{}, _three_child_states(), url=dashboard_url
|
||||
)
|
||||
views = result["views"]
|
||||
board = views[0]["cards"][0]
|
||||
assert board["type"] == "custom:nestquest-party-board-card"
|
||||
assert board["quest_log_path"] == root
|
||||
for slug, view in zip(("ada", "bo", "cory"), views[1:]):
|
||||
assert view["cards"][0]["type"] == "custom:nestquest-quest-log-card"
|
||||
assert view["cards"][0]["board_path"] == root
|
||||
|
||||
|
||||
def test_strategy_zero_children_still_generates_the_board() -> None:
|
||||
"""With an empty roster (NestQuest not set up yet) the strategy
|
||||
renders the board view alone — the board card shows its own
|
||||
"not set up yet" notice for an empty roster — and never crashes."""
|
||||
result = _generate_dashboard({}, {})
|
||||
assert len(result["views"]) == 1
|
||||
assert result["views"][0]["path"] == "party"
|
||||
|
||||
|
||||
def test_strategy_config_overrides_still_apply() -> None:
|
||||
"""Explicit strategy configuration wins where the design allows: an
|
||||
explicit url_path pins the computed dashboard root."""
|
||||
result = _generate_dashboard(
|
||||
{"url_path": "/wallboard"},
|
||||
_three_child_states(),
|
||||
)
|
||||
root = "/wallboard"
|
||||
assert result["views"][0]["cards"][0]["quest_log_path"] == root
|
||||
for view in result["views"][1:]:
|
||||
assert view["cards"][0]["board_path"] == root
|
||||
|
||||
Reference in New Issue
Block a user