Update DVR to not require group selection, use new channel summary endpoint.

This commit is contained in:
SergeantPanda 2026-02-18 14:38:47 -06:00
parent 8427ae0a2d
commit c68d73fbdb
3 changed files with 78 additions and 133 deletions

View file

@ -88,13 +88,11 @@ const RecordingModal = ({
isOpen,
onClose,
}) => {
const channelGroups = useChannelsStore((s) => s.channelGroups);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
// Local state: selected group and channels for that group
const [selectedGroupId, setSelectedGroupId] = useState(null);
const [localChannels, setLocalChannels] = useState([]);
// All channels loaded via lightweight summary API
const [allChannels, setAllChannels] = useState([]);
const [isChannelsLoading, setIsChannelsLoading] = useState(false);
const [mode, setMode] = useState('single');
@ -216,36 +214,19 @@ const RecordingModal = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, recording, channel]);
// Initialize group selection to the first available group when modal opens
useEffect(() => {
if (!isOpen) return;
if (selectedGroupId) return;
const firstGroup = Object.values(channelGroups || {}).find(
(g) => g?.hasChannels
);
if (firstGroup) {
setSelectedGroupId(String(firstGroup.id));
}
}, [isOpen, selectedGroupId, channelGroups]);
// Fetch channels for selected group (no global load)
// Load all channels via lightweight summary API when modal opens
useEffect(() => {
let cancelled = false;
const run = async () => {
if (!isOpen) return;
if (!selectedGroupId) return;
const group = channelGroups?.[Number(selectedGroupId)];
if (!group?.name) return;
try {
setIsChannelsLoading(true);
const params = new URLSearchParams();
params.set('channel_group', group.name);
const chans = await API.getChannelsForParams(params);
const chans = await API.getChannelsSummary();
if (cancelled) return;
setLocalChannels(Array.isArray(chans) ? chans : []);
setAllChannels(Array.isArray(chans) ? chans : []);
} catch (e) {
console.warn('Failed to load channels for group', group?.name, e);
if (!cancelled) setLocalChannels([]);
console.warn('Failed to load channels for recording form', e);
if (!cancelled) setAllChannels([]);
} finally {
if (!cancelled) setIsChannelsLoading(false);
}
@ -254,41 +235,10 @@ const RecordingModal = ({
return () => {
cancelled = true;
};
}, [isOpen, selectedGroupId, channelGroups]);
// When group changes, clear selected channel in both forms
useEffect(() => {
if (!isOpen) return;
singleForm.setFieldValue('channel_id', '');
recurringForm.setFieldValue('channel_id', '');
}, [isOpen, selectedGroupId]);
// After channels load for a group, auto-select the first channel
useEffect(() => {
if (!isOpen) {
return;
}
if (!selectedGroupId) {
return;
}
const first = Array.isArray(localChannels) ? localChannels[0] : null;
if (!first) {
return;
}
if (mode === 'single') {
if (!singleForm.values.channel_id) {
singleForm.setFieldValue('channel_id', `${first.id}`);
}
} else {
if (!recurringForm.values.channel_id) {
recurringForm.setFieldValue('channel_id', `${first.id}`);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, selectedGroupId, localChannels, mode]);
}, [isOpen]);
const channelOptions = useMemo(() => {
const list = Array.isArray(localChannels) ? [...localChannels] : [];
const list = Array.isArray(allChannels) ? [...allChannels] : [];
list.sort((a, b) => {
const aNum = Number(a.channel_number) || 0;
const bNum = Number(b.channel_number) || 0;
@ -297,18 +247,11 @@ const RecordingModal = ({
});
return list.map((item) => ({
value: `${item.id}`,
label: item.name || `Channel ${item.id}`,
label: item.channel_number
? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
: item.name || `Channel ${item.id}`,
}));
}, [localChannels]);
// Group options (no "All")
const groupOptions = useMemo(() => {
const arr = Object.values(channelGroups || {}).filter(
(g) => g?.hasChannels
);
arr.sort((a, b) => (a?.name || '').localeCompare(b?.name || ''));
return arr.map((g) => ({ value: String(g.id), label: g.name }));
}, [channelGroups]);
}, [allChannels]);
const resetForms = () => {
singleForm.reset();
@ -419,20 +362,10 @@ const RecordingModal = ({
<form onSubmit={onSubmit}>
<Stack gap="md">
{/* Group selection (required, no "All") */}
<Select
label="Group"
placeholder="Select group"
data={groupOptions}
value={selectedGroupId}
onChange={(value) => setSelectedGroupId(value)}
searchable
/>
{mode === 'single' ? (
<Select
{...singleForm.getInputProps('channel_id')}
key={`${singleForm.key('channel_id')}-${selectedGroupId || 'none'}`}
key={singleForm.key('channel_id')}
label="Channel"
placeholder="Select channel"
searchable
@ -445,7 +378,7 @@ const RecordingModal = ({
) : (
<Select
{...recurringForm.getInputProps('channel_id')}
key={`${recurringForm.key('channel_id')}-${selectedGroupId || 'none'}`}
key={recurringForm.key('channel_id')}
label="Channel"
placeholder="Select channel"
searchable

View file

@ -52,7 +52,6 @@ const DVRPage = () => {
const recordings = useChannelsStore((s) => s.recordings);
const fetchRecordings = useChannelsStore((s) => s.fetchRecordings);
const fetchRecurringRules = useChannelsStore((s) => s.fetchRecurringRules);
const channelIds = useChannelsStore((s) => s.channelIds);
const [channelsById, setChannelsById] = useState({});
const { toUserTime, userNow } = useTimeHelpers();
@ -95,29 +94,16 @@ const DVRPage = () => {
fetchRecurringRules();
}, [fetchRecordings, fetchRecurringRules]);
// When recordings change, ensure we have local channel details on demand
// Load channel details for recordings via lightweight summary API
useEffect(() => {
const neededIds = new Set();
for (const rec of Array.isArray(recordings) ? recordings : []) {
if (rec?.channel) neededIds.add(rec.channel);
}
const missing = Array.from(neededIds).filter(
(id) => !channelsById[id]
);
if (missing.length === 0) return;
let cancelled = false;
(async () => {
try {
const fetched = await Promise.all(
missing.map((id) => API.getChannel(id))
);
const channels = await API.getChannelsSummary();
if (cancelled) return;
setChannelsById((prev) => {
const next = { ...prev };
for (const ch of fetched) if (ch?.id) next[ch.id] = ch;
return next;
});
const byId = {};
for (const ch of channels) if (ch?.id) byId[ch.id] = ch;
setChannelsById(byId);
} catch (e) {
console.warn('Failed to fetch channels for DVR page', e);
}
@ -125,7 +111,7 @@ const DVRPage = () => {
return () => {
cancelled = true;
};
}, [recordings, channelsById]);
}, []);
// Re-render every second so time-based bucketing updates without a refresh
const [now, setNow] = useState(userNow());

View file

@ -1,5 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import {
render,
screen,
fireEvent,
waitFor,
act,
} from '@testing-library/react';
import DVRPage from '../DVR';
import dayjs from 'dayjs';
import useChannelsStore from '../../store/channels';
@ -171,8 +177,8 @@ describe('DVRPage', () => {
const now = new Date('2024-01-15T12:00:00Z');
vi.setSystemTime(now);
// Default: API.getChannel returns null (no channel data loaded by default)
API.getChannel.mockResolvedValue(null);
// Default: API.getChannelsSummary returns empty array
API.getChannelsSummary.mockResolvedValue([]);
isAfter.mockImplementation((a, b) => new Date(a) > new Date(b));
isBefore.mockImplementation((a, b) => new Date(a) < new Date(b));
@ -228,21 +234,25 @@ describe('DVRPage', () => {
});
describe('Initial Render', () => {
it('renders new recording buttons', () => {
render(<DVRPage />);
it('renders new recording buttons', async () => {
await act(async () => {
render(<DVRPage />);
});
expect(screen.getByText('New Recording')).toBeInTheDocument();
});
it('renders empty state when no recordings', () => {
render(<DVRPage />);
it('renders empty state when no recordings', async () => {
await act(async () => {
render(<DVRPage />);
});
expect(screen.getByText('No upcoming recordings.')).toBeInTheDocument();
});
});
describe('Recording Display', () => {
it('displays recordings grouped by date', () => {
it('displays recordings grouped by date', async () => {
const now = dayjs('2024-01-15T12:00:00Z');
const recordings = [
{
@ -266,7 +276,9 @@ describe('DVRPage', () => {
return selector ? selector(state) : state;
});
render(<DVRPage />);
await act(async () => {
render(<DVRPage />);
});
expect(screen.getByTestId('recording-card-1')).toBeInTheDocument();
expect(screen.getByTestId('recording-card-2')).toBeInTheDocument();
@ -275,24 +287,34 @@ describe('DVRPage', () => {
describe('New Recording', () => {
it('opens recording form when new recording button is clicked', async () => {
render(<DVRPage />);
await act(async () => {
render(<DVRPage />);
});
const newButton = screen.getByText('New Recording');
fireEvent.click(newButton);
act(() => {
fireEvent.click(newButton);
});
expect(screen.getByTestId('recording-form')).toBeInTheDocument();
});
it('closes recording form when close is clicked', async () => {
render(<DVRPage />);
await act(async () => {
render(<DVRPage />);
});
const newButton = screen.getByText('New Recording');
fireEvent.click(newButton);
act(() => {
fireEvent.click(newButton);
});
expect(screen.getByTestId('recording-form')).toBeInTheDocument();
const closeButton = screen.getByText('Close Form');
fireEvent.click(closeButton);
act(() => {
fireEvent.click(closeButton);
});
expect(screen.queryByTestId('recording-form')).not.toBeInTheDocument();
});
@ -395,10 +417,13 @@ describe('DVRPage', () => {
return selector ? selector(state) : state;
});
render(<DVRPage />);
await act(async () => {
render(<DVRPage />);
});
const recurringButton = screen.getByText('Open Recurring');
fireEvent.click(recurringButton);
act(() => {
fireEvent.click(screen.getByText('Open Recurring'));
});
expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
expect(screen.getByText('Rule ID: 100')).toBeInTheDocument();
@ -426,15 +451,19 @@ describe('DVRPage', () => {
return selector ? selector(state) : state;
});
render(<DVRPage />);
await act(async () => {
render(<DVRPage />);
});
const recurringButton = screen.getByText('Open Recurring');
fireEvent.click(recurringButton);
act(() => {
fireEvent.click(screen.getByText('Open Recurring'));
});
expect(screen.getByTestId('recurring-modal')).toBeInTheDocument();
const closeButton = screen.getByText('Close Recurring');
fireEvent.click(closeButton);
act(() => {
fireEvent.click(screen.getByText('Close Recurring'));
});
expect(screen.queryByTestId('recurring-modal')).not.toBeInTheDocument();
});
@ -453,14 +482,11 @@ describe('DVRPage', () => {
custom_properties: { Title: 'Live Show' },
};
// DVR.jsx now lazy-loads channel data via API.getChannel rather than
// reading s.channels from the store. Mock the API so channelsById
// gets populated before the Watch Live handler runs.
API.getChannel.mockResolvedValue({
id: 1,
name: 'Channel 1',
stream_url: 'http://stream.url',
});
// DVR.jsx loads all channel data via getChannelsSummary.
// Mock the API so channelsById gets populated before the handler runs.
API.getChannelsSummary.mockResolvedValue([
{ id: 1, name: 'Channel 1', stream_url: 'http://stream.url' },
]);
useChannelsStore.mockImplementation((selector) => {
const state = {
@ -479,7 +505,7 @@ describe('DVRPage', () => {
// Wait for channelsById to be populated from the async API call
await waitFor(() => {
expect(API.getChannel).toHaveBeenCalledWith(1);
expect(API.getChannelsSummary).toHaveBeenCalled();
});
const watchLiveButton = screen.getByText('Watch Live');