Skip to content

fix(apps): render array settings so Menu Board installs with its menu - #3276

Merged
vpetersson merged 3 commits into
Screenly:masterfrom
vpetersson-bot:fix/app-store-array-settings
Aug 12, 2026
Merged

fix(apps): render array settings so Menu Board installs with its menu#3276
vpetersson merged 3 commits into
Screenly:masterfrom
vpetersson-bot:fix/app-store-array-settings

Conversation

@vpetersson-bot

Copy link
Copy Markdown
Contributor

Issues Fixed

The Menu Board app installs from Add → Apps, but the board shows no menu items.

The config form skipped any setting whose JSON Schema type is array: widgetFor() mapped it to 'unsupported' and renderField() dropped the field. Menu Board keeps its entire content in one array (item, exploded by {?…,item*,…}), so the operator saw Board title / Subtitle / Currency / Footer note and had no way to enter a single item. The launch URL carried the scalars and not one item= param.

The app only falls back to its worked example when the query string is empty, so filling in any other field produced a board with a title and nothing under it — which is what made it look like the app itself was broken. World Clock's tz was affected identically. Those are the only two apps in the store index that use array settings.

Description

Ports the app store's repeated-group widget: rows of the item schema's sub-fields, each composed into one token via its x-format.

  • apps/item-format.ts (new) — applyItemFormat is a faithful port of the store's lib/item-format.js, so the same rows build the same URL whether the operator configures the app in the store or here. parseItemToken is the inverse.
  • apps/widget-for.tstype: 'array''array'.
  • apps/manifest-form.tsrenderArrayField(): add/remove rows, compose on change, seed from saved values on edit. Timezone items get the shared IANA datalist.
  • _styles.scss — row / Add / remove / empty-state styles. The remove button states its own colours because .app-btn-icon reads --surface-text-muted, which only exists inside a .surface context, and the modal body is not one.

On reopening a saved config. The store never does this, but our edit modal does, and x-format drops a blank field together with its separator — so a short token is genuinely ambiguous (Coffee|Espresso is a section and an item; Cortado|3.10 is an item and its price). parseItemToken keeps the leftmost fields and never drops one the schema marks required. Parts land on a contiguous run of fields, so re-composing an untouched row reproduces the token exactly: reopening and saving an installed app never rewrites its launch URL. That is pinned by a test over every shape Menu Board documents.

Verified end-to-end by driving the real renderer in headless Chromium against the live manifests, inside a replica of the .modal-card DOM, then loading the generated URLs in the live apps:

  • before (scalars only): Corner Coffee + footer note, zero items
  • after: full board — Coffee / Pastries / Lunch sections, £3.40, description lines, footer note
  • World Clock: ?tz=Europe/Oslo|Home&tz=America/New_York renders both city cards

Reverting the one-line widget-for.ts change fails 17 of the 18 new DOM tests.

Dev dependency. The DOM suite needs a document, so happy-dom is added as a dev dependency and preloaded via bunfig.toml. CI's bun install --frozen-lockfile + bun run test picks it up unchanged.

Two upstream notes, not changed here because doing so unilaterally would diverge from the store:

  1. Neither composer escapes the separator, so a field containing | yields an ambiguous token. Menu Board's parser handles \|, but nothing emits it.
  2. The ambiguity exists only because x-format drops a blank field and its separator. Keeping the separator would make tokens unambiguous in both directions.

Checklist

  • I have performed a self-review of my own code.
  • New and existing unit tests pass locally and on CI with my changes.
  • I have done an end-to-end test for Raspberry Pi devices.
  • I have tested my changes for x86 devices.
  • I added a documentation for the changes I have made (when necessary).

Both device boxes are unticked deliberately: this is operator-browser code in the Add → Apps modal, with no viewer, player or device-side component. It was verified in a real browser as described above rather than on hardware.

🤖 Generated with Claude Code

The Add -> Apps config form skipped any setting whose JSON Schema type
was `array`: widgetFor() mapped it to 'unsupported' and renderField()
dropped the field. Menu Board keeps its entire content in one array
(`item`, exploded by `{?...,item*,...}`), so the operator got Board
title / Subtitle / Currency / Footer note and no way to enter a single
item. The launch URL carried the scalars and not one `item=` param, and
because the app only falls back to its worked example when the query
string is empty, filling in any other field produced a board with a
title and nothing on it. World Clock's `tz` was affected the same way.

Port the app store's repeated-group widget: rows of the item schema's
sub-fields, each composed into one token via its `x-format`. The
composer is a faithful port of the store's lib/item-format.js so the
same rows build the same URL in both places.

Edit mode has no counterpart in the store, which only ever composes a
fresh link. Since `x-format` drops a blank field together with its
separator, a short token is genuinely ambiguous, so parseItemToken
keeps the leftmost fields and never drops one the schema marks
`required`. Parts land on a contiguous run of fields, so re-composing
an untouched row reproduces the token exactly and reopening an
installed app never rewrites its launch URL.

The remove button states its own colours: .app-btn-icon reads
--surface-text-muted, which only exists inside a .surface context, and
the modal body is not one.

Tests cover the codec, the round trip over every shape Menu Board
documents, and the widget itself. The DOM suite needs a document, so
happy-dom is added as a dev dependency and preloaded via bunfig.toml.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@vpetersson-bot
vpetersson-bot requested a review from a team as a code owner August 12, 2026 10:03
@vpetersson-bot vpetersson-bot self-assigned this Aug 12, 2026
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (master@936e7d8). Learn more about missing BASE report.

Additional details and impacted files
@@            Coverage Diff            @@
##             master    #3276   +/-   ##
=========================================
  Coverage          ?   90.65%           
=========================================
  Files             ?       76           
  Lines             ?     8467           
  Branches          ?      898           
=========================================
  Hits              ?     7676           
  Misses            ?      570           
  Partials          ?      221           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Add → Apps manifest-driven configuration so apps with array-type settings (notably Menu Board’s item[] and World Clock’s tz[]) render an editable repeated-row control instead of silently skipping the setting, ensuring generated launch URLs include the repeated query params those apps require.

Changes:

  • Add an array widget type and render it as a repeated group of sub-field inputs composed into tokens via the item schema’s x-format.
  • Introduce a shared token codec (applyItemFormat / parseItemToken) and add comprehensive unit + DOM tests to pin URL round-tripping and UI behavior.
  • Add happy-dom test preloading for Bun so DOM-dependent modules can be tested in CI.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/anthias_server/app/static/src/test-setup.ts Preloads a DOM environment for Bun tests via happy-dom.
src/anthias_server/app/static/src/apps/widget-for.ts Maps JSON Schema type: 'array' to a supported widget ('array').
src/anthias_server/app/static/src/apps/widget-for.test.ts Updates/extends tests to assert arrays are rendered (not skipped).
src/anthias_server/app/static/src/apps/types.ts Adds maxItems to the typed subset of the manifest schema.
src/anthias_server/app/static/src/apps/manifest-form.ts Implements the repeated-group array widget and hooks it into renderField().
src/anthias_server/app/static/src/apps/manifest-form.test.ts DOM-level regression tests for array widget rendering/editing/reopen behavior.
src/anthias_server/app/static/src/apps/item-format.ts Adds token compose/parse utilities for x-format-based array items.
src/anthias_server/app/static/src/apps/item-format.test.ts Unit tests for token codec and launch URL expansion behavior.
src/anthias_server/app/static/sass/_styles.scss Styling for repeated rows, add/remove controls, and empty-state hint.
package.json Adds happy-dom and global registrator as dev dependencies.
bunfig.toml Preloads the DOM setup module for Bun tests.
bun.lock Locks new dev dependency graph (happy-dom and transitive updates).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/anthias_server/app/static/src/apps/manifest-form.ts

@Glassto Glassto left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(apps)

Solid fix for a real bug, and a good root-cause writeup. The one-line widget-for.ts change reverting to break 17/18 new DOM tests is a good sign that the test suite actually exercises the fix, not just its presence.

A few things I'd like resolved or clarified before approving:

  1. parseItemToken ambiguity handling. The description acknowledges that x-format drops a blank field and its separator, which makes some tokens genuinely ambiguous (Coffee|Espresso vs. Cortado|3.10) . The "keep leftmost fields, never drop a required one" heuristic sounds reasonable, but I'd like the round-trip test to explicitly cover: a blank field in the middle of a row, multiple consecutive blanks, and a single | with everything else empty , not just "every shape Menu Board documents". Can you confirm these are covered, or add them?

  2. Separator escaping. Agreed that not emitting \| here (to stay compatible with the store) is the right call for this PR. Can we open a tracking issue so it doesn't get lost?

  3. x-format dropping blank-field separators. Same ask, a linked issue so this stays visible as tech debt rather than becoming permanent by omission.

  4. SonarQube flagged 3 new issues. Could you share what they are or link the SonarCloud report? Want to confirm none touch the parsing logic from point 1.

  5. Nit: worth double-checking the 'array' mapping in widget-for.ts doesn't accidentally catch other array-typed schema shapes (e.g. oneOf/anyOf) elsewhere in the manifest set, beyond Menu Board/World Clock.

no notes at the_styles.scss, the happy-dom dev dependency, and the bunfig.toml preload: all look clean and low-risk.

Nothing here blocks merge on its own; mainly want (1) and (4) addressed before approving, with (2)/(3) tracked as follow-ups.

Copilot: sync() emitted `[]` when no rows were filled in. pruneEmpty()
in apps.ts clears none of its guards on an empty array, so an untouched
array setting was persisted into metadata.app.values as `{ item: [] }`
while the launch URL carried no `item=` at all. Emit undefined instead,
keeping the saved values 1:1 with the URL as that module documents.

SonarCloud flagged three issues, all in item-format.ts:

  - S8786: /\{([^}]+)\}/g backtracks quadratically on an unterminated
    brace. Not reachable from an allowlisted manifest, but a two-indexOf
    scan is linear and no less readable, so fix rather than suppress.
  - S7780: String.raw for the escape replacement.
  - S7755: .at(-1) over [length - 1].

Review asked for explicit round-trip coverage of blank fields: a blank
in the middle of a row, consecutive blanks, a blank required field, and
an all-blank row - all driven from rows so the dropped separators are
the composer's own. Separator-only and dangling-separator tokens are
covered too: they normalise rather than round-trip, and a combinatorial
test pins the guarantee they rest on, that the composer can never emit
one.

Also pins an array of plain scalars (no x-format), the one array shape
no store app ships today.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@vpetersson-bot

Copy link
Copy Markdown
Contributor Author

Thanks — all five addressed. Code changes are in ea73dd2.

1. parseItemToken ambiguity coverage — added

You were right that "every shape Menu Board documents" was the wrong framing: those are all tokens, and the interesting cases are the rows that produce them. New blank fields round trip block drives compose → parse → compose from rows, so the dropped separators are the composer's own:

case row token round-trips
blank in the middle section+name+description, no price Lunch|Soup|Ask inside yes
two consecutive blanks name + description only Soup|Ask inside yes
every optional blank name only Espresso yes
blank required field section + price, no name Coffee|3.10 yes
every field blank `` (empty) yes

On your third shape, a single | with everything else empty: it does not round-trip — '\|' parses to an all-blank row and re-composes to ''. That is correct rather than a gap, and worth being explicit about: the composer can never emit such a token. It drops a blank field together with its separator, so no row produces a leading, trailing, or doubled separator. A bare | can only come from a hand-edited metadata.app.values, and normalising it away (the row contributes no item) is the right outcome.

I did not want that resting on my say-so, so there is now a combinatorial test over all 16 present/absent field combinations asserting no emitted token ever starts with, ends with, or contains a doubled separator — plus tests that '|', '||', '|||', ' | ' collapse to nothing and 'a|' / '|a' normalise to 'a'.

2 + 3. Tracking issues — opened

Both in Screenly-Labs/app-store, since that is where the x-format contract and the reference composer live:

  • Screenly-Labs/app-store issue 56 — separators inside field values are not escaped (Menu Board's parser already handles \|; nothing emits it)
  • Screenly-Labs/app-store issue 57 — dropping a blank field's separator is what makes short tokens ambiguous, with a suggested encoding that preserves position

Each explains why it is not fixed downstream: Anthias mirrors the store byte-for-byte so identical rows build identical URLs in both UIs, and a one-sided change would diverge them.

4. SonarCloud — 3 issues, all mine, all fixed

The check reported green, but you were right that there were 3 new issues. All three were in item-format.ts, and one did touch the parsing logic from point 1:

rule line issue
typescript:S8786 (major) parseFormat /\{([^}]+)\}/g backtracks quadratically on an unterminated brace
typescript:S7780 (minor) escapeRe use String.raw to avoid escaping \
typescript:S7755 (minor) parseItemToken prefer .at(-1) over [length - 1]

S8786 is a real finding, not a false positive: [^}]+ followed by \} retries at every length for each start position, so '{' + 'a'.repeat(n) is O(n²). Not reachable in practice — fmt comes from a manifest fetched from an allowlisted host and is ~40 chars — but the remedy is a two-indexOf scan that is linear and no less readable, so I fixed it rather than suppressing. Behaviour is identical, including the {}-is-not-a-field edge case, and the existing parseFormat tests still pass unchanged.

5. Other array-typed shapes — checked across all 16 manifests

Walked every property in every manifest in the store index, recursively, looking for oneOf / anyOf / allOf / not / $ref / prefixItems / patternProperties / union type arrays:

array properties:  menu-board.item, world-clock.tz   (both items.type=object, x-format, required)
combinators/unions: NONE
type values used:   array, boolean, number, object, string
x-widget values:    datetime, location-map, select, text, timezone

So nothing else is caught today. On the mechanism: widgetFor keys strictly on schema.type === 'array', so a combinator-only schema with no type falls through to 'text' exactly as before — the new branch cannot claim it. No nested arrays and no maxItems in the wild either.

The one plausible shape nobody ships yet is an array of plain scalars (no x-format, no sub-properties). That already degrades sensibly — one input per row, raw value as the token — and is now pinned by a test so it does not regress into something worse than the old skip.

@vpetersson-bot

Copy link
Copy Markdown
Contributor Author

One correction to my previous comment, for the record: I said the "every shape Menu Board documents" framing had missed the blank-in-the-middle case. Checking the original commit, it had not — Lunch|Soup|Ask inside is exactly that case (blank price) and was in the round-trip list from the start, as was Cortado|3.10 (blank section).

What the new block genuinely adds is (a) driving those cases from rows rather than tokens, so the dropped separators are the composer's own rather than ones I wrote by hand, and (b) the combinatorial invariant over all 16 present/absent combinations. Both are worth having, but the coverage gap I implied was not there.

The rest of that comment stands as written. SonarCloud is now reporting 0 open issues on this PR, down from the 3 noted above.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

src/anthias_server/app/static/src/apps/manifest-form.ts:257

  • Saved tokens are rehydrated via addRow(...), but because addRow() currently applies the maxItems guard, any saved entries beyond the cap will be dropped and sync() will emit a shortened value set. If you make addRow() cap only non-seeded rows, make sure this seed path opts into that behavior.
  // Edit mode: repopulate from the tokens saved in metadata.app.values.
  const saved = Array.isArray(seedValue) ? seedValue : []
  for (const token of saved) {
    if (typeof token !== 'string' || !token.trim()) continue
    addRow(
      fmt
        ? parseItemToken(fmt, token, required)
        : { [keys[0] as string]: token },
    )
  }

src/anthias_server/app/static/src/apps/manifest-form.test.ts:112

  • The DOM tests append a new host to document.body on every beforeEach() but never remove prior hosts. This can leak DOM state between tests and makes the suite order-dependent as it grows.

Clearing the body (or removing the previous host) in beforeEach keeps the tests isolated.

beforeEach(() => {
  host = document.createElement('div')
  document.body.appendChild(host)
  latest = {}
})

Comment thread src/anthias_server/app/static/src/apps/manifest-form.ts Outdated
addRow() enforced maxItems for every row, including those seeded from
metadata.app.values. If a manifest introduced or lowered maxItems after
an install, reopening the edit modal rendered only the first maxItems
rows and the sync() that follows seeding immediately propagated the
truncated token list — so the operator lost items by opening the modal,
before touching anything.

Move the cap to the Add button's click handler: it gates the affordance,
not the data. A saved config over the cap renders in full, Add stays
disabled until removals bring it back under, and removal still works.

Latent today (no app in the store index declares maxItems) but it would
have bitten the first manifest that added one.

Reported by Copilot.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@sonarqubecloud

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 12 changed files in this pull request and generated no new comments.

@vpetersson
vpetersson merged commit 8de6fe7 into Screenly:master Aug 12, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants