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
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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user