fix(web): scope channel bulk ops to visible rows, review fixes for #84
- Derive selectedChannels from visibleChannels and clear selection on filter change so bulk operations can never act on rows hidden by the active filter (reachable via the 30s state poll under the on-air filter) - Disable the per-row delete button while a mutation is in flight - Correct the --pad-cell-x fallback (token resolves to 16px) - Add tests: bulk renumber request body + selection clearing, and selection reset across filter changes Review: PR #124 findings 1, 2, 4, 8 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -537,6 +537,117 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(await screen.findByText('Channel 1 cannot be deleted while active')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('runs bulk renumber and clears the selection afterward', async () => {
|
||||
mockDashboardApi({
|
||||
channels: [
|
||||
{
|
||||
fFmpegProfile: 'HLS Direct',
|
||||
group: 'Kids',
|
||||
id: 1,
|
||||
isEnabled: true,
|
||||
language: 'en',
|
||||
name: 'Retro Cartoons',
|
||||
number: '5.1',
|
||||
showInEpg: true,
|
||||
sortNumber: 5.1,
|
||||
streamingMode: 'HLS Direct'
|
||||
},
|
||||
{
|
||||
fFmpegProfile: 'MPEG-TS',
|
||||
group: 'News',
|
||||
id: 2,
|
||||
isEnabled: false,
|
||||
language: 'fr',
|
||||
name: 'News 24',
|
||||
number: '24',
|
||||
showInEpg: false,
|
||||
sortNumber: 24,
|
||||
streamingMode: 'MPEG-TS'
|
||||
}
|
||||
],
|
||||
prompt: '10'
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
|
||||
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Renumber' }));
|
||||
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/channels/bulk/renumber', expect.objectContaining({
|
||||
body: JSON.stringify({ channels: [{ id: 1, number: '10' }, { id: 2, number: '11' }] }),
|
||||
method: 'POST'
|
||||
}));
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: 'Select Retro Cartoons' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false'
|
||||
);
|
||||
expect(screen.getByRole('checkbox', { name: 'Select News 24' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'false'
|
||||
);
|
||||
});
|
||||
|
||||
it('clears the selection when the channel view filter changes', async () => {
|
||||
mockDashboardApi({
|
||||
channels: [
|
||||
{
|
||||
fFmpegProfile: 'HLS Direct',
|
||||
group: 'Kids',
|
||||
id: 1,
|
||||
isEnabled: true,
|
||||
language: 'en',
|
||||
name: 'Retro Cartoons',
|
||||
number: '5.1',
|
||||
showInEpg: true,
|
||||
sortNumber: 5.1,
|
||||
streamingMode: 'HLS Direct'
|
||||
},
|
||||
{
|
||||
fFmpegProfile: 'MPEG-TS',
|
||||
group: 'News',
|
||||
id: 2,
|
||||
isEnabled: true,
|
||||
language: 'fr',
|
||||
name: 'News 24',
|
||||
number: '24',
|
||||
showInEpg: true,
|
||||
sortNumber: 24,
|
||||
streamingMode: 'MPEG-TS'
|
||||
}
|
||||
],
|
||||
channelStates: [
|
||||
{ channelId: 1, channelNumber: '5.1', onAir: true, nowPlaying: null },
|
||||
{ channelId: 2, channelNumber: '24', onAir: false, nowPlaying: null }
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Channels' }));
|
||||
expect(await screen.findByText('Retro Cartoons')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Select News 24' }));
|
||||
expect(screen.getByRole('button', { name: 'Delete selected' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
expect(screen.getByRole('button', { name: 'On air 1' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'On air 1' }));
|
||||
expect(screen.queryByText('News 24')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'Select Retro Cartoons' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Delete selected' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'All 2' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the dashboard loading state while requests are pending', async () => {
|
||||
vi.spyOn(window, 'fetch').mockImplementation(() => new Promise<Response>(() => {}));
|
||||
|
||||
|
||||
+16
-5
@@ -877,7 +877,7 @@ function ChannelsScreen() {
|
||||
const selectedVisibleCount = visibleIds.filter((id) => selectedIds.has(id)).length;
|
||||
const allVisibleSelected = visibleIds.length > 0 && selectedVisibleCount === visibleIds.length;
|
||||
const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;
|
||||
const selectedChannels = channels.filter((channel) => selectedIds.has(channel.id));
|
||||
const selectedChannels = visibleChannels.filter((channel) => selectedIds.has(channel.id));
|
||||
|
||||
const refreshAfterMutation = async (operation: () => Promise<void>) => {
|
||||
setMutationError(null);
|
||||
@@ -965,6 +965,11 @@ function ChannelsScreen() {
|
||||
void refreshAfterMutation(() => bulkDeleteChannels({ channelIds: selectedChannels.map((channel) => channel.id) }));
|
||||
};
|
||||
|
||||
const changeFilter = (nextFilter: ChannelViewFilter) => {
|
||||
setFilter(nextFilter);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const deleteOne = (channel: ChannelSummary) => {
|
||||
if (!window.confirm(`Delete ${channel.name}?`)) {
|
||||
return;
|
||||
@@ -993,9 +998,9 @@ function ChannelsScreen() {
|
||||
) : (
|
||||
<>
|
||||
<div className="ctv-segmented" role="group" aria-label="Channel view">
|
||||
<button type="button" aria-pressed={filter === 'all'} onClick={() => setFilter('all')}>All <code>{channels.length}</code></button>
|
||||
<button type="button" aria-pressed={filter === 'onair'} onClick={() => setFilter('onair')}>On air <code>{onAirCount}</code></button>
|
||||
<button type="button" aria-pressed={filter === 'disabled'} onClick={() => setFilter('disabled')}>Disabled <code>{disabledCount}</code></button>
|
||||
<button type="button" aria-pressed={filter === 'all'} onClick={() => changeFilter('all')}>All <code>{channels.length}</code></button>
|
||||
<button type="button" aria-pressed={filter === 'onair'} onClick={() => changeFilter('onair')}>On air <code>{onAirCount}</code></button>
|
||||
<button type="button" aria-pressed={filter === 'disabled'} onClick={() => changeFilter('disabled')}>Disabled <code>{disabledCount}</code></button>
|
||||
</div>
|
||||
<span className="ctv-channels-spacer" />
|
||||
<span className="ctv-channels-live"><StatusDot status="live" size={7} /><code>{onAirCount}</code> on air</span>
|
||||
@@ -1031,6 +1036,7 @@ function ChannelsScreen() {
|
||||
channels={rows}
|
||||
group={group}
|
||||
key={group}
|
||||
mutating={mutating}
|
||||
onDelete={deleteOne}
|
||||
onToggle={toggleOne}
|
||||
selectedIds={selectedIds}
|
||||
@@ -1052,6 +1058,7 @@ function ChannelsScreen() {
|
||||
function ChannelGroupRows({
|
||||
channels,
|
||||
group,
|
||||
mutating,
|
||||
onDelete,
|
||||
onToggle,
|
||||
selectedIds,
|
||||
@@ -1059,6 +1066,7 @@ function ChannelGroupRows({
|
||||
}: {
|
||||
channels: ChannelSummary[];
|
||||
group: string;
|
||||
mutating: boolean;
|
||||
onDelete: (channel: ChannelSummary) => void;
|
||||
onToggle: (channelId: number) => void;
|
||||
selectedIds: Set<number>;
|
||||
@@ -1075,6 +1083,7 @@ function ChannelGroupRows({
|
||||
<ChannelTableRow
|
||||
channel={channel}
|
||||
key={channel.id}
|
||||
mutating={mutating}
|
||||
onDelete={onDelete}
|
||||
onToggle={onToggle}
|
||||
selected={selectedIds.has(channel.id)}
|
||||
@@ -1091,12 +1100,14 @@ function formatChannelNumber(value: number): string {
|
||||
|
||||
function ChannelTableRow({
|
||||
channel,
|
||||
mutating,
|
||||
onDelete,
|
||||
onToggle,
|
||||
selected,
|
||||
state
|
||||
}: {
|
||||
channel: ChannelSummary;
|
||||
mutating: boolean;
|
||||
onDelete: (channel: ChannelSummary) => void;
|
||||
onToggle: (channelId: number) => void;
|
||||
selected: boolean;
|
||||
@@ -1151,7 +1162,7 @@ function ChannelTableRow({
|
||||
<IconButton disabled size="sm" title={`Troubleshoot ${channel.name}`}>
|
||||
<Stethoscope aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => onDelete(channel)} size="sm" title={`Delete ${channel.name}`}>
|
||||
<IconButton disabled={mutating} onClick={() => onDelete(channel)} size="sm" title={`Delete ${channel.name}`}>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -816,7 +816,7 @@
|
||||
border-bottom: 1px solid var(--border-hairline);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-disabled);
|
||||
padding: 0 var(--pad-cell-x, 12px);
|
||||
padding: 0 var(--pad-cell-x, 16px);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-2xs, 11px);
|
||||
@@ -829,7 +829,7 @@
|
||||
height: 56px;
|
||||
border-top: 1px solid var(--border-hairline);
|
||||
color: var(--text-primary);
|
||||
padding: 0 var(--pad-cell-x, 12px);
|
||||
padding: 0 var(--pad-cell-x, 16px);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user