fix(spa): bare-channel create reachable on an empty lineup (#212 reopened)

ChannelsScreen returned ChannelsEmptyState before the action bar that owns
"New blank channel", so a fresh install could never create its first channel.
The empty state now offers both create paths (bare-create + ChannelBuilder),
reusing the exact createBlankChannel handler (number = max+1 → 1 on empty,
group "ErsatzTV", default ffmpeg profile).

Regression: App.test.tsx bare-creates from a [] lineup, asserts the POST
payload (number "1") + navigation to /app/edit-channel/{id}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:38:10 +02:00
co-authored by Claude Fable 5
parent 554f658989
commit d3d50f60c4
3 changed files with 146 additions and 6 deletions
+76 -1
View File
@@ -42,7 +42,7 @@ describe('ChicoryTV SPA scaffold', () => {
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(screen.getByRole('heading', { name: 'Channels' })).toBeInTheDocument();
expect(await screen.findByText('No channels returned')).toBeInTheDocument();
expect(await screen.findByText('No channels yet')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Channels' })).toHaveAttribute(
'aria-current',
'page'
@@ -849,6 +849,41 @@ describe('ChicoryTV SPA scaffold', () => {
expect(window.location.pathname).toBe('/app/channels');
});
it('bare-creates the first channel from an empty lineup (#212 regression)', async () => {
// Fresh install: GET /api/channels returns []. The empty state must still offer bare-create — the
// old early return rendered a dead-end card before the action bar, so no channel could be created.
mockDashboardApi({
channels: [],
createChannelResponse: { id: 7 },
ffmpegSettings: defaultFfmpegSettings({ defaultFFmpegProfileId: 3 })
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
expect(await screen.findByText('No channels yet')).toBeInTheDocument();
// Both create affordances are reachable on an empty lineup (the empty-state "Add Channel" is in
// addition to the always-present TopBar one, so there are two).
expect(screen.getAllByRole('button', { name: 'Add Channel' }).length).toBeGreaterThanOrEqual(2);
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
await waitFor(() =>
expect(window.fetch).toHaveBeenCalledWith('/api/channels', expect.objectContaining({ method: 'POST' }))
);
const body = requestBodyFor('/api/channels');
expect(body).toMatchObject({
ffmpegProfileId: 3,
group: 'ErsatzTV',
name: 'New Channel',
// max(number)+1 degenerates to 1 on an empty lineup.
number: '1'
});
await waitFor(() => expect(window.location.pathname).toBe('/app/edit-channel/7'));
});
it('clears the selection when the channel view filter changes', async () => {
mockDashboardApi({
channels: [
@@ -924,6 +959,46 @@ describe('ChicoryTV SPA scaffold', () => {
expect(window.fetch).toHaveBeenCalledWith('/api/languages', expect.any(Object));
});
it('honors the unsaved-changes guard on browser Back/popstate (#230 finding 1)', async () => {
mockDashboardApi({
scheduleItems: [scheduleItem({ id: 11, name: 'Saturday Cartoons', collectionName: 'Saturday Cartoons' })],
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
await screen.findByRole('list', { name: 'Schedule lineup' });
// Dirty the item draft so the registered navigation guard is armed.
fireEvent.click(screen.getByRole('button', { name: 'Add item' }));
expect(await screen.findByText(/unsaved changes/)).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/schedules');
// Simulate browser Back: popstate fires AFTER the URL has already moved. The guard cannot cancel
// it, so on a vetoed confirm the App handler must re-push the pre-pop path and keep the route.
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
window.history.pushState(null, '', '/app/guide');
window.dispatchEvent(new PopStateEvent('popstate'));
expect(confirmSpy).toHaveBeenCalled();
expect(screen.getByRole('heading', { name: 'Prime Time Cartoons' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/schedules');
// Accept the discard on the next Back → route changes and the schedules screen (with its draft)
// unmounts, matching the sidebar-nav case.
confirmSpy.mockReturnValue(true);
window.history.pushState(null, '', '/app/guide');
window.dispatchEvent(new PopStateEvent('popstate'));
expect(await screen.findByRole('heading', { name: 'Guide' })).toBeInTheDocument();
expect(window.location.pathname).toBe('/app/guide');
expect(screen.queryByRole('heading', { name: 'Prime Time Cartoons' })).not.toBeInTheDocument();
confirmSpy.mockRestore();
});
it('opens the create-schedule dialog from the TopBar Add Schedule button', async () => {
mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] });
+55 -5
View File
@@ -1255,10 +1255,41 @@ function ChannelsErrorState({ error, refresh }: { error: string; refresh: () =>
);
}
function ChannelsEmptyState() {
// Empty lineup (fresh install) must still let the user create their first channel — both the
// library-to-lineup ChannelBuilder flow ("Add Channel") and the bare-create flow ("New blank
// channel"). The bare-create handler is passed in from ChannelsScreen so the defaults (number =
// max+1, which degenerates to 1 on an empty lineup; group "ErsatzTV"; default ffmpeg profile) are
// identical to the action bar's. Regression for #212 (reopened): the old early return rendered a
// dead-end card before the action bar, so a fresh install could never create a channel.
function ChannelsEmptyState({
creatingBlank,
onCreateBlank
}: {
creatingBlank: boolean;
onCreateBlank: () => void;
}) {
return (
<Card title={<h2>No channels</h2>} subtitle="The API returned an empty channel lineup.">
<div className="ctv-dashboard-empty">No channels returned</div>
<Card title={<h2>No channels</h2>} subtitle="No channels yet — create your first one.">
<div className="ctv-channels-empty">
<p className="ctv-dashboard-empty">No channels yet</p>
<div className="ctv-channels-empty-actions">
<Button
disabled={creatingBlank}
loading={creatingBlank}
onClick={onCreateBlank}
startIcon={<Plus aria-hidden="true" size={14} />}
variant="secondary"
>
New blank channel
</Button>
<Button
startIcon={<Plus aria-hidden="true" size={14} />}
onClick={() => navigateToPath('/app/new-channel')}
>
Add Channel
</Button>
</div>
</div>
</Card>
);
}
@@ -1458,7 +1489,7 @@ function ChannelsScreen() {
};
if (channels.length === 0) {
return <ChannelsEmptyState />;
return <ChannelsEmptyState creatingBlank={creatingBlank} onCreateBlank={() => void createBlankChannel()} />;
}
return (
@@ -3442,12 +3473,30 @@ export function App() {
const [activeRoute, setActiveRoute] = useState<ScreenRoute | null>(() => routeFromLocation());
const healthState = useDashboardHealthQuery();
// The last path the guard has approved us being on. Browser Back/Forward (popstate) can't be
// cancelled, so when the dirty guard vetoes a pop we re-push THIS path to undo the browser's URL
// change. Kept in a ref (not state) so the popstate handler reads it synchronously at event time.
const currentPathRef = useRef<string>(window.location.pathname);
useEffect(() => {
applyDesignSystemTheme(theme);
}, [theme]);
useEffect(() => {
const onPopState = () => setActiveRoute(routeFromLocation());
// popstate covers both real Back/Forward and the synthetic pop navigateToPath() dispatches. If a
// screen has registered a dirty guard and it vetoes (user cancelled the confirm), we cannot stop
// the navigation — the URL has already moved — so we restore it by re-pushing the pre-pop path
// and leave activeRoute untouched. Re-pushing does NOT fire popstate, but that's fine: only one
// screen is mounted at a time and the guard-registering screen (schedules) owns no internal
// popstate listener, so no sub-path screen's pathname state can desync (see spa-conventions §8).
const onPopState = () => {
if (!canLeaveCurrentScreen()) {
window.history.pushState(null, '', currentPathRef.current);
return;
}
currentPathRef.current = window.location.pathname;
setActiveRoute(routeFromLocation());
};
window.addEventListener('popstate', onPopState);
@@ -3474,6 +3523,7 @@ export function App() {
}
window.history.pushState(null, '', routeHref(nextRoute));
currentPathRef.current = window.location.pathname;
setActiveRoute(nextRoute);
};
+15
View File
@@ -1275,6 +1275,21 @@ body {
font-size: var(--text-sm, 13px);
}
.ctv-channels-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 16px 0;
}
.ctv-channels-empty-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.ctv-schedule-inspector {
display: flex;
flex-direction: column;