diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e9d6289d4..c6a89fa49 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -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(); + + 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(); + + 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' })] }); diff --git a/web/src/App.tsx b/web/src/App.tsx index f8f5f7fb4..9fce3a50f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( - No channels} subtitle="The API returned an empty channel lineup."> -
No channels returned
+ No channels} subtitle="No channels yet — create your first one."> +
+

No channels yet

+
+ + +
+
); } @@ -1458,7 +1489,7 @@ function ChannelsScreen() { }; if (channels.length === 0) { - return ; + return void createBlankChannel()} />; } return ( @@ -3442,12 +3473,30 @@ export function App() { const [activeRoute, setActiveRoute] = useState(() => 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(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); }; diff --git a/web/src/shell.css b/web/src/shell.css index 3dc15264c..2aa669fc0 100644 --- a/web/src/shell.css +++ b/web/src/shell.css @@ -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;