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:
+76
-1
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user