fix(web): schedule editor review fixes for #86
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

- Add a schedule selector (previously only the first schedule was ever
  shown); switching loads items only, with a pane-level spinner
- Fix duplicated mount effect that fetched the whole screen twice
- Add a collection picker to the add-item flow (was silently using the
  alphabetically-first collection with no way to correct it)
- Reorder now reconciles from the PUT response; add/delete refetch items
  only — pickers are no longer refetched on every mutation
- Guard reorder against double-submit via a ref; remove the inert Save
  button; document the widened item type (OpenAPI gap → #126)
- Inspector fidelity: Tail filler row + Keep multi-part together
- a11y: aria-current for the active item, draggable role description
- CSS: correct --text-md fallbacks; replace nonexistent --text-faint
  with --text-disabled in new blocks
- Tests: single-fetch-on-mount assertion, chosen-collection POST body,
  picker-failure path, collection() fixture with all required fields;
  drop unreachable mock branch (52 tests)

Review: PR #125 findings; refs #126

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-05 12:16:53 +02:00
co-authored by Claude Fable 5
parent 2e58fa1815
commit f18f2dc058
4 changed files with 231 additions and 57 deletions
+74 -17
View File
@@ -677,13 +677,55 @@ describe('ChicoryTV SPA scaffold', () => {
expect(screen.getAllByText('Station IDs').length).toBeGreaterThan(0);
expect(screen.getByText('unknown')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'Content' })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByDisplayValue('Prime Time Cartoons')).toBeInTheDocument();
expect(screen.getAllByDisplayValue('Prime Time Cartoons').length).toBeGreaterThan(0);
expect(window.fetch).toHaveBeenCalledWith('/api/schedules', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.any(Object));
expect(window.fetch).toHaveBeenCalledWith('/api/collections', expect.any(Object));
expect(window.fetch).not.toHaveBeenCalledWith('/api/languages', expect.any(Object));
});
it('fetches the schedules screen data exactly once on mount', async () => {
mockDashboardApi({ 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();
expect(fetchCount('/api/schedules')).toBe(1);
});
it('shows the Schedule editor error state when a picker endpoint fails', async () => {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const path = input.toString();
if (path === '/api/schedules') {
return Promise.resolve(jsonResponse([schedule({ id: 5, name: 'Prime Time Cartoons' })]));
}
if (path === '/api/schedules/5/items') {
return Promise.resolve(jsonResponse({ items: [], totalDurationEstimate: null }));
}
if (path === '/api/collections') {
return Promise.resolve(jsonResponse({
detail: 'Collections service is unavailable',
status: 500,
title: 'Internal error'
}, 500));
}
return Promise.resolve(jsonResponse([]));
});
render(<App />);
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByText('Collections service is unavailable')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
it('shows the Schedule editor loading state', async () => {
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const path = input.toString();
@@ -767,6 +809,8 @@ describe('ChicoryTV SPA scaffold', () => {
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findAllByText('Saturday Cartoons')).not.toHaveLength(0);
const collectionsFetchesBeforeReorder = fetchCount('/api/collections');
fireEvent.click(screen.getByRole('button', { name: 'Move Station IDs up' }));
await waitFor(() => {
@@ -779,6 +823,7 @@ describe('ChicoryTV SPA scaffold', () => {
const rows = within(lineup).getAllByRole('listitem');
expect(rows[0]).toHaveTextContent('Station IDs');
expect(rows[1]).toHaveTextContent('Saturday Cartoons');
expect(fetchCount('/api/collections')).toBe(collectionsFetchesBeforeReorder);
});
it('rolls back schedule reorder and shows ProblemDetails on replace failure', async () => {
@@ -809,11 +854,14 @@ describe('ChicoryTV SPA scaffold', () => {
expect(within(lineup).getAllByRole('listitem')[0]).toHaveTextContent('Saturday Cartoons');
});
it('adds schedule items and refetches after success', async () => {
const added = scheduleItem({ collection: { id: 3, name: 'Movie Mix' }, id: 20, name: 'Movie Mix' });
it('adds schedule items using the selected (non-first) collection', async () => {
const added = scheduleItem({ collection: { id: 4, name: 'Nature Docs' }, id: 20, name: 'Nature Docs' });
mockDashboardApi({
addScheduleItemResponse: added,
collections: [{ id: 3, name: 'Movie Mix' }],
collections: [
collection({ id: 3, name: 'Movie Mix' }),
collection({ id: 4, name: 'Nature Docs' })
],
scheduleItems: [],
scheduleItemsAfterAdd: [added],
schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })]
@@ -824,19 +872,28 @@ describe('ChicoryTV SPA scaffold', () => {
fireEvent.click(screen.getByRole('link', { name: 'Schedules' }));
expect(await screen.findByText('No schedule items')).toBeInTheDocument();
const collectionsFetchesBeforeAdd = fetchCount('/api/collections');
fireEvent.change(screen.getByRole('combobox', { name: 'Collection for new item' }), {
target: { value: '4' }
});
fireEvent.click(screen.getByRole('button', { name: 'Add item' }));
expect(await screen.findAllByText('Movie Mix')).not.toHaveLength(0);
expect(await screen.findAllByText('Nature Docs')).not.toHaveLength(0);
const expectedBody = scheduleItemRequest(added);
expect(expectedBody.collectionId).toBe(4);
expect(expectedBody.collectionType).toBe('Collection');
expect(window.fetch).toHaveBeenCalledWith('/api/schedules/5/items', expect.objectContaining({
body: JSON.stringify(scheduleItemRequest(added)),
body: JSON.stringify(expectedBody),
method: 'POST'
}));
expect(fetchCount('/api/schedules/5/items')).toBeGreaterThan(2);
expect(fetchCount('/api/collections')).toBe(collectionsFetchesBeforeAdd);
});
it('shows ProblemDetails when adding a schedule item fails', async () => {
mockDashboardApi({
collections: [{ id: 3, name: 'Movie Mix' }],
collections: [collection({ id: 3, name: 'Movie Mix' })],
mutationFailures: {
'/api/schedules/5/items': {
detail: 'Collection is required',
@@ -1070,6 +1127,16 @@ function schedule(overrides: Record<string, unknown> = {}): Record<string, unkno
};
}
function collection(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
collectionType: 'Collection',
id: 2,
name: 'Saturday Cartoons',
useCustomPlaybackOrder: false,
...overrides
};
}
function scheduleItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
const collection = { id: 2, name: 'Saturday Cartoons', ...(overrides.collection as object | undefined) };
@@ -1239,16 +1306,6 @@ function mockDashboardApi({
return Promise.resolve(jsonResponse(schedules));
}
if (path.match(/^\/api\/schedules\/\d+$/)) {
const id = Number(path.split('/').at(-1));
const found = schedules.find((item) => (item as { id?: number }).id === id);
return Promise.resolve(found ? jsonResponse(found) : jsonResponse({
detail: `Schedule ${id} was not found`,
status: 404,
title: 'Not found'
}, 404));
}
if (path.match(/^\/api\/schedules\/\d+\/items$/)) {
const method = init?.method ?? 'GET';
+51 -16
View File
@@ -1228,11 +1228,13 @@ function SchedulesEmptyState() {
function ScheduleScreen() {
const query = useScheduleScreenQuery();
const [selectedItemId, setSelectedItemId] = useState<number | null>(null);
const [selectedCollectionId, setSelectedCollectionId] = useState<number | null>(null);
const [dragItemId, setDragItemId] = useState<number | null>(null);
const [overItemId, setOverItemId] = useState<number | null>(null);
const [inspectorTab, setInspectorTab] = useState<ScheduleInspectorTab>('content');
const [mutationError, setMutationError] = useState<string | null>(null);
const [mutating, setMutating] = useState(false);
const mutatingRef = useRef(false);
if (query.status === 'loading') {
return <SchedulesLoadingState />;
@@ -1243,6 +1245,7 @@ function ScheduleScreen() {
}
const { activeSchedule, items, pickers, schedules, totalDurationEstimate } = query.data;
const { itemsLoading, setActiveSchedule, setItems } = query;
if (!activeSchedule) {
return <SchedulesEmptyState />;
@@ -1252,18 +1255,27 @@ function ScheduleScreen() {
const selectedItem = orderedItems.find((item) => item.id === selectedItemId) ?? orderedItems[0] ?? null;
const effectiveSelectedItemId = selectedItem?.id ?? null;
const selectedIndex = selectedItem ? orderedItems.findIndex((item) => item.id === selectedItem.id) : -1;
const collectionOptions = sortedCollections(pickers.collections);
const effectiveCollectionId = collectionOptions.some((collection) => collection.id === selectedCollectionId)
? selectedCollectionId
: collectionOptions[0]?.id ?? null;
const refreshAfterMutation = async (operation: () => Promise<void>) => {
const setMutatingState = (value: boolean) => {
mutatingRef.current = value;
setMutating(value);
};
const refetchItemsAfterMutation = async (operation: () => Promise<void>) => {
setMutationError(null);
setMutating(true);
setMutatingState(true);
try {
await operation();
query.refresh();
setActiveSchedule(activeSchedule.id);
} catch (error: unknown) {
setMutationError(messageFromError(error));
} finally {
setMutating(false);
setMutatingState(false);
}
};
@@ -1273,22 +1285,22 @@ function ScheduleScreen() {
try {
setMutationError(null);
setMutating(true);
setMutatingState(true);
const result = await replaceScheduleItems(activeSchedule.id, {
items: nextItems.map(scheduleItemToRequest)
});
setSelectedItemId(previousSelectedId ?? result[0]?.id ?? null);
query.refresh();
setItems(result);
} catch (error: unknown) {
setSelectedItemId(previousSelectedId);
setMutationError(messageFromError(error));
} finally {
setMutating(false);
setMutatingState(false);
}
};
const moveItem = (itemId: number, direction: -1 | 1) => {
if (mutating) {
if (mutatingRef.current) {
return;
}
@@ -1306,7 +1318,7 @@ function ScheduleScreen() {
};
const dropItem = (targetId: number) => {
if (dragItemId == null || dragItemId === targetId || mutating) {
if (dragItemId == null || dragItemId === targetId || mutatingRef.current) {
setDragItemId(null);
setOverItemId(null);
return;
@@ -1327,14 +1339,14 @@ function ScheduleScreen() {
};
const addItem = () => {
const collection = firstCollection(pickers.collections);
const collection = collectionOptions.find((candidate) => candidate.id === effectiveCollectionId);
if (!collection) {
setMutationError('A collection is required before adding a schedule item');
return;
}
void refreshAfterMutation(async () => {
void refetchItemsAfterMutation(async () => {
const added = await addScheduleItem(activeSchedule.id, newScheduleItemRequest(collection));
setSelectedItemId(added.id ?? null);
});
@@ -1347,7 +1359,7 @@ function ScheduleScreen() {
return;
}
void refreshAfterMutation(async () => {
void refetchItemsAfterMutation(async () => {
await deleteScheduleItem(activeSchedule.id, itemId);
setSelectedItemId(null);
});
@@ -1363,8 +1375,15 @@ function ScheduleScreen() {
{schedules.length} schedule{schedules.length === 1 ? '' : 's'} · {orderedItems.length} item{orderedItems.length === 1 ? '' : 's'} · programs <code>{totalDurationEstimate ?? 'unknown'}</code>
</p>
</div>
<Select
disabled={mutating}
label="Active schedule"
onChange={(event) => setActiveSchedule(Number(event.target.value))}
options={scheduleOptions(schedules)}
size="sm"
value={`${activeSchedule.id}`}
/>
<Button disabled startIcon={<Play aria-hidden="true" size={15} />} variant="secondary">Preview playout</Button>
<Button disabled={mutating} startIcon={<Check aria-hidden="true" size={15} />}>Save</Button>
</section>
{mutationError && (
@@ -1378,6 +1397,15 @@ function ScheduleScreen() {
<section className="ctv-schedule-lineup-panel" aria-label="Lineup">
<div className="ctv-schedule-panel-head">
<span>Lineup · drag to reorder</span>
{itemsLoading && <Spinner size={13} tone="muted" />}
<Select
disabled={mutating || collectionOptions.length === 0}
label="Collection for new item"
onChange={(event) => setSelectedCollectionId(Number(event.target.value))}
options={pickerOptions(collectionOptions)}
size="sm"
value={`${effectiveCollectionId ?? ''}`}
/>
<Button disabled={mutating} onClick={addItem} size="sm" startIcon={<Plus aria-hidden="true" size={14} />} variant="ghost">Add item</Button>
</div>
@@ -1469,6 +1497,7 @@ function ScheduleItemBlock({
<code>{fixed ? formatScheduleTime(item.startTime) : 'flows'}</code>
</div>
<div
aria-roledescription="draggable schedule item"
className={`ctv-schedule-block${active ? ' ctv-schedule-block-active' : ''}${dragging ? ' ctv-schedule-block-dragging' : ''}${isOver ? ' ctv-schedule-block-over' : ''}`}
draggable={!mutating}
onDragEnd={() => {
@@ -1486,7 +1515,7 @@ function ScheduleItemBlock({
<button
type="button"
className="ctv-schedule-block-main"
aria-pressed={active}
aria-current={active ? 'true' : undefined}
onClick={() => onSelect(itemId)}
>
<GripVertical aria-hidden="true" size={16} />
@@ -1597,6 +1626,7 @@ function ScheduleInspector({
<Select disabled label="Guide mode" value={item.guideMode ?? 'Normal'} options={['Normal', 'Filler']} />
<Input disabled label="Schedule" value={schedule.name ?? 'Unnamed schedule'} />
</div>
<Checkbox disabled checked={schedule.keepMultiPartEpisodesTogether} label="Keep multi-part together" />
</>
)}
{tab === 'playback' && (
@@ -1619,6 +1649,7 @@ function ScheduleInspector({
<Select disabled label="Mid-roll" value={item.midRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Post-roll" value={item.postRollFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Fallback" value={item.fallbackFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
<Select disabled label="Tail" value={item.tailFiller?.name ?? 'None'} options={fillerOptions(pickers.fillerPresets)} />
</div>
)}
{tab === 'overrides' && (
@@ -1730,8 +1761,12 @@ function collectionIdForItem(item: ProgramScheduleItem): number | null {
return item.collection?.id ?? item.multiCollection?.id ?? item.smartCollection?.id ?? null;
}
function firstCollection(collections: MediaCollection[]): MediaCollection | null {
return [...collections].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''))[0] ?? null;
function sortedCollections(collections: MediaCollection[]): MediaCollection[] {
return [...collections].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
function scheduleOptions(schedules: ProgramSchedule[]): Array<{ label: string; value: string }> {
return schedules.map((schedule) => ({ label: schedule.name ?? `Schedule ${schedule.id}`, value: `${schedule.id}` }));
}
function scheduleFillDescriptor(item: ProgramScheduleItem): { icon: ReactNode; label: string } {
+102 -20
View File
@@ -3,6 +3,11 @@ import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
// subtypes with Newtonsoft using the runtime type), but the OpenAPI schema doesn't declare
// them: ProgramScheduleItemViewModel is an abstract base with no polymorphism annotation, so
// the generator only sees the base shape. Tracked in Gitea issue #126 — remove this widening
// once the schema is fixed to describe the concrete subtypes.
export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
count?: null | string;
discardToFillAttempts?: null | number;
@@ -35,12 +40,20 @@ export interface ScheduleScreenData {
}
export type ScheduleScreenQueryState =
| { data: ScheduleScreenData; error: null; refresh: () => void; status: 'success' }
| {
data: ScheduleScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActiveSchedule: (scheduleId: number) => void;
setItems: (items: ProgramScheduleItem[]) => void;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ScheduleScreenState =
| { data: ScheduleScreenData; error: null; status: 'success' }
| { data: ScheduleScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
@@ -126,6 +139,7 @@ export function useScheduleScreenQuery(): ScheduleScreenQueryState {
status: 'loading'
});
const activeRef = useRef(true);
const activeScheduleIdRef = useRef<number | null>(null);
useEffect(() => {
activeRef.current = true;
@@ -136,12 +150,11 @@ export function useScheduleScreenQuery(): ScheduleScreenQueryState {
}, []);
const load = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
getScheduleScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
activeScheduleIdRef.current = data.activeSchedule?.id ?? null;
setState({ data, error: null, itemsLoading: false, status: 'success' });
}
})
.catch((error: unknown) => {
@@ -152,25 +165,94 @@ export function useScheduleScreenQuery(): ScheduleScreenQueryState {
}, []);
useEffect(() => {
getScheduleScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
}
});
}, []);
const refresh = useCallback(() => {
load();
}, [load]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActiveSchedule = useCallback((scheduleId: number) => {
activeScheduleIdRef.current = scheduleId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
const nextActiveSchedule = current.data.schedules.find((schedule) => schedule.id === scheduleId)
?? current.data.activeSchedule;
return {
data: { ...current.data, activeSchedule: nextActiveSchedule },
error: null,
itemsLoading: true,
status: 'success'
};
});
getScheduleItems(scheduleId)
.then((itemsEnvelope) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState({
data: null,
error: messageFromScheduleError(error, 'Unable to load schedule items'),
status: 'error'
});
});
}, []);
const setItems = useCallback((items: ProgramScheduleItem[]) => {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items },
error: null,
itemsLoading: false,
status: 'success'
};
});
}, []);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, status: 'success' };
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActiveSchedule,
setItems,
status: 'success'
};
}
if (state.status === 'error') {
+4 -4
View File
@@ -1022,7 +1022,7 @@
.ctv-schedule-inspector-head strong {
margin: 0;
color: var(--text-primary);
font-size: var(--text-md, 15px);
font-size: var(--text-md, 14px);
font-weight: var(--weight-semibold, 600);
line-height: 1.2;
}
@@ -1100,7 +1100,7 @@
align-items: center;
gap: var(--space-2, 4px);
padding-top: var(--space-7, 16px);
color: var(--text-faint);
color: var(--text-disabled);
font-size: 9px;
}
@@ -1160,7 +1160,7 @@
}
.ctv-schedule-block-main > svg {
color: var(--text-faint);
color: var(--text-disabled);
cursor: grab;
}
@@ -1173,7 +1173,7 @@
.ctv-schedule-block-title strong {
overflow: hidden;
color: var(--text-primary);
font-size: var(--text-md, 15px);
font-size: var(--text-md, 14px);
font-weight: var(--weight-semibold, 600);
line-height: 1.2;
text-overflow: ellipsis;