+16
-5
@@ -211,11 +211,22 @@ draft to navigation. The shared module `web/src/navigationGuard.ts` is the seam:
|
||||
window.confirm(...))`, returning the unregister fn from the mount `useEffect` for cleanup. Only one
|
||||
guard is active at a time (the mounted screen); a stale unregister only clears its own guard.
|
||||
- `App.tsx`'s `navigate` handler calls `canLeaveCurrentScreen()` before `pushState` — a `false`
|
||||
return aborts the in-app sidebar/nav click. This covers React-router-less in-app navigation only.
|
||||
- The screen **also** guards the three paths the module can't see — a **schedule switch** and any
|
||||
in-screen action that would replace the draft use their own `window.confirm(...)`, and a
|
||||
`beforeunload` listener (installed while `dirty`) covers full-page unloads (reload / close tab /
|
||||
external link).
|
||||
return aborts the in-app sidebar/nav click.
|
||||
- **Browser Back/Forward (`popstate`) is also covered.** `App.tsx`'s `popstate` handler consults
|
||||
`canLeaveCurrentScreen()` too. A `popstate` cannot be cancelled — by the time it fires the URL has
|
||||
already changed — so on a veto the handler **re-pushes the pre-pop path** (tracked in a
|
||||
`currentPathRef` updated on every approved navigation) via `history.pushState` and leaves
|
||||
`activeRoute` untouched, effectively undoing the browser's URL change. This same handler also runs
|
||||
for the **synthetic** pop `navigateToPath()` dispatches, so programmatic in-app navigation is
|
||||
guarded as well. The re-push does **not** re-fire `popstate`; that's safe because only one screen
|
||||
is mounted at a time and the guard-registering screen (the schedules editor) owns no internal
|
||||
`popstate`/sub-path listener — so a blocked pop cannot desync a sub-path screen's own pathname
|
||||
state (§2). A sub-path screen that both registers a dirty guard *and* tracks its own pathname would
|
||||
need to re-validate this; none does today.
|
||||
- The screen **also** guards the paths the module still can't see — an in-screen action that would
|
||||
replace the draft (schedule switch, opening the properties editor) uses its own
|
||||
`window.confirm(...)`, and a `beforeunload` listener (installed while `dirty`) covers full-page
|
||||
unloads (reload / close tab / external link).
|
||||
|
||||
Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state value, so
|
||||
`canLeaveCurrentScreen()` sees the current dirtiness synchronously at click time.
|
||||
|
||||
+99
-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,64 @@ 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('surfaces a bare-create failure on an empty lineup (#242 review follow-up)', async () => {
|
||||
// The fresh-install path #212 exists for: a new install may have no default ffmpeg profile, so
|
||||
// bare-create can 4xx. The empty state must render that error, not swallow it behind a spinner
|
||||
// that just re-enables. (Regression for the empty branch dropping mutationError.)
|
||||
mockDashboardApi({
|
||||
channels: [],
|
||||
createChannelFailure: { detail: 'No FFmpeg profile is configured', status: 422, title: 'Validation failed' },
|
||||
ffmpegSettings: defaultFfmpegSettings({ defaultFFmpegProfileId: 0 })
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
|
||||
expect(await screen.findByText('No channels yet')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New blank channel' }));
|
||||
|
||||
expect(await screen.findByText('No FFmpeg profile is configured')).toBeInTheDocument();
|
||||
// Still on the empty lineup — no navigation, and the create affordances remain reachable.
|
||||
expect(window.location.pathname).toBe('/app/channels');
|
||||
expect(screen.getByRole('button', { name: 'New blank channel' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the selection when the channel view filter changes', async () => {
|
||||
mockDashboardApi({
|
||||
channels: [
|
||||
@@ -924,6 +982,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' })] });
|
||||
|
||||
|
||||
+73
-5
@@ -1255,10 +1255,53 @@ 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.
|
||||
// mutationError is rendered here too: a fresh install may have no default ffmpeg profile, so
|
||||
// bare-create can 4xx — the non-empty screen shows that alert, and this branch must not swallow it
|
||||
// (a silent spinner on the one screen that exists to bootstrap the first channel is the worst place
|
||||
// to hide the failure).
|
||||
function ChannelsEmptyState({
|
||||
creatingBlank,
|
||||
mutationError,
|
||||
onCreateBlank
|
||||
}: {
|
||||
creatingBlank: boolean;
|
||||
mutationError: string | null;
|
||||
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>
|
||||
{mutationError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{mutationError}</span>
|
||||
</div>
|
||||
)}
|
||||
<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 +1501,13 @@ function ChannelsScreen() {
|
||||
};
|
||||
|
||||
if (channels.length === 0) {
|
||||
return <ChannelsEmptyState />;
|
||||
return (
|
||||
<ChannelsEmptyState
|
||||
creatingBlank={creatingBlank}
|
||||
mutationError={mutationError}
|
||||
onCreateBlank={() => void createBlankChannel()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -3442,12 +3491,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 +3541,7 @@ export function App() {
|
||||
}
|
||||
|
||||
window.history.pushState(null, '', routeHref(nextRoute));
|
||||
currentPathRef.current = window.location.pathname;
|
||||
setActiveRoute(nextRoute);
|
||||
};
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ function responseItem(overrides: Record<string, unknown> = {}) {
|
||||
interface MockOptions {
|
||||
schedules?: unknown[];
|
||||
items?: unknown[];
|
||||
onRequest?: (url: string, method: string, body: unknown) => Response | null;
|
||||
onRequest?: (url: string, method: string, body: unknown) => Response | Promise<Response> | null;
|
||||
}
|
||||
|
||||
interface MockHandle {
|
||||
@@ -418,3 +418,94 @@ describe('SchedulesScreen — conditional rendering', () => {
|
||||
expect(values).toEqual(['Dynamic']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesScreen — shuffle toggle re-normalizes the draft (#230 finding 2)', () => {
|
||||
it('reloads the item draft when a properties save flips shuffleScheduleItems', async () => {
|
||||
let shuffled = false;
|
||||
const handle = mockApi({
|
||||
items: [responseItem({ startType: 'Fixed', startTime: '20:00:00' })],
|
||||
onRequest: (url, method, body) => {
|
||||
if (url === '/api/schedules/1' && method === 'PUT') {
|
||||
shuffled = (body as { shuffleScheduleItems: boolean }).shuffleScheduleItems;
|
||||
return jsonResponse({ ...schedule, shuffleScheduleItems: shuffled });
|
||||
}
|
||||
// Server EnforceProperties strips Fixed → Dynamic on GET once the schedule shuffles.
|
||||
if (url === '/api/schedules/1/items' && method === 'GET' && shuffled) {
|
||||
return jsonResponse({ items: [responseItem({ startType: 'Dynamic', startTime: null })], totalDurationEstimate: '00:30:00' });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
render(<SchedulesScreen />);
|
||||
await waitFor(() => expect(screen.getByLabelText('Schedule lineup').querySelectorAll('[role="listitem"]').length).toBeGreaterThan(0));
|
||||
|
||||
// Precondition: the inspector offers Fixed on the non-shuffled schedule.
|
||||
const before = screen.getByLabelText('Start type') as HTMLSelectElement;
|
||||
expect(Array.from(before.options).map((o) => o.value)).toContain('Fixed');
|
||||
|
||||
const getsBefore = handle.requests.filter((r) => r.url === '/api/schedules/1/items' && r.method === 'GET').length;
|
||||
|
||||
// Edit properties → enable shuffle → Save.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Edit$/ }));
|
||||
const dialog = screen.getByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByLabelText('Shuffle schedule items'));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
// A fresh items GET re-normalizes the draft after the shuffle flip.
|
||||
await waitFor(() =>
|
||||
expect(handle.requests.filter((r) => r.url === '/api/schedules/1/items' && r.method === 'GET').length).toBe(getsBefore + 1)
|
||||
);
|
||||
|
||||
// The Fixed option is gone and the reloaded draft carries the normalized Dynamic value.
|
||||
await waitFor(() => {
|
||||
const after = screen.getByLabelText('Start type') as HTMLSelectElement;
|
||||
expect(Array.from(after.options).map((o) => o.value)).toEqual(['Dynamic']);
|
||||
});
|
||||
|
||||
// A subsequent Save can no longer carry Fixed (the reloaded draft was normalized server-side).
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add item/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/schedules/1/items' && r.method === 'PUT')).toBe(true));
|
||||
expect(lastPut(handle).every((it) => it.startType !== 'Fixed')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesScreen — Edit is blocked while an items save is in flight (#242 review follow-up)', () => {
|
||||
it('disables Edit during a save PUT so an in-flight items save cannot clobber a shuffle reload', async () => {
|
||||
// During save() the draft is still dirty until the PUT resolves. Without gating Edit on `saving`, a
|
||||
// user could discard-to-open the properties editor, flip shuffle (→ reload), and have the original
|
||||
// items PUT resolve AFTER the reload — overwriting the normalized draft with the pre-shuffle body.
|
||||
let resolvePut: (r: Response) => void = () => {};
|
||||
const putPending = new Promise<Response>((resolve) => {
|
||||
resolvePut = resolve;
|
||||
});
|
||||
const handle = mockApi({
|
||||
onRequest: (url, method) => {
|
||||
if (url === '/api/schedules/1/items' && method === 'PUT') {
|
||||
// Hold the PUT in flight; resolve only after we've asserted Edit/Delete are disabled.
|
||||
return putPending;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
render(<SchedulesScreen />);
|
||||
await waitFor(() => expect(screen.getByLabelText('Schedule lineup').querySelectorAll('[role="listitem"]').length).toBeGreaterThan(0));
|
||||
|
||||
// Make the draft dirty and start a save that stays in flight.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add item/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/schedules/1/items' && r.method === 'PUT')).toBe(true));
|
||||
|
||||
// Edit AND Delete are disabled while saving → the discard-to-open race (Edit) and the
|
||||
// switch-during-save race (Delete → applySwitch → stale PUT clobbers the next schedule) are both
|
||||
// unreachable. Delete is the only schedule-switch path guardedSwitch's `saving` guard missed.
|
||||
expect((screen.getByRole('button', { name: /^Edit$/ }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByRole('button', { name: /^Delete$/ }) as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
// Resolve the PUT; both re-enable once the save settles over the fresh baseline.
|
||||
const submitted = handle.requests.filter((r) => r.url === '/api/schedules/1/items' && r.method === 'PUT').pop();
|
||||
resolvePut(jsonResponse(((submitted?.body as { items: Record<string, unknown>[] }).items).map((it, i) => responseItem({ ...it, id: 500 + i, index: i, name: `Item ${i}` }))));
|
||||
await waitFor(() => expect((screen.getByRole('button', { name: /^Edit$/ }) as HTMLButtonElement).disabled).toBe(false));
|
||||
expect((screen.getByRole('button', { name: /^Delete$/ }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -342,8 +342,40 @@ export function SchedulesScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
// Opening the properties editor is blocked while the item draft is dirty (confirm-to-discard, same
|
||||
// semantics as guardedSwitch). This is the smaller, fully-consistent fix for the shuffle-toggle
|
||||
// normalization gap (#230 finding 2): if editing shuffleScheduleItems could change under a dirty
|
||||
// draft, a post-save reload would either lose the draft or (on a cancelled discard) leave a
|
||||
// now-shuffled schedule holding Fixed/Flood values the schedule no longer supports. Guaranteeing
|
||||
// the form only ever opens over a clean (baseline) draft makes the post-save reload lossless.
|
||||
// The Edit button is ALSO gated on `saving` (like the schedule Select): during an items-save PUT
|
||||
// the draft is still dirty, so without the gate a user could discard-to-open the editor, flip
|
||||
// shuffle, and have the in-flight items PUT resolve AFTER the shuffle reload — clobbering the
|
||||
// freshly normalized draft with the pre-shuffle response. Blocking Edit while saving closes that
|
||||
// race (Codex review, PR #242). Delete is gated on `saving` for the same class of race: it is the
|
||||
// only schedule-switch path (via applySwitch) that guardedSwitch's `saving` guard didn't already
|
||||
// cover, so deleting mid-save could switch schedules and let the stale items PUT clobber the next
|
||||
// schedule's draft (Codex re-review, PR #242).
|
||||
const openEditForm = () => {
|
||||
if (dirtyRef.current && !window.confirm(DIRTY_PROMPT)) {
|
||||
return;
|
||||
}
|
||||
if (dirtyRef.current) {
|
||||
const restored = baselineRef.current;
|
||||
setItems(restored);
|
||||
setSelectedKey(restored[0]?._key ?? null);
|
||||
setDirtyState(false);
|
||||
setMutationError(null);
|
||||
}
|
||||
setForm('edit');
|
||||
};
|
||||
|
||||
const onScheduleSaved = (saved: ProgramSchedule) => {
|
||||
setForm(null);
|
||||
// Did the ACTIVE schedule's shuffle flag flip? Capture BEFORE setBoot rewrites the list.
|
||||
const editedActive = saved.id === activeId;
|
||||
const shuffleChanged =
|
||||
editedActive && (activeSchedule?.shuffleScheduleItems ?? false) !== (saved.shuffleScheduleItems ?? false);
|
||||
setBoot((current) => {
|
||||
if (current.status !== 'ready') {
|
||||
return current;
|
||||
@@ -358,6 +390,15 @@ export function SchedulesScreen() {
|
||||
// New schedule: switch focus to it, but through the same dirty guard as the schedule picker so
|
||||
// an unsaved draft on the current schedule isn't silently wiped without a confirm (F5).
|
||||
guardedSwitch(saved.id);
|
||||
} else if (shuffleChanged && activeId != null) {
|
||||
// Toggling shuffleScheduleItems re-normalizes the lineup server-side (EnforceProperties strips
|
||||
// hidden Fixed/Flood start values on GET). The open draft was built from the pre-toggle GET, so
|
||||
// reload it — otherwise those hidden values could be saved back and the inspector would keep
|
||||
// offering start-type controls the schedule no longer supports (#230 finding 2). The draft is
|
||||
// guaranteed clean here (openEditForm blocks opening the editor while dirty), so this is lossless.
|
||||
setItemsLoaded(false);
|
||||
setItemsLoading(true);
|
||||
loadItems(activeId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -416,8 +457,8 @@ export function SchedulesScreen() {
|
||||
<Button size="sm" variant="ghost" startIcon={<Plus aria-hidden="true" size={14} />} onClick={() => setForm('create')}>Add schedule</Button>
|
||||
{activeSchedule && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" startIcon={<Pencil aria-hidden="true" size={14} />} onClick={() => setForm('edit')}>Edit</Button>
|
||||
<Button size="sm" variant="ghost" startIcon={<Trash2 aria-hidden="true" size={14} />} onClick={() => setConfirmDelete(true)}>Delete</Button>
|
||||
<Button size="sm" variant="ghost" disabled={saving} startIcon={<Pencil aria-hidden="true" size={14} />} onClick={openEditForm}>Edit</Button>
|
||||
<Button size="sm" variant="ghost" disabled={saving} startIcon={<Trash2 aria-hidden="true" size={14} />} onClick={() => setConfirmDelete(true)}>Delete</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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