fix(spa): block-history page-size persistence + History gating, blocks/templates list filter (#213)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m7s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Blazor parity conveniences: BlockPlayoutTroubleshootingScreen now persists the block-history
page-size selector to localStorage (ctv-block-history-page-size, same ctv- namespace as
ctv-theme) and restores it on mount, and gates the per-block History action on block.id >= 0
(mirrors BlockPlayoutTroubleshooting.razor, which hides it for synthesized/virtual blocks).
BlocksScreen and TemplatesScreen list screens gain a client-side name/group search filter box,
matching the filter already present on the troubleshooting blocks list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 00:04:15 +02:00
co-authored by Claude Fable 5
parent 8b77d5e739
commit caeabf2a2b
7 changed files with 243 additions and 32 deletions
@@ -15,7 +15,8 @@ const playoutsPage = {
};
const blocks = [
{ id: 10, groupId: 1, groupName: 'Morning', name: 'Toons', minutes: 60, stopScheduling: 'AfterDurationEnd' }
{ id: 10, groupId: 1, groupName: 'Morning', name: 'Toons', minutes: 60, stopScheduling: 'AfterDurationEnd' },
{ id: -1, groupId: 1, groupName: 'Morning', name: '(none)', minutes: 0, stopScheduling: 'AfterDurationEnd' }
];
const historyPage = {
@@ -87,4 +88,36 @@ describe('BlockPlayoutTroubleshootingScreen', () => {
expect(await screen.findByText('Cartoons Collection')).toBeTruthy();
expect(screen.getByText('S1E1 - Pilot')).toBeTruthy();
});
it('hides the History action for a block with a negative (unpersisted) id', async () => {
mockApi();
render(<BlockPlayoutTroubleshootingScreen />);
await screen.findByRole('option', { name: '1 - Cartoons' });
fireEvent.change(screen.getByRole('combobox'), { target: { value: '5' } });
await screen.findByText('Toons');
expect(await screen.findByText('(none)')).toBeTruthy();
// Only the persisted block (id 10) gets a History button.
expect(screen.getAllByRole('button', { name: 'History' })).toHaveLength(1);
});
it('restores the page size from localStorage on mount and persists a change', async () => {
window.localStorage.setItem('ctv-block-history-page-size', '50');
mockApi();
render(<BlockPlayoutTroubleshootingScreen />);
await screen.findByRole('option', { name: '1 - Cartoons' });
fireEvent.change(screen.getByRole('combobox'), { target: { value: '5' } });
await screen.findByText('Toons');
fireEvent.click(screen.getByRole('button', { name: 'History' }));
await screen.findByText('{"BlockId":10}');
const pageSizeSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement;
expect(pageSizeSelect.value).toBe('50');
fireEvent.change(pageSizeSelect, { target: { value: '25' } });
expect(window.localStorage.getItem('ctv-block-history-page-size')).toBe('25');
});
});
@@ -15,6 +15,31 @@ import {
const PAGE_SIZE_OPTIONS = ['10', '25', '50', '100'];
// Mirrors Blazor's TroubleshootingBlockPlayoutHistoryPageSize ConfigElement — persisted
// client-side here since this screen has no server-side config surface. Namespaced with the
// same `ctv-` prefix as other SPA-persisted preferences (e.g. `ctv-theme`).
const PAGE_SIZE_STORAGE_KEY = 'ctv-block-history-page-size';
function readStoredPageSize(): number {
try {
const raw = window.localStorage.getItem(PAGE_SIZE_STORAGE_KEY);
if (raw != null && PAGE_SIZE_OPTIONS.includes(raw)) {
return Number(raw);
}
} catch {
// localStorage unavailable (e.g. private browsing) - fall back to the default.
}
return 10;
}
function writeStoredPageSize(pageSize: number): void {
try {
window.localStorage.setItem(PAGE_SIZE_STORAGE_KEY, String(pageSize));
} catch {
// ignore - persistence is a convenience, not a requirement.
}
}
type PlayoutsState =
| { status: 'loading'; playouts: []; error: null }
| { status: 'success'; playouts: PlayoutSummary[]; error: null }
@@ -58,7 +83,7 @@ export function BlockPlayoutTroubleshootingScreen() {
const [blockFilter, setBlockFilter] = useState('');
const [selectedBlock, setSelectedBlock] = useState<SelectedBlock | null>(null);
const [pageNum, setPageNum] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [pageSize, setPageSize] = useState(() => readStoredPageSize());
const [historyState, setHistoryState] = useState<HistoryState>({
status: 'loading',
entries: [],
@@ -343,7 +368,9 @@ export function BlockPlayoutTroubleshootingScreen() {
<Select
onChange={(event) => {
setPageNum(0);
setPageSize(Number(event.target.value));
const nextPageSize = Number(event.target.value);
setPageSize(nextPageSize);
writeStoredPageSize(nextPageSize);
}}
options={PAGE_SIZE_OPTIONS}
style={{ width: 96 }}
@@ -464,14 +491,18 @@ function BlockGroupRows({
<td>{block.name}</td>
<td>{block.minutes}</td>
<td>
<Button
onClick={() => onOpenHistory(block)}
size="sm"
startIcon={<History aria-hidden="true" size={14} />}
variant="secondary"
>
History
</Button>
{/* Mirrors Blazor BlockPlayoutTroubleshooting.razor: the History action only exists
for persisted blocks (Id >= 0) — synthesized/virtual blocks have no history to show. */}
{block.id >= 0 && (
<Button
onClick={() => onOpenHistory(block)}
size="sm"
startIcon={<History aria-hidden="true" size={14} />}
variant="secondary"
>
History
</Button>
)}
</td>
</tr>
))}
+41 -4
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BlocksScreen } from './BlocksScreen';
@@ -6,8 +6,15 @@ function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const groups = [{ id: 2, name: 'Prime' }];
const blocks = [{ id: 4, groupId: 2, groupName: 'Prime', name: 'Morning', minutes: 90, stopScheduling: 'AfterDurationEnd' }];
const groups = [
{ id: 2, name: 'Prime' },
{ id: 3, name: 'Late Night' }
];
const blocks = [
{ id: 4, groupId: 2, groupName: 'Prime', name: 'Morning', minutes: 90, stopScheduling: 'AfterDurationEnd' },
{ id: 7, groupId: 2, groupName: 'Prime', name: 'Evening News', minutes: 30, stopScheduling: 'AfterDurationEnd' },
{ id: 8, groupId: 3, groupName: 'Late Night', name: 'Talk Show', minutes: 60, stopScheduling: 'AfterDurationEnd' }
];
function blockItem(overrides: Record<string, unknown>) {
return {
@@ -107,12 +114,42 @@ describe('BlocksScreen', () => {
expect(screen.getByText('Morning')).toBeInTheDocument();
});
it('filters blocks by name, case-insensitive', async () => {
mockApi();
render(<BlocksScreen />);
await screen.findByText('Morning');
expect(screen.getByText('Evening News')).toBeInTheDocument();
expect(screen.getByText('Talk Show')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search for blocks…'), { target: { value: 'EVENING' } });
expect(screen.getByText('Evening News')).toBeInTheDocument();
expect(screen.queryByText('Morning')).not.toBeInTheDocument();
expect(screen.queryByText('Talk Show')).not.toBeInTheDocument();
});
it('filters blocks by group name and shows an empty state for no matches', async () => {
mockApi();
render(<BlocksScreen />);
await screen.findByText('Morning');
fireEvent.change(screen.getByPlaceholderText('Search for blocks…'), { target: { value: 'late night' } });
expect(screen.getByText('Talk Show')).toBeInTheDocument();
expect(screen.queryByText('Morning')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search for blocks…'), { target: { value: 'zzz-no-match' } });
expect(await screen.findByText('No blocks match this filter.')).toBeInTheDocument();
});
it('opens the copy dialog and POSTs to the copy route, then refreshes the list', async () => {
mockApi();
render(<BlocksScreen />);
await screen.findByText('Morning');
fireEvent.click(screen.getByRole('button', { name: /Copy block/ }));
const row = screen.getByText('Morning').closest('.ctv-settings-flush-row') as HTMLElement;
fireEvent.click(within(row).getByRole('button', { name: /Copy block/ }));
const dialog = await screen.findByText('Copy "Morning"');
expect(dialog).toBeInTheDocument();
+37 -4
View File
@@ -9,6 +9,7 @@ import {
Eye,
FolderPlus,
Plus,
Search,
Trash2,
TriangleAlert
} from 'lucide-react';
@@ -320,6 +321,7 @@ function BlockList() {
const [deleteGroupTarget, setDeleteGroupTarget] = useState<BlockGroup | null>(null);
const [deleteBlockTarget, setDeleteBlockTarget] = useState<Block | null>(null);
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState('');
const activeRef = useRef(true);
const load = useCallback(() => {
@@ -455,6 +457,26 @@ function BlockList() {
const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name));
const groupOptions = sortedGroups.map((group) => ({ label: group.name, value: String(group.id) }));
// Client-side filter by block name or group name, case-insensitive (parity with Blazor
// Blocks.razor's search box). A group whose own name matches keeps all of its blocks; otherwise
// only its matching blocks are kept, and the group is hidden entirely if none match.
const needle = filter.trim().toLowerCase();
const filteredGroups = sortedGroups
.map((group) => {
const groupBlocks = blocks
.filter((b) => b.groupId === group.id)
.sort((a, b) => a.name.localeCompare(b.name));
if (needle === '') {
return { group, groupBlocks };
}
const groupNameMatches = group.name.toLowerCase().includes(needle);
const matchingBlocks = groupNameMatches
? groupBlocks
: groupBlocks.filter((b) => b.name.toLowerCase().includes(needle));
return groupNameMatches || matchingBlocks.length > 0 ? { group, groupBlocks: matchingBlocks } : null;
})
.filter((entry): entry is { group: BlockGroup; groupBlocks: Block[] } => entry !== null);
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
@@ -472,6 +494,16 @@ function BlockList() {
</Button>
</div>
<div className="ctv-channels-actionbar">
<Input
leadingIcon={<Search aria-hidden="true" size={14} />}
onChange={(event) => setFilter(event.target.value)}
placeholder="Search for blocks…"
value={filter}
/>
<span className="ctv-channels-spacer" />
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
@@ -483,11 +515,12 @@ function BlockList() {
<Card>
<div className="ctv-collections-empty">No block groups yet. Create one to get started.</div>
</Card>
) : filteredGroups.length === 0 ? (
<Card>
<div className="ctv-collections-empty">No blocks match this filter.</div>
</Card>
) : (
sortedGroups.map((group) => {
const groupBlocks = blocks
.filter((b) => b.groupId === group.id)
.sort((a, b) => a.name.localeCompare(b.name));
filteredGroups.map(({ group, groupBlocks }) => {
return (
<Card
key={group.id}
+41 -4
View File
@@ -1,4 +1,4 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TemplatesScreen } from './TemplatesScreen';
@@ -6,8 +6,15 @@ function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
}
const groups = [{ id: 2, name: 'Prime', templateCount: 1 }];
const templates = [{ id: 4, templateGroupId: 2, groupName: 'Prime', name: 'Weekdays' }];
const groups = [
{ id: 2, name: 'Prime', templateCount: 1 },
{ id: 3, name: 'Overnight', templateCount: 1 }
];
const templates = [
{ id: 4, templateGroupId: 2, groupName: 'Prime', name: 'Weekdays' },
{ id: 6, templateGroupId: 2, groupName: 'Prime', name: 'Weekends' },
{ id: 9, templateGroupId: 3, groupName: 'Overnight', name: 'Late Show' }
];
const blockGroups = [{ id: 1, name: 'Morning Blocks' }];
const blocks = [{ id: 10, groupId: 1, groupName: 'Morning Blocks', name: 'Cartoons', minutes: 60, stopScheduling: 'AfterDurationEnd' }];
@@ -80,12 +87,42 @@ describe('TemplatesScreen', () => {
expect(screen.getByText('Weekdays')).toBeInTheDocument();
});
it('filters templates by name, case-insensitive', async () => {
mockApi();
render(<TemplatesScreen />);
await screen.findByText('Weekdays');
expect(screen.getByText('Weekends')).toBeInTheDocument();
expect(screen.getByText('Late Show')).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search for templates…'), { target: { value: 'weekEND' } });
expect(screen.getByText('Weekends')).toBeInTheDocument();
expect(screen.queryByText('Weekdays')).not.toBeInTheDocument();
expect(screen.queryByText('Late Show')).not.toBeInTheDocument();
});
it('filters templates by group name and shows an empty state for no matches', async () => {
mockApi();
render(<TemplatesScreen />);
await screen.findByText('Weekdays');
fireEvent.change(screen.getByPlaceholderText('Search for templates…'), { target: { value: 'overnight' } });
expect(screen.getByText('Late Show')).toBeInTheDocument();
expect(screen.queryByText('Weekdays')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText('Search for templates…'), { target: { value: 'zzz-no-match' } });
expect(await screen.findByText('No templates match this filter.')).toBeInTheDocument();
});
it('opens the copy dialog and POSTs to the copy route', async () => {
mockApi();
render(<TemplatesScreen />);
await screen.findByText('Weekdays');
fireEvent.click(screen.getByRole('button', { name: /Copy template/ }));
const row = screen.getByText('Weekdays').closest('.ctv-settings-flush-row') as HTMLElement;
fireEvent.click(within(row).getByRole('button', { name: /Copy template/ }));
const dialog = await screen.findByText('Copy "Weekdays"');
expect(dialog).toBeInTheDocument();
+39 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, Check, Copy, FolderPlus, Plus, Trash2, TriangleAlert } from 'lucide-react';
import { ArrowLeft, Check, Copy, FolderPlus, Plus, Search, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select, Spinner } from '../components';
import {
@@ -162,6 +162,7 @@ function TemplateList() {
const [deleteGroupTarget, setDeleteGroupTarget] = useState<TemplateGroup | null>(null);
const [deleteTemplateTarget, setDeleteTemplateTarget] = useState<Template | null>(null);
const [busy, setBusy] = useState(false);
const [filter, setFilter] = useState('');
const activeRef = useRef(true);
const load = useCallback(() => {
@@ -300,6 +301,28 @@ function TemplateList() {
const sortedGroups = [...groups].sort((a, b) => a.name.localeCompare(b.name));
const groupOptions = sortedGroups.map((group) => ({ label: group.name, value: String(group.id) }));
// Client-side filter by template name or group name, case-insensitive (parity with Blazor
// Templates.razor's search box). A group whose own name matches keeps all of its templates;
// otherwise only its matching templates are kept, and the group is hidden entirely if none match.
const needle = filter.trim().toLowerCase();
const filteredGroups = sortedGroups
.map((group) => {
const groupTemplates = templates
.filter((t) => t.templateGroupId === group.id && t.id > 0)
.sort((a, b) => a.name.localeCompare(b.name));
if (needle === '') {
return { group, groupTemplates };
}
const groupNameMatches = group.name.toLowerCase().includes(needle);
const matchingTemplates = groupNameMatches
? groupTemplates
: groupTemplates.filter((t) => t.name.toLowerCase().includes(needle));
return groupNameMatches || matchingTemplates.length > 0
? { group, groupTemplates: matchingTemplates }
: null;
})
.filter((entry): entry is { group: TemplateGroup; groupTemplates: Template[] } => entry !== null);
return (
<div className="ctv-collections">
<div className="ctv-channels-actionbar">
@@ -317,6 +340,16 @@ function TemplateList() {
</Button>
</div>
<div className="ctv-channels-actionbar">
<Input
leadingIcon={<Search aria-hidden="true" size={14} />}
onChange={(event) => setFilter(event.target.value)}
placeholder="Search for templates…"
value={filter}
/>
<span className="ctv-channels-spacer" />
</div>
{error && (
<div className="ctv-channels-error" role="alert">
<TriangleAlert aria-hidden="true" size={15} />
@@ -328,11 +361,12 @@ function TemplateList() {
<Card>
<div className="ctv-collections-empty">No template groups yet. Create one to get started.</div>
</Card>
) : filteredGroups.length === 0 ? (
<Card>
<div className="ctv-collections-empty">No templates match this filter.</div>
</Card>
) : (
sortedGroups.map((group) => {
const groupTemplates = templates
.filter((t) => t.templateGroupId === group.id && t.id > 0)
.sort((a, b) => a.name.localeCompare(b.name));
filteredGroups.map(({ group, groupTemplates }) => {
return (
<Card
key={group.id}