Fix strategy review findings: panel views, display-name titles, weather pass-through test
- P1: every generated view now emits type: "panel" — HA defaults an
unspecified view to Masonry, which constrains the 1080px-tall card to
a narrow column (the exact failure the README warns about), and a
generated dashboard leaves no editor step to set Panel by hand. Added
a regression assertion that each view's type is "panel".
- P3: quest-log view titles use the roster's display name ("Ada's Quest
Log") instead of the raw slug; the slug stays the view path.
- P3: added a focused test that weather_entity from the strategy config
is passed through to every generated card config.
- P3: dropped the vestigial new (): unknown constructor from
StrategyClass — HA calls generate() statically.
- P2: README's TouchHub setup now documents the zero-config dashboard
strategy as the primary path, with the manual steps kept as the
alternative (full runbook rewrite is task fc69c940).
- Rebuilt the bundle.
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
|
||||
|
||||
@@ -3506,10 +3506,16 @@ function _n(s) {
|
||||
return t || (window.location.pathname.split("/").filter(Boolean)[0] ?? "");
|
||||
}
|
||||
function yn(s) {
|
||||
const n = s?.states?.["sensor.nestquest_household_quests_due_today"]?.attributes?.child_roster;
|
||||
return Array.isArray(n) ? n.map((i) => gn(i)).filter((i) => i.length > 0) : [];
|
||||
if (s === null || typeof s != "object" || Array.isArray(s))
|
||||
return "";
|
||||
const t = s.name;
|
||||
return typeof t == "string" ? t.trim() : "";
|
||||
}
|
||||
class bn {
|
||||
function bn(s) {
|
||||
const n = s?.states?.["sensor.nestquest_household_quests_due_today"]?.attributes?.child_roster;
|
||||
return Array.isArray(n) ? n.map((i) => ({ slug: gn(i), name: yn(i) })).filter((i) => i.slug.length > 0) : [];
|
||||
}
|
||||
class vn {
|
||||
static async generate(t, e) {
|
||||
const n = `/${_n(t)}`, i = Bt(t.weather_entity), r = {
|
||||
type: "custom:nestquest-party-board-card",
|
||||
@@ -3525,11 +3531,13 @@ class bn {
|
||||
{
|
||||
path: "party",
|
||||
title: "The Party",
|
||||
type: "panel",
|
||||
cards: [r]
|
||||
},
|
||||
...yn(e).map((l) => ({
|
||||
path: l,
|
||||
title: `${l}'s Quest Log`,
|
||||
...bn(e).map((l) => ({
|
||||
path: l.slug,
|
||||
title: l.name ? `${l.name}'s Quest Log` : `${l.slug}'s Quest Log`,
|
||||
type: "panel",
|
||||
cards: [{ ...o }]
|
||||
}))
|
||||
]
|
||||
@@ -3537,9 +3545,9 @@ class bn {
|
||||
}
|
||||
}
|
||||
window.customStrategies = window.customStrategies ?? {};
|
||||
window.customStrategies["nestquest-party"] = bn;
|
||||
const vn = "/nestquest-static/nestquest-fonts.css";
|
||||
window.customStrategies["nestquest-party"] = vn;
|
||||
const xn = "/nestquest-static/nestquest-fonts.css";
|
||||
if (!document.querySelector('link[data-nq-fonts=""]')) {
|
||||
const s = document.createElement("link");
|
||||
s.rel = "stylesheet", s.href = vn, s.dataset.nqFonts = "", document.head.appendChild(s);
|
||||
s.rel = "stylesheet", s.href = xn, s.dataset.nqFonts = "", document.head.appendChild(s);
|
||||
}
|
||||
|
||||
@@ -6,12 +6,14 @@ type StateObject = {
|
||||
};
|
||||
|
||||
type RosterEntry = {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
type View = {
|
||||
path?: string;
|
||||
title?: string;
|
||||
type: string;
|
||||
cards: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
@@ -22,7 +24,6 @@ type LovelaceConfig = {
|
||||
type StrategyConfig = Record<string, unknown>;
|
||||
|
||||
type StrategyClass = {
|
||||
new (): unknown;
|
||||
generate: (
|
||||
config: StrategyConfig,
|
||||
hass: HassLike | undefined
|
||||
@@ -65,12 +66,23 @@ function dashboardUrlPath(config: StrategyConfig): string {
|
||||
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 rosterSlugs(hass: HassLike | undefined): string[] {
|
||||
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;
|
||||
@@ -78,8 +90,8 @@ function rosterSlugs(hass: HassLike | undefined): string[] {
|
||||
return [];
|
||||
}
|
||||
return roster
|
||||
.map((entry) => asRosterSlug(entry))
|
||||
.filter((slug) => slug.length > 0);
|
||||
.map((entry) => ({ slug: asRosterSlug(entry), name: asRosterName(entry) }))
|
||||
.filter((entry) => entry.slug.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,11 +141,15 @@ export class NestQuestPartyStrategy {
|
||||
{
|
||||
path: "party",
|
||||
title: "The Party",
|
||||
type: "panel",
|
||||
cards: [boardCard],
|
||||
},
|
||||
...rosterSlugs(hass).map<View>((slug) => ({
|
||||
path: slug,
|
||||
title: `${slug}'s Quest Log`,
|
||||
...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 }],
|
||||
})),
|
||||
],
|
||||
|
||||
@@ -350,6 +350,61 @@ def test_strategy_generates_views_for_three_children() -> None:
|
||||
"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:
|
||||
|
||||
Reference in New Issue
Block a user