Merge remote-tracking branch 'origin/main' into feat/144-s1-blocks
This commit is contained in:
@@ -1834,6 +1834,120 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(screen.getAllByText('Saturday Morning Cartoons').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('creates a classic playout from the Add Playout dialog and selects it', async () => {
|
||||
mockDashboardApi({
|
||||
channels: [channelSummary({ id: 1, name: 'Retro Cartoons', number: '5' })],
|
||||
createPlayoutResponse: playout({ id: 42 }),
|
||||
playoutItems: [playoutItem()],
|
||||
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
|
||||
schedules: [schedule({ id: 7, name: 'Weekend Lineup' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Playout' }));
|
||||
expect(await screen.findByText('Add playout')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Channel'), { target: { value: '1' } });
|
||||
await screen.findByRole('option', { name: 'Weekend Lineup' });
|
||||
fireEvent.change(screen.getByLabelText('Classic schedule'), { target: { value: '7' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestBodyFor('/api/playouts')).toMatchObject({
|
||||
channelId: 1,
|
||||
programScheduleId: 7,
|
||||
scheduleKind: 'Classic'
|
||||
});
|
||||
});
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/playouts', expect.objectContaining({ method: 'POST' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Add playout')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the create-playout error inline when the API rejects the request', async () => {
|
||||
mockDashboardApi({
|
||||
channels: [channelSummary({ id: 1, name: 'Retro Cartoons', number: '5' })],
|
||||
mutationFailures: {
|
||||
'/api/playouts': {
|
||||
detail: 'Channel already has one playout',
|
||||
status: 422,
|
||||
title: 'Validation failed'
|
||||
}
|
||||
},
|
||||
playoutItems: [playoutItem()],
|
||||
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
|
||||
schedules: [schedule({ id: 7, name: 'Weekend Lineup' })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Playout' }));
|
||||
expect(await screen.findByText('Add playout')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Channel'), { target: { value: '1' } });
|
||||
await screen.findByRole('option', { name: 'Weekend Lineup' });
|
||||
fireEvent.change(screen.getByLabelText('Classic schedule'), { target: { value: '7' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
expect(await screen.findByText('Channel already has one playout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edits daily rebuild time from the playout detail panel', async () => {
|
||||
mockDashboardApi({
|
||||
playoutDetails: playout({ id: 20, dailyRebuildTime: '04:00:00' }),
|
||||
playoutItems: [playoutItem()],
|
||||
playouts: { page: [listPlayout({ id: 20 })], totalCount: 1 },
|
||||
updatePlayoutDetailsResponse: playout({ id: 20, dailyRebuildTime: '05:00:00' })
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
|
||||
expect(await screen.findByText('Edit playout details')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Daily reset time'), { target: { value: '05:00:00' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestBodyFor('/api/playouts/20')).toMatchObject({ dailyRebuildTime: '05:00:00' });
|
||||
});
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/playouts/20', expect.objectContaining({ method: 'PUT' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Edit playout details')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the schedule-file field only for file-backed playout kinds when editing', async () => {
|
||||
mockDashboardApi({
|
||||
playoutDetails: playout({ id: 20, scheduleFile: '/config/schedule.yml', scheduleKind: 'Sequential' }),
|
||||
playoutItems: [playoutItem()],
|
||||
playouts: { page: [listPlayout({ id: 20, scheduleKind: 'Sequential' })], totalCount: 1 }
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: /Playouts/ }));
|
||||
expect(await screen.findByRole('heading', { name: 'Retro Cartoons' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit details' }));
|
||||
expect(await screen.findByText('Edit playout details')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByLabelText('Sequential schedule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the dashboard loading state while requests are pending', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation(() => new Promise<Response>(() => {}));
|
||||
|
||||
@@ -3336,6 +3450,8 @@ function mockDashboardApi({
|
||||
mediaSourcesFailuresBeforeSuccess = 0,
|
||||
multiCollections = [],
|
||||
mutationFailures = {},
|
||||
createPlayoutResponse = null,
|
||||
updatePlayoutDetailsResponse = null,
|
||||
playoutDetails = null,
|
||||
playoutItems = [],
|
||||
playoutItemsFailure = null,
|
||||
@@ -3395,6 +3511,8 @@ function mockDashboardApi({
|
||||
mediaSourcesFailuresBeforeSuccess?: number;
|
||||
multiCollections?: unknown[];
|
||||
mutationFailures?: Record<string, unknown>;
|
||||
createPlayoutResponse?: unknown;
|
||||
updatePlayoutDetailsResponse?: unknown;
|
||||
playoutDetails?: unknown;
|
||||
playoutItems?: unknown[];
|
||||
playoutItemsFailure?: unknown;
|
||||
@@ -3626,6 +3744,17 @@ function mockDashboardApi({
|
||||
}
|
||||
|
||||
if (path === '/api/playouts') {
|
||||
const method = init?.method ?? 'GET';
|
||||
|
||||
if (method === 'POST') {
|
||||
if (path in mutationFailures) {
|
||||
const failure = mutationFailures[path] as { status?: number };
|
||||
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(createPlayoutResponse ?? playout({ id: 99 }), 201));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(playouts));
|
||||
}
|
||||
|
||||
@@ -3667,6 +3796,19 @@ function mockDashboardApi({
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/playouts\/\d+$/)) {
|
||||
const method = init?.method ?? 'GET';
|
||||
|
||||
if (method === 'PUT') {
|
||||
if (path in mutationFailures) {
|
||||
const failure = mutationFailures[path] as { status?: number };
|
||||
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(
|
||||
updatePlayoutDetailsResponse ?? playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })
|
||||
));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(playoutDetails ?? playout({ id: Number(path.split('/').at(-1)) })));
|
||||
}
|
||||
|
||||
|
||||
+358
-12
@@ -79,6 +79,7 @@ import {
|
||||
Card,
|
||||
Checkbox,
|
||||
ChannelLogo,
|
||||
Dialog,
|
||||
IconButton,
|
||||
Input,
|
||||
NavItem,
|
||||
@@ -97,12 +98,15 @@ import {
|
||||
bulkDeleteChannels,
|
||||
bulkMoveChannelsToGroup,
|
||||
bulkRenumberChannels,
|
||||
createPlayout,
|
||||
deleteChannel,
|
||||
messageFromError,
|
||||
addScheduleItem,
|
||||
deleteScheduleItem,
|
||||
getSchedules,
|
||||
resetAllPlayouts,
|
||||
replaceScheduleItems,
|
||||
updatePlayoutDetails,
|
||||
usePlayoutsScreenQuery,
|
||||
useScheduleScreenQuery,
|
||||
useDashboardHealthQuery,
|
||||
@@ -116,6 +120,7 @@ import {
|
||||
type ChannelSummary,
|
||||
type ChannelGuideChannel,
|
||||
type ChannelGuideProgramme,
|
||||
type CreatePlayoutRequest,
|
||||
type DashboardChannel,
|
||||
type SchedulePickerData,
|
||||
type DashboardChannelState,
|
||||
@@ -124,6 +129,8 @@ import {
|
||||
type MediaSource,
|
||||
type MediaSourceLibrary,
|
||||
type MediaCollection,
|
||||
type PlayoutDetail,
|
||||
type PlayoutScheduleKind,
|
||||
type ProgramSchedule,
|
||||
type ProgramScheduleItem,
|
||||
type PlayoutItem,
|
||||
@@ -2617,12 +2624,258 @@ function PlayoutsEmptyState() {
|
||||
);
|
||||
}
|
||||
|
||||
const PLAYOUT_KIND_OPTIONS: Array<{ label: string; value: PlayoutScheduleKind }> = [
|
||||
{ label: 'Classic', value: 'Classic' },
|
||||
{ label: 'Block', value: 'Block' },
|
||||
{ label: 'Sequential', value: 'Sequential' },
|
||||
{ label: 'Scripted', value: 'Scripted' },
|
||||
{ label: 'External JSON (dizqueTV)', value: 'ExternalJson' }
|
||||
];
|
||||
|
||||
function scheduleFileHelperText(kind: PlayoutScheduleKind): string {
|
||||
switch (kind) {
|
||||
case 'Sequential':
|
||||
return 'The full path to the sequential schedule (YAML) file';
|
||||
case 'Scripted':
|
||||
return 'The command line used to run the scripted schedule';
|
||||
case 'ExternalJson':
|
||||
return 'The full path to the JSON (dizqueTV) schedule file';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function AddPlayoutDialog({
|
||||
busy,
|
||||
channelsWithPlayouts,
|
||||
error,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
open
|
||||
}: {
|
||||
busy: boolean;
|
||||
channelsWithPlayouts: Set<string>;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (request: CreatePlayoutRequest) => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const [kind, setKind] = useState<PlayoutScheduleKind>('Classic');
|
||||
const [channelId, setChannelId] = useState('');
|
||||
const [scheduleId, setScheduleId] = useState('');
|
||||
const [scheduleFile, setScheduleFile] = useState('');
|
||||
const [schedules, setSchedules] = useState<ProgramSchedule[] | null>(null);
|
||||
const schedulesLoading = kind === 'Classic' && schedules === null;
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== 'Classic' || schedules !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
getSchedules()
|
||||
.then((result) => {
|
||||
if (active) {
|
||||
setSchedules(result);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setSchedules([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [kind, schedules]);
|
||||
|
||||
const channels = channelsQuery.status === 'success' ? channelsQuery.channels : [];
|
||||
const needsScheduleFile = kind === 'Sequential' || kind === 'Scripted' || kind === 'ExternalJson';
|
||||
const canSubmit =
|
||||
channelId.length > 0 &&
|
||||
(kind !== 'Classic' || scheduleId.length > 0) &&
|
||||
(!needsScheduleFile || scheduleFile.trim().length > 0);
|
||||
|
||||
const submit = () => {
|
||||
if (!canSubmit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request: CreatePlayoutRequest = {
|
||||
channelId: Number(channelId),
|
||||
programScheduleId: kind === 'Classic' ? Number(scheduleId) : null,
|
||||
scheduleFile: needsScheduleFile ? scheduleFile.trim() : null,
|
||||
scheduleKind: kind
|
||||
};
|
||||
onSubmit(request);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={busy || !canSubmit} loading={busy} onClick={submit} variant="primary">
|
||||
Create
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onCancel}
|
||||
open={open}
|
||||
title="Add playout"
|
||||
width={480}
|
||||
>
|
||||
<Select
|
||||
label="Kind"
|
||||
onChange={(event) => setKind(event.target.value as PlayoutScheduleKind)}
|
||||
options={PLAYOUT_KIND_OPTIONS}
|
||||
value={kind}
|
||||
/>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Select
|
||||
label="Channel"
|
||||
onChange={(event) => setChannelId(event.target.value)}
|
||||
options={[
|
||||
{ label: 'Select a channel...', value: '' },
|
||||
...channels.map((channel) => ({
|
||||
label: `${channel.number} - ${channel.name}${channelsWithPlayouts.has(channel.number) ? ' (already has a playout)' : ''}`,
|
||||
value: `${channel.id}`
|
||||
}))
|
||||
]}
|
||||
value={channelId}
|
||||
/>
|
||||
</div>
|
||||
{kind === 'Classic' && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Select
|
||||
disabled={schedulesLoading}
|
||||
label="Classic schedule"
|
||||
onChange={(event) => setScheduleId(event.target.value)}
|
||||
options={[
|
||||
{ label: schedulesLoading ? 'Loading...' : 'Select a schedule...', value: '' },
|
||||
...(schedules ?? []).map((schedule) => ({ label: schedule.name ?? `Schedule ${schedule.id}`, value: `${schedule.id}` }))
|
||||
]}
|
||||
value={scheduleId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{kind === 'Block' && (
|
||||
<p style={{ color: 'var(--ctv-text-muted, #888)', marginTop: 12 }}>Block templates are added later.</p>
|
||||
)}
|
||||
{needsScheduleFile && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Input
|
||||
label={kind === 'ExternalJson' ? 'JSON (dizqueTV) schedule' : `${kind} schedule`}
|
||||
onChange={(event) => setScheduleFile(event.target.value)}
|
||||
value={scheduleFile}
|
||||
/>
|
||||
<small style={{ color: 'var(--ctv-text-muted, #888)' }}>{scheduleFileHelperText(kind)}</small>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditPlayoutDetailsDialog({
|
||||
busy,
|
||||
error,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
open,
|
||||
playout
|
||||
}: {
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (dailyRebuildTime: string | null, scheduleFile: string | null) => void;
|
||||
open: boolean;
|
||||
playout: PlayoutDetail;
|
||||
}) {
|
||||
const canEditScheduleFile =
|
||||
playout.scheduleKind === 'Sequential' || playout.scheduleKind === 'Scripted' || playout.scheduleKind === 'ExternalJson';
|
||||
|
||||
const [dailyRebuildTime, setDailyRebuildTime] = useState(playout.dailyRebuildTime ?? '');
|
||||
const [scheduleFile, setScheduleFile] = useState(playout.scheduleFile ?? '');
|
||||
|
||||
const rebuildOptions = [
|
||||
{ label: 'Do not automatically reset', value: '' },
|
||||
...Array.from({ length: 47 }, (_, index) => {
|
||||
const totalMinutes = (index + 1) * 30;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const value = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
|
||||
const label = new Date(2000, 0, 1, hours, minutes).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
return { label, value };
|
||||
})
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onCancel} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || (canEditScheduleFile && scheduleFile.trim().length === 0)}
|
||||
loading={busy}
|
||||
onClick={() => onSubmit(dailyRebuildTime.length > 0 ? dailyRebuildTime : null, canEditScheduleFile ? scheduleFile.trim() : null)}
|
||||
variant="primary"
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onCancel}
|
||||
open={open}
|
||||
title="Edit playout details"
|
||||
width={480}
|
||||
>
|
||||
<Select
|
||||
label="Daily reset time"
|
||||
onChange={(event) => setDailyRebuildTime(event.target.value)}
|
||||
options={rebuildOptions}
|
||||
value={dailyRebuildTime}
|
||||
/>
|
||||
{canEditScheduleFile && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Input
|
||||
label={playout.scheduleKind === 'ExternalJson' ? 'JSON (dizqueTV) schedule' : `${playout.scheduleKind} schedule`}
|
||||
onChange={(event) => setScheduleFile(event.target.value)}
|
||||
value={scheduleFile}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayoutsScreen() {
|
||||
const query = usePlayoutsScreenQuery();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const mutatingRef = useRef(false);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addBusy, setAddBusy] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editBusy, setEditBusy] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
if (query.status === 'loading') {
|
||||
return <PlayoutsLoadingState />;
|
||||
@@ -2635,16 +2888,7 @@ function PlayoutsScreen() {
|
||||
const { channelStates, items, playout, playouts, selectedPlayoutId, totalCount, warningsCount } = query.data;
|
||||
const { itemsLoading, setShowFiller, showFiller } = query;
|
||||
const selectedSummary = playouts.find((candidate) => candidate.id === selectedPlayoutId) ?? playouts[0] ?? null;
|
||||
|
||||
if (!selectedSummary) {
|
||||
return <PlayoutsEmptyState />;
|
||||
}
|
||||
|
||||
const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber);
|
||||
const nowPlaying = selectedState?.nowPlaying ?? null;
|
||||
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
|
||||
const nextItem = nextPlayoutItem(items, nowItem);
|
||||
const filteredPlayouts = filterPlayouts(playouts, filter);
|
||||
const channelsWithPlayouts = new Set(playouts.map((candidate) => candidate.channelNumber));
|
||||
|
||||
const setMutatingState = (value: boolean) => {
|
||||
mutatingRef.current = value;
|
||||
@@ -2670,13 +2914,77 @@ function PlayoutsScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
const submitAddPlayout = (request: CreatePlayoutRequest) => {
|
||||
setAddBusy(true);
|
||||
setAddError(null);
|
||||
createPlayout(request)
|
||||
.then((created) => {
|
||||
setAddOpen(false);
|
||||
query.setActivePlayout(created.id);
|
||||
query.refresh();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setAddError(messageFromError(error));
|
||||
})
|
||||
.finally(() => {
|
||||
setAddBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
const submitEditPlayoutDetails = (dailyRebuildTime: string | null, scheduleFile: string | null) => {
|
||||
if (!selectedSummary) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEditBusy(true);
|
||||
setEditError(null);
|
||||
updatePlayoutDetails(selectedSummary.id, { dailyRebuildTime, scheduleFile })
|
||||
.then(() => {
|
||||
setEditOpen(false);
|
||||
query.refresh();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setEditError(messageFromError(error));
|
||||
})
|
||||
.finally(() => {
|
||||
setEditBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
if (!selectedSummary) {
|
||||
return (
|
||||
<div className="ctv-playouts-screen">
|
||||
<section className="ctv-playouts-header">
|
||||
<span className="ctv-playouts-header-spacer" />
|
||||
<Button onClick={() => setAddOpen(true)} startIcon={<Plus aria-hidden="true" size={15} />} variant="primary">Add Playout</Button>
|
||||
</section>
|
||||
<PlayoutsEmptyState />
|
||||
<AddPlayoutDialog
|
||||
busy={addBusy}
|
||||
channelsWithPlayouts={channelsWithPlayouts}
|
||||
error={addError}
|
||||
key={`add-${addOpen}`}
|
||||
onCancel={() => setAddOpen(false)}
|
||||
onSubmit={submitAddPlayout}
|
||||
open={addOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedState = channelStates.find((state) => state.channelNumber === selectedSummary.channelNumber);
|
||||
const nowPlaying = selectedState?.nowPlaying ?? null;
|
||||
const nowItem = itemMatchingNow(items, nowPlaying?.title) ?? items[0] ?? null;
|
||||
const nextItem = nextPlayoutItem(items, nowItem);
|
||||
const filteredPlayouts = filterPlayouts(playouts, filter);
|
||||
|
||||
return (
|
||||
<div className="ctv-playouts-screen">
|
||||
<section className="ctv-playouts-header">
|
||||
<Button disabled={mutating} onClick={resetAll} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Reset all playouts</Button>
|
||||
<span className="ctv-playouts-header-spacer" />
|
||||
<Badge tone={warningsCount > 0 ? 'warn' : 'neutral'} dot={warningsCount > 0}>{warningsCount} warning{warningsCount === 1 ? '' : 's'}</Badge>
|
||||
<Button disabled startIcon={<Plus aria-hidden="true" size={15} />} variant="primary">Add Playout</Button>
|
||||
<Button onClick={() => setAddOpen(true)} startIcon={<Plus aria-hidden="true" size={15} />} variant="primary">Add Playout</Button>
|
||||
</section>
|
||||
|
||||
{mutationError && (
|
||||
@@ -2760,7 +3068,24 @@ function PlayoutsScreen() {
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
<Card title="Playout">
|
||||
<Card
|
||||
title="Playout"
|
||||
actions={
|
||||
playout && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditError(null);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
size="sm"
|
||||
startIcon={<Pencil aria-hidden="true" size={13} />}
|
||||
variant="ghost"
|
||||
>
|
||||
Edit details
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="ctv-playout-detail-grid">
|
||||
<Input disabled label="Mode" value={playout?.playoutMode ?? 'Unknown'} />
|
||||
<Input disabled label="Schedule" value={playout?.scheduleName ?? selectedSummary.scheduleName} />
|
||||
@@ -2814,6 +3139,27 @@ function PlayoutsScreen() {
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<AddPlayoutDialog
|
||||
busy={addBusy}
|
||||
channelsWithPlayouts={channelsWithPlayouts}
|
||||
error={addError}
|
||||
key={`add-${addOpen}`}
|
||||
onCancel={() => setAddOpen(false)}
|
||||
onSubmit={submitAddPlayout}
|
||||
open={addOpen}
|
||||
/>
|
||||
{playout && (
|
||||
<EditPlayoutDetailsDialog
|
||||
busy={editBusy}
|
||||
error={editError}
|
||||
key={`edit-${editOpen}-${playout.id}`}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onSubmit={submitEditPlayoutDetails}
|
||||
open={editOpen}
|
||||
playout={playout}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+7
-1
@@ -395,7 +395,9 @@ export interface components {
|
||||
};
|
||||
"CreatePlayoutRequest": {
|
||||
"channelId": number;
|
||||
"programScheduleId": number;
|
||||
"scheduleKind": components["schemas"]["PlayoutScheduleKind"];
|
||||
"programScheduleId": null | number;
|
||||
"scheduleFile": null | string;
|
||||
};
|
||||
"CreateResolutionRequest": {
|
||||
"width": number;
|
||||
@@ -1054,6 +1056,10 @@ export interface components {
|
||||
"searchingMinimumLogLevel": components["schemas"]["LogEventLevel"];
|
||||
"streamingMinimumLogLevel": components["schemas"]["LogEventLevel"];
|
||||
"httpMinimumLogLevel": components["schemas"]["LogEventLevel"];
|
||||
};
|
||||
"UpdatePlayoutDetailsRequest": {
|
||||
"dailyRebuildTime": null | string;
|
||||
"scheduleFile": null | string;
|
||||
};
|
||||
"UpdatePlayoutSettingsRequest": {
|
||||
"daysToBuild": number;
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPlayout, updatePlayoutDetails, type CreatePlayoutRequest } from './playouts';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
describe('playouts api client', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('createPlayout POSTs a classic playout request', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 7 }, 201));
|
||||
|
||||
const body: CreatePlayoutRequest = {
|
||||
channelId: 1,
|
||||
programScheduleId: 2,
|
||||
scheduleFile: null,
|
||||
scheduleKind: 'Classic'
|
||||
};
|
||||
|
||||
await expect(createPlayout(body)).resolves.toMatchObject({ id: 7 });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/playouts');
|
||||
expect(init).toMatchObject({ method: 'POST' });
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
channelId: 1,
|
||||
programScheduleId: 2,
|
||||
scheduleKind: 'Classic'
|
||||
});
|
||||
});
|
||||
|
||||
it('createPlayout POSTs a sequential playout request with a schedule file', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 9 }, 201));
|
||||
|
||||
const body: CreatePlayoutRequest = {
|
||||
channelId: 3,
|
||||
programScheduleId: null,
|
||||
scheduleFile: '/config/schedule.yml',
|
||||
scheduleKind: 'Sequential'
|
||||
};
|
||||
|
||||
await createPlayout(body);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
channelId: 3,
|
||||
scheduleFile: '/config/schedule.yml',
|
||||
scheduleKind: 'Sequential'
|
||||
});
|
||||
});
|
||||
|
||||
it('updatePlayoutDetails PUTs to the id route', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3 }));
|
||||
|
||||
await updatePlayoutDetails(3, { dailyRebuildTime: '04:00:00', scheduleFile: null });
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('/api/playouts/3');
|
||||
expect(init).toMatchObject({ method: 'PUT' });
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({ dailyRebuildTime: '04:00:00' });
|
||||
});
|
||||
|
||||
it('updatePlayoutDetails sends null dailyRebuildTime to clear the daily reset', async () => {
|
||||
const fetchMock = vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse({ id: 3 }));
|
||||
|
||||
await updatePlayoutDetails(3, { dailyRebuildTime: null, scheduleFile: null });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0];
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({ dailyRebuildTime: null });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@ export type PlayoutItem = components['schemas']['PlayoutItemResponseModel'];
|
||||
export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
|
||||
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
|
||||
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
|
||||
export type PlayoutScheduleKind = components['schemas']['PlayoutScheduleKind'];
|
||||
export type CreatePlayoutRequest = components['schemas']['CreatePlayoutRequest'];
|
||||
export type UpdatePlayoutDetailsRequest = components['schemas']['UpdatePlayoutDetailsRequest'];
|
||||
|
||||
export interface PlayoutsScreenData {
|
||||
channelStates: PlayoutChannelState[];
|
||||
@@ -64,6 +67,20 @@ export function resetAllPlayouts(): Promise<void> {
|
||||
return request<void>('/api/playouts/reset-all', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function createPlayout(body: CreatePlayoutRequest): Promise<PlayoutDetail> {
|
||||
return request<PlayoutDetail>('/api/playouts', {
|
||||
body,
|
||||
method: 'POST'
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlayoutDetails(playoutId: number, body: UpdatePlayoutDetailsRequest): Promise<PlayoutDetail> {
|
||||
return request<PlayoutDetail>(`/api/playouts/${playoutId}`, {
|
||||
body,
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
|
||||
const [state, setState] = useState<PlayoutsScreenState>({
|
||||
data: null,
|
||||
|
||||
Reference in New Issue
Block a user