fix(#238): wire TopBar primary-action on create screens, drop dead buttons #324

Merged
timothy merged 2 commits from fix/238-topbar-primary-action into main 2026-07-12 23:51:26 +02:00
13 changed files with 207 additions and 41 deletions
+38
View File
@@ -1680,3 +1680,41 @@ worried about was already correct (each handler loads/validates → 404 before `
first-party SPA only ever echoes the single canonical strong tag we emit, so no shipped client changes behavior;
the change only makes a hand-written/tooling `If-Match` get the RFC-correct status. Docs: `api-conventions.md`
§7a. Refs #265 #253 #197.
## 2026-07-12 — TopBar primary-action button: wire creates, drop the rest (#238)
The shell TopBar rendered a prominent top-right primary-action button (Plus icon) for **every** screen, but
only `SchedulesScreen` had ever subscribed to its `ctv:primary-action` event — so on every other screen the
button was **dead** (either a labelled no-op like "Save Changes"/"Add Channel", or, for the ~10 routes whose
`primaryAction` was `''`, a bare labelless "+"; the button rendered unconditionally). Issue #238 (from a #229
live-E2E finding, pre-existing since the TopBar's introduction).
**Decision — the Plus-icon button is a "create new item" affordance; keep+wire it only where that fits:**
- **TopBar renders the button only when the active route declares a non-empty `primaryAction`** (was:
unconditional). This alone removes every empty-`''` dead "+".
- **WIRE** (8 list screens with a single unambiguous create flow) via a shared `usePrimaryAction(routeId,
handler)` hook (`web/src/primaryAction.ts`), each delegating to the same top-level create handler its in-body
control uses (`navigateToPath('/app/new-channel')`, `setEditing({kind:'new'})`, `navigateToPath('…/add')`,
etc.): channels, schedules (refactored onto the hook), multiCollections, rerunCollections, traktLists,
fillerPresets, ffmpegProfiles, watermarks.
- **DROP** (`primaryAction: ''`, no button) everywhere else, for one of four reasons: (1) the action isn't a
create so the "+" is wrong and a correct in-body control already exists — editChannel (savebar), settings
(savebar), guide (Jump to now), playouts (Reset all), logs/troubleshooting/blockPlayoutTroubleshooting
(Refresh), playbackTroubleshooting (Play), yamlValidator (Validate); (2) ambiguous — collections (two create
types behind tabs); (3) a silent no-op — builder ("Create Channel" is disabled until the form is valid),
playlists (needs a group first); (4) semantically misplaced — dashboard (status page), libraries ("Scan" is
per-library-row, no global target).
**Why not wire everything** (the SchedulesScreen precedent): the recon found every screen already carries a
correct, disabled-state-aware, context-aware in-body control, and several banner actions are unreachable
without refactoring handlers out from under early returns, or would render a silent no-op — reintroducing the
very dead-button class #238 fixes. Wiring only the single-create-flow screens gives one explainable rule
("primary button = create a new item on a list screen") and needs no risky refactors.
**Also fixed here:** the `apiKey` route's `primaryAction: 'Save key'` + its description/comment were stale
post-#295 (the screen displays the machine key + changes the local password; there is nothing to "save") —
relabelled to reflect the #295 reality. Convention + hook documented in `spa-conventions.md` §10. The
implicit route-label ⟺ screen-subscription coupling is the kind of thing #247 (shell extraction) will
formalize; #238 keeps it a documented convention guarded by a data-driven `App.test.tsx` test over the URL-navigating
create screens (a typo'd route id → the banner navigates nowhere → red). Refs #238 #247.
+28
View File
@@ -339,3 +339,31 @@ Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state v
copying it.** #221 came from copying a fetch model that keeps stale results visible onto screens
that had gained mutation surfaces — the exemplar was safe read-only, the copy was not. Copying a
pattern copies its *assumptions*; confirm they still hold in the new context.
## 10. TopBar primary-action button (`usePrimaryAction`)
The shell TopBar (`App.tsx`) renders at most **one** primary-action button (top-right, Plus icon) for the
active screen. The TopBar has no reference to the screen component, so the click is delivered as a window
`CustomEvent` keyed on the active route id; the seam is `web/src/primaryAction.ts`:
- **A screen opts in** by calling `usePrimaryAction(routeId, handler)` at the **top of the component, before
any early return** (it's a hook). The handler must be reachable there — a create/navigate handler or a
`useState` setter, not something defined below a loading/error `return`. Reference: `SchedulesScreen`
(`usePrimaryAction('schedules', () => setForm('create'))`).
- **The route must ALSO declare a matching non-empty `primaryAction` label** in the `routes` table. The
TopBar renders the button **only when `route.primaryAction` is non-empty** — so declaring a label without
subscribing renders a dead button, and subscribing without a label renders nothing. Keep the two in lockstep.
The `#238` tests in `App.test.tsx` guard this: a data-driven `it.each` asserts each URL-navigating create
screen's banner actually navigates (so a typo'd route id → a button that navigates nowhere → red), plus a
drop test that an action screen shows no banner button. The dialog/editor create screens (schedules, multi/
rerun collections, trakt) are exercised by their own create-flow tests.
- **When to WIRE vs DROP (issue #238).** The Plus icon makes the button semantically a *"create new item"*
affordance. **Keep + wire it only on list screens with a single, unambiguous create flow** ("Add Channel",
"Add Schedule", "Add Multi-Collection", "Add Rerun Collection", "Add Trakt List", "Add Filler Preset", "Add
Profile", "Add Watermark"). **Drop it** (`primaryAction: ''`) where the primary action is **not** a create
(Save / Refresh / Play / Validate / Reset / Scan — the "+" is wrong, and those screens already carry the
correct in-body control), or where a single banner action would be **ambiguous** (Collections has two create
types behind tabs), **a silent no-op** (the Channel builder's Create is disabled until the form is valid),
or **semantically misplaced** (Dashboard is a status page; Libraries "Scan" is per-row). Coexisting with an
in-body create control is fine (Schedules has both) — the banner is a convenience, not the sole entry point.
See `docs/decisions.md` 2026-07-12 for the full rationale.
+31 -1
View File
@@ -31,11 +31,41 @@ describe('ChicoryTV SPA scaffold', () => {
expect(screen.getByRole('banner')).toBeInTheDocument();
expect(screen.getByRole('searchbox', { name: 'Search' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add Channel' })).toBeInTheDocument();
// The Dashboard declares no primaryAction, so the TopBar renders no primary-action button
// here (issue #238 — the old code rendered a dead "Add Channel" button on every screen).
expect(screen.queryByRole('button', { name: 'Add Channel' })).not.toBeInTheDocument();
expect(await screen.findAllByText('Healthy')).toHaveLength(2);
expect(designSystemStylesheet).toBe('../../design-system/styles.css');
});
// Data-driven over every WIRED create screen whose primary action navigates to a URL — this
// catches a typo'd route-id in usePrimaryAction on any of them (the button would render but do
// nothing). The button lives in the shell header (role="banner"), scoped so an in-body Add
// control can't satisfy the assertion. (#238)
it.each([
['/app/channels', 'Add Channel', '/app/new-channel'],
['/app/filler-presets', 'Add Filler Preset', '/app/filler-presets/add'],
['/app/ffmpeg-profiles', 'Add Profile', '/app/ffmpeg-profiles/add'],
['/app/watermarks', 'Add Watermark', '/app/watermarks/add']
])('wires the TopBar create button on %s → %s (#238)', async (path, label, target) => {
window.history.pushState(null, '', path);
render(<App />);
const banner = await screen.findByRole('banner');
fireEvent.click(within(banner).getByRole('button', { name: label }));
expect(window.location.pathname).toBe(target);
});
it('drops the TopBar primary-action button on action screens (#238)', async () => {
render(<App />);
// An "action" screen (Logs) declares no primaryAction → no TopBar button at all
// (its Refresh control lives in-body, where the "+" primary styling would be wrong).
fireEvent.click(screen.getByRole('link', { name: 'Logs' }));
expect(await screen.findByRole('heading', { name: 'Logs' })).toBeInTheDocument();
expect(within(screen.getByRole('banner')).queryByRole('button', { name: /Refresh/ })).toBeNull();
});
it('routes between shell screen slots without a page reload', async () => {
render(<App />);
+32 -31
View File
@@ -73,6 +73,7 @@ import { RemoteLibrariesEditScreen } from './screens/RemoteLibrariesEditScreen';
import { PathReplacementsEditScreen } from './screens/PathReplacementsEditScreen';
import { SchedulesScreen } from './screens/SchedulesScreen';
import { canLeaveCurrentScreen } from './navigationGuard';
import { dispatchPrimaryAction } from './primaryAction';
import {
ArtistDetailScreen,
MovieDetailScreen,
@@ -223,7 +224,7 @@ const routes: ScreenRoute[] = [
kicker: 'Overview',
description: 'On-air status, health checks, and recent server activity.',
icon: <LayoutDashboard aria-hidden="true" size={16} />,
primaryAction: 'Add Channel',
primaryAction: '',
placeholder: 'Dashboard workspace'
},
{
@@ -245,7 +246,7 @@ const routes: ScreenRoute[] = [
kicker: 'Builder',
description: 'Library-to-lineup channel creation flow with template-backed defaults.',
icon: <Plus aria-hidden="true" size={16} />,
primaryAction: 'Create Channel',
primaryAction: '',
placeholder: 'Channel builder workspace'
},
{
@@ -259,7 +260,7 @@ const routes: ScreenRoute[] = [
kicker: 'Channel',
description: 'Full channel editor: identity, playout, streaming, selection and branding.',
icon: <Tv aria-hidden="true" size={16} />,
primaryAction: 'Save Changes',
primaryAction: '',
placeholder: 'Channel editor workspace',
allowSubPaths: true
},
@@ -271,7 +272,7 @@ const routes: ScreenRoute[] = [
kicker: 'EPG',
description: 'Timeline grid with channel rows, programme blocks, and a live now marker.',
icon: <LayoutGrid aria-hidden="true" size={16} />,
primaryAction: 'Jump To Now',
primaryAction: '',
placeholder: 'EPG grid workspace'
},
{
@@ -347,7 +348,7 @@ const routes: ScreenRoute[] = [
kicker: 'Runtime',
description: 'Playout state, reset controls, timeline diagnostics, and build warnings.',
icon: <ListVideo aria-hidden="true" size={16} />,
primaryAction: 'Reset All',
primaryAction: '',
placeholder: 'Playouts workspace',
allowSubPaths: true
},
@@ -394,7 +395,7 @@ const routes: ScreenRoute[] = [
kicker: 'Media',
description: 'Manual, smart, multi, playlist, search, and rerun collection management.',
icon: <FolderTree aria-hidden="true" size={16} />,
primaryAction: 'Add Collection',
primaryAction: '',
placeholder: 'Collections workspace'
},
{
@@ -427,7 +428,7 @@ const routes: ScreenRoute[] = [
kicker: 'Media',
description: 'Group, order, and preview playlist items scheduled into channels.',
icon: <ListMusic aria-hidden="true" size={16} />,
primaryAction: 'Add Playlist',
primaryAction: '',
placeholder: 'Playlists workspace'
},
{
@@ -455,7 +456,7 @@ const routes: ScreenRoute[] = [
kicker: 'Sources',
description: 'Local, Plex, Jellyfin, and Emby media source monitoring.',
icon: <Library aria-hidden="true" size={16} />,
primaryAction: 'Scan',
primaryAction: '',
placeholder: 'Libraries workspace',
allowSubPaths: true
},
@@ -505,21 +506,22 @@ const routes: ScreenRoute[] = [
kicker: 'System',
description: 'Server configuration, access keys, FFmpeg profiles, and UI preferences.',
icon: <Settings aria-hidden="true" size={16} />,
primaryAction: 'Save Changes',
primaryAction: '',
placeholder: 'Settings workspace',
allowSubPaths: true
},
{
// Keyless by design: this screen reads/writes only localStorage (auth.ts) and never calls
// `/api`, so it works on a fresh install where every `/api` read 401s (#280 / #197).
// Since #295 the browser authenticates by session cookie; this screen only *displays* the
// server machine key (for MCP / external REST clients) and, for local accounts, changes the
// admin password — there is no key to "save" here (see spa-conventions §5e).
id: 'apiKey',
path: '/app/api-key',
label: 'API Key',
title: 'API Key',
kicker: 'System',
description: 'Store the API key this server requires on every request.',
description: 'View the machine key for MCP and external REST clients, and change the local admin password.',
icon: <KeyRound aria-hidden="true" size={16} />,
primaryAction: 'Save key',
primaryAction: '',
placeholder: 'API key workspace'
},
{
@@ -530,7 +532,7 @@ const routes: ScreenRoute[] = [
kicker: 'System',
description: 'Recent server log entries with level and free-text filtering.',
icon: <ScrollText aria-hidden="true" size={16} />,
primaryAction: 'Refresh',
primaryAction: '',
placeholder: 'Logs workspace'
},
{
@@ -541,7 +543,7 @@ const routes: ScreenRoute[] = [
kicker: 'System',
description: 'Version, environment, hardware and FFmpeg capability diagnostics.',
icon: <Stethoscope aria-hidden="true" size={16} />,
primaryAction: 'Refresh',
primaryAction: '',
placeholder: 'Troubleshooting workspace'
},
{
@@ -552,7 +554,7 @@ const routes: ScreenRoute[] = [
kicker: 'System',
description: 'Inspect a block playout, its blocks, and each block’s scheduling history.',
icon: <Boxes aria-hidden="true" size={16} />,
primaryAction: 'Refresh',
primaryAction: '',
placeholder: 'Block playout troubleshooting workspace'
},
{
@@ -563,7 +565,7 @@ const routes: ScreenRoute[] = [
kicker: 'System',
description: 'Preview channel or media-item playback with a chosen FFmpeg profile and inspect the transcode logs.',
icon: <Stethoscope aria-hidden="true" size={16} />,
primaryAction: 'Play',
primaryAction: '',
placeholder: 'Playback troubleshooting workspace'
},
{
@@ -574,7 +576,7 @@ const routes: ScreenRoute[] = [
kicker: 'System',
description: 'Validate a sequential-schedule YAML document against the schema.',
icon: <ListChecks aria-hidden="true" size={16} />,
primaryAction: 'Validate',
primaryAction: '',
placeholder: 'Sequential schedule validator workspace'
}
];
@@ -867,7 +869,10 @@ function ConnectMenu() {
function TopBar({ route }: { route: ScreenRoute | null }) {
const title = route?.title ?? 'Page not found';
const kicker = route?.kicker ?? 'Unknown route';
const primaryAction = route?.primaryAction ?? 'Go To Dashboard';
// Screens opt into a TopBar primary-action button by declaring a non-empty `primaryAction`
// AND subscribing via usePrimaryAction(route.id, …). A screen that declares no action gets
// no button (issue #238 — the old code rendered a dead button for every unwired route).
const primaryAction = route?.primaryAction ?? '';
const [searchValue, setSearchValue] = useState('');
return (
@@ -913,18 +918,14 @@ function TopBar({ route }: { route: ScreenRoute | null }) {
<span className="ctv-avatar">TB</span>
</div>
<Button
startIcon={<Plus aria-hidden="true" size={15} />}
onClick={() => {
// Screens opt in to the TopBar primary action by listening for this event keyed on their
// route id (e.g. SchedulesScreen opens its create dialog on 'schedules').
if (route) {
window.dispatchEvent(new CustomEvent('ctv:primary-action', { detail: route.id }));
}
}}
>
{primaryAction}
</Button>
{route && primaryAction ? (
<Button
startIcon={<Plus aria-hidden="true" size={15} />}
onClick={() => dispatchPrimaryAction(route.id)}
>
{primaryAction}
</Button>
) : null}
</header>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useRef } from 'react';
// The TopBar renders one "primary action" button per screen (top-right). Because the
// TopBar and the screens are decoupled (the TopBar has no reference to the active
// screen component), the click is delivered as a window CustomEvent keyed on the
// active route id; a screen opts in with `usePrimaryAction(routeId, handler)`.
//
// A screen that does NOT call usePrimaryAction gets NO button — App.tsx's route table
// declares an empty `primaryAction` for such screens and the TopBar suppresses the
// button (see issue #238: the old code rendered a dead button for every unwired route).
export const PRIMARY_ACTION_EVENT = 'ctv:primary-action';
export function dispatchPrimaryAction(routeId: string): void {
window.dispatchEvent(new CustomEvent(PRIMARY_ACTION_EVENT, { detail: routeId }));
}
/**
* Subscribe a screen to its TopBar primary-action button. The handler runs whenever the
* TopBar dispatches `ctv:primary-action` with a detail matching `routeId`. The latest
* handler is held in a ref so re-renders don't churn the window listener.
*/
export function usePrimaryAction(routeId: string, handler: () => void): void {
const handlerRef = useRef(handler);
// Keep the ref pointing at the latest handler without re-subscribing the window listener
// on every render. Updating a ref during render trips react-hooks/refs, so do it in an effect.
useEffect(() => {
handlerRef.current = handler;
});
useEffect(() => {
const listener = (event: Event) => {
if ((event as CustomEvent<string>).detail === routeId) {
handlerRef.current();
}
};
window.addEventListener(PRIMARY_ACTION_EVENT, listener);
return () => window.removeEventListener(PRIMARY_ACTION_EVENT, listener);
}, [routeId]);
}
+4
View File
@@ -25,6 +25,7 @@ import {
type CreateChannelRequest
} from '../api';
import { navigateToPath } from '../routing';
import { usePrimaryAction } from '../primaryAction';
type ChannelViewFilter = 'all' | 'onair' | 'disabled';
@@ -177,6 +178,9 @@ export function ChannelsScreen() {
const [mutating, setMutating] = useState(false);
const [creatingBlank, setCreatingBlank] = useState(false);
// TopBar "Add Channel" primary action → the library-to-lineup builder.
usePrimaryAction('channels', () => navigateToPath('/app/new-channel'));
if (query.status === 'loading') {
return <ChannelsLoadingState />;
}
+4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowLeft, Check, Copy, Plus, SlidersHorizontal, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
import { usePrimaryAction } from '../primaryAction';
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
import {
createFFmpegProfile,
@@ -342,6 +343,9 @@ export function FFmpegProfilesScreen() {
return () => window.removeEventListener('popstate', onPop);
}, []);
// TopBar "Add Profile" primary action → the add editor route.
usePrimaryAction('ffmpegProfiles', () => navigateToPath(`${BASE_PATH}/add`));
const mode = modeFromPath(window.location.pathname);
if (mode.kind === 'list') {
+4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowLeft, Check, Film, Plus, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
import { usePrimaryAction } from '../primaryAction';
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
import {
createFillerPreset,
@@ -285,6 +286,9 @@ export function FillerPresetsScreen() {
return () => window.removeEventListener('popstate', onPop);
}, []);
// TopBar "Add Filler Preset" primary action → the add editor route.
usePrimaryAction('fillerPresets', () => navigateToPath(`${BASE_PATH}/add`));
const mode = modeFromPath(pathname);
if (mode.kind === 'list') {
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, Check, Layers, Plus, Trash2, TriangleAlert } from 'lucide-react';
import { Badge, Button, Card, ConfirmDialog, IconButton, Input, Select, Spinner, Switch } from '../components';
import { usePrimaryAction } from '../primaryAction';
import {
ApiError,
createMultiCollection,
@@ -368,6 +369,9 @@ export function MultiCollectionsScreen() {
const [deleteBusy, setDeleteBusy] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
// TopBar "Add Multi-Collection" primary action.
usePrimaryAction('multiCollections', () => setEditing({ kind: 'new' }));
if (editing) {
return (
<MultiCollectionEditor
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, Check, Plus, Repeat, Trash2, TriangleAlert } from 'lucide-react';
import { Badge, Button, Card, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
import type { SelectOption } from '../components';
import { usePrimaryAction } from '../primaryAction';
import {
ApiError,
createRerunCollection,
@@ -448,6 +449,9 @@ export function RerunCollectionsScreen() {
const [deleteBusy, setDeleteBusy] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
// TopBar "Add Rerun Collection" primary action.
usePrimaryAction('rerunCollections', () => setEditing({ kind: 'new' }));
if (editing) {
return (
<RerunCollectionEditor
+2 -9
View File
@@ -25,6 +25,7 @@ import {
replaceScheduleItems
} from '../api';
import { registerNavigationGuard } from '../navigationGuard';
import { usePrimaryAction } from '../primaryAction';
import {
copyDraftItem,
type DraftItem,
@@ -188,15 +189,7 @@ export function SchedulesScreen() {
}, [dirty]);
// ---- TopBar "Add Schedule" primary action ------------------------------
useEffect(() => {
const handler = (event: Event) => {
if ((event as CustomEvent<string>).detail === 'schedules') {
setForm('create');
}
};
window.addEventListener('ctv:primary-action', handler);
return () => window.removeEventListener('ctv:primary-action', handler);
}, []);
usePrimaryAction('schedules', () => setForm('create'));
if (boot.status === 'loading') {
return (
+13
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
import { usePrimaryAction } from '../primaryAction';
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Spinner, Switch } from '../components';
import {
addTraktList,
@@ -276,6 +277,18 @@ export function TraktListsScreen() {
const [rowError, setRowError] = useState<string | null>(null);
const activeRef = useRef(true);
// TopBar "Add Trakt List" primary action. On the list, open the add dialog. On the
// detail sub-route (/app/trakt-lists/{id}) the add dialog isn't mounted and this component
// is keyed by pathname (so state wouldn't survive a navigate), so route back to the list
// instead of flipping a state nobody renders — avoids a silent no-op button (#238 review).
usePrimaryAction('traktLists', () => {
if (editingId != null) {
navigateToPath(TRAKT_BASE_PATH);
} else {
setAddOpen(true);
}
});
// Fetch only; state updates happen in the async callbacks (never synchronously in the
// effect body). `loading` starts true and flips false in `finally`, so the mount effect
// shows the spinner; later quiet reloads (e.g. after a busy -> idle transition) never
+4
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
import { ArrowLeft, Check, Copy, Image as ImageIcon, Plus, Trash2, TriangleAlert, Upload } from 'lucide-react';
import { navigateToPath } from '../routing';
import { usePrimaryAction } from '../primaryAction';
import { Badge, Button, Card, Checkbox, ConfirmDialog, IconButton, Input, Select, Spinner } from '../components';
import {
createWatermark,
@@ -188,6 +189,9 @@ export function WatermarksScreen() {
return () => window.removeEventListener('popstate', onPop);
}, []);
// TopBar "Add Watermark" primary action → the add editor route.
usePrimaryAction('watermarks', () => navigateToPath(`${BASE_PATH}/add`));
const mode = modeFromPath(pathname);
if (mode.kind === 'list') {