diff --git a/frontend/src/components/forms/AutoSyncAdvanced.jsx b/frontend/src/components/forms/AutoSyncAdvanced.jsx new file mode 100644 index 00000000..abe101fd --- /dev/null +++ b/frontend/src/components/forms/AutoSyncAdvanced.jsx @@ -0,0 +1,756 @@ +// Shared preview box for include and exclude filters. The marker and +// color reflect whether matched names are kept (teal check) or dropped +// (red x); empty/loading/error states mirror the find preview. +import { + Box, + Button, + Center, + Checkbox, + Divider, + Flex, + Group, + MultiSelect, + Popover, + PopoverDropdown, + PopoverTarget, + ScrollArea, + Select, + Stack, + Switch, + Text, + TextInput, + Tooltip, +} from '@mantine/core'; +import { getRegexOptions } from '../../utils/forms/LiveGroupFilterUtils.js'; +import React from 'react'; +import useChannelsStore from '../../store/channels.jsx'; +import { FixedSizeList as List } from 'react-window'; +import logo from '../../images/logo.png'; +import LazyLogo from '../LazyLogo.jsx'; +import { RefreshCw } from 'lucide-react'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + formatPreviewSummary, + getEpgSourceData, + getEpgSourceValue, + repackGroupChannels, +} from '../../utils/forms/AutoSyncAdvancedUtils.js'; + +const RegexPreviewBox = ({ group, kind, regexPreviewState }) => { + const pattern = + kind === 'exclude' + ? group.custom_properties?.name_match_exclude_regex || '' + : kind === 'include' + ? group.custom_properties?.name_match_regex || '' + : group.custom_properties?.name_regex_pattern || ''; + if (!pattern) return null; + const state = regexPreviewState[group.channel_group] || {}; + const result = + kind === 'exclude' + ? state.excludeResult + : kind === 'include' + ? state.filterResult + : state.findResult; + const loading = state.loading; + const summaryLabel = + kind === 'exclude' ? 'exclude' : kind === 'include' ? 'filter' : 'rename'; + const placeholderLabel = + kind === 'exclude' + ? 'Exclude preview' + : kind === 'include' + ? 'Filter preview' + : 'Preview'; + const markerChar = kind === 'exclude' ? '✗' : '✓'; + const markerColor = kind === 'exclude' ? 'red.4' : 'teal.4'; + const emptyText = + kind === 'exclude' + ? 'No streams matched this pattern (nothing would be excluded).' + : 'No streams matched this pattern.'; + return ( + + + {result ? formatPreviewSummary(summaryLabel, result) : placeholderLabel} + + {result?.error && ( + + Invalid regex: {result.error} + + )} + {loading && !result && ( + + Scanning streams... + + )} + {result && !result.error && result.matches?.length === 0 && ( + + {result.total_in_group === 0 + ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' + : emptyText} + + )} + {result?.matches?.map((row, idx) => + kind === 'find' ? ( + + + {row.before} + + + {' -> '} + + + {row.after} + + + ) : ( + + + {markerChar} + + + {row.name} + + + ) + )} + + ); +}; + +// Advanced Options form rendered inside the gear modal. A field's +// presence in custom_properties activates it; blanking returns the +// group to default behavior. +const AutoSyncAdvanced = ({ + group, + epgSources, + channelGroups, + streamProfiles, + regexPreviewState, + onApplyGroupChange, + onScheduleRegexPreview, + onOpenLogoUpload, + channelLogos, + playlist, + logosLoading, + ensureLogosLoaded, +}) => { + const cp = group.custom_properties || {}; + const profiles = useChannelsStore((s) => s.profiles); + + const setCp = (patch, clears = []) => { + const next = { ...cp, ...patch }; + clears.forEach((k) => delete next[k]); + onApplyGroupChange({ ...group, custom_properties: next }); + }; + + // --- Name Transforms --- + + const findValue = cp.name_regex_pattern ?? ''; + const replaceValue = cp.name_replace_pattern ?? ''; + const filterValue = cp.name_match_regex ?? ''; + const excludeValue = cp.name_match_exclude_regex ?? ''; + const updateFind = (val) => { + if (!val && !replaceValue) { + setCp({}, ['name_regex_pattern', 'name_replace_pattern']); + } else { + setCp({ + name_regex_pattern: val, + name_replace_pattern: replaceValue, + }); + } + onScheduleRegexPreview( + group, + getRegexOptions(val, replaceValue, filterValue, excludeValue) + ); + }; + const updateReplace = (val) => { + if (!val && !findValue) { + setCp({}, ['name_regex_pattern', 'name_replace_pattern']); + } else { + setCp({ + name_regex_pattern: findValue, + name_replace_pattern: val, + }); + } + onScheduleRegexPreview( + group, + getRegexOptions(findValue, val, filterValue, excludeValue) + ); + }; + const updateFilter = (val) => { + if (!val) setCp({}, ['name_match_regex']); + else setCp({ name_match_regex: val }); + onScheduleRegexPreview( + group, + getRegexOptions(findValue, replaceValue, val, excludeValue) + ); + }; + const updateExclude = (val) => { + if (!val) setCp({}, ['name_match_exclude_regex']); + else setCp({ name_match_exclude_regex: val }); + onScheduleRegexPreview( + group, + getRegexOptions(findValue, replaceValue, filterValue, val) + ); + }; + + // --- EPG --- + + const epgValue = getEpgSourceValue(cp); + const updateEpg = (value) => { + const next = { ...cp }; + delete next.custom_epg_id; + delete next.force_dummy_epg; + delete next.force_epg_selected; + if (value === '0') { + next.force_dummy_epg = true; + } else if (value) { + next.custom_epg_id = parseInt(value); + } + onApplyGroupChange({ ...group, custom_properties: next }); + }; + + // --- Channel Assignment --- + + const groupOverrideValue = cp.group_override + ? cp.group_override.toString() + : ''; + const updateGroupOverride = (value) => { + if (!value) setCp({}, ['group_override']); + else setCp({ group_override: parseInt(value) }); + }; + + const profileValue = cp.channel_profile_ids ?? []; + const updateProfiles = (value) => { + if (!value || value.length === 0) { + setCp({}, ['channel_profile_ids']); + } else { + setCp({ channel_profile_ids: value }); + } + }; + + const streamProfileValue = cp.stream_profile_id + ? cp.stream_profile_id.toString() + : ''; + const updateStreamProfile = (value) => { + if (!value) setCp({}, ['stream_profile_id']); + else setCp({ stream_profile_id: parseInt(value) }); + }; + + const sortOrderValue = cp.channel_sort_order ?? '__default__'; + const sortReverseEnabled = cp.channel_sort_order !== undefined; + const updateSortOrder = (value) => { + if (!value || value === '__default__') { + setCp({}, ['channel_sort_order', 'channel_sort_reverse']); + } else { + setCp({ + channel_sort_order: value, + channel_sort_reverse: cp.channel_sort_reverse ?? false, + }); + } + }; + const updateSortReverse = (checked) => { + setCp({ channel_sort_reverse: checked }); + }; + + // --- Custom Logo --- + + const logoValue = cp.custom_logo_id; + + return ( + + + + Name Transforms + + } + labelPosition="left" + size="sm" + color="gray.6" + /> + + + + updateFind(e.currentTarget.value)} + size="xs" + style={{ flex: 1 }} + /> + updateReplace(e.currentTarget.value)} + size="xs" + style={{ flex: 1 }} + /> + + {findValue && ( + + )} + + + + + + updateFilter(e.currentTarget.value)} + size="xs" + /> + {filterValue && ( + + )} + + + + + updateExclude(e.currentTarget.value)} + size="xs" + /> + {excludeValue && ( + + )} + + + + + + + + EPG & Logo + + } + labelPosition="left" + size="sm" + color="gray.6" + /> + + + ({ + value: g.id.toString(), + label: g.name, + }))} + clearable + searchable + size="xs" + style={{ flex: 1 }} + /> + + + ({ + value: profile.id.toString(), + label: profile.name, + }))} + clearable + searchable + size="xs" + style={{ flex: 1 }} + /> + + + + + + {sortReverseEnabled && ( + + updateSortReverse(event.currentTarget.checked) + } + size="xs" + mt="xs" + /> + )} + + + + + + + { + if (event.currentTarget.checked) { + setCp({ compact_numbering: true }); + } else { + setCp({}, ['compact_numbering']); + } + }} + size="xs" + /> + + + + + + + + + ); +}; + +export default AutoSyncAdvanced; diff --git a/frontend/src/components/forms/AutoSyncBasic.jsx b/frontend/src/components/forms/AutoSyncBasic.jsx new file mode 100644 index 00000000..6cab8ea7 --- /dev/null +++ b/frontend/src/components/forms/AutoSyncBasic.jsx @@ -0,0 +1,181 @@ +// Inline Start/End range inputs for Fixed and Provider modes. Start +// writes to auto_sync_channel_start (Fixed) or +// custom_properties.channel_numbering_fallback (Provider); End always +// writes to auto_sync_channel_end. Next Available shows a one-line +// explanation because a range contradicts pack-anywhere semantics. +import { Flex, NumberInput, Stack, Text, Tooltip } from '@mantine/core'; +import { AlertTriangle } from 'lucide-react'; +import React from 'react'; +import { + clampChannelNumber, + computeRangeOverlapsFor, +} from '../../utils/forms/AutoSyncBasicUtils.js'; + +const AutoSyncBasic = ({ + group, + groupStates, + groupConflicts, + onApplyGroupChange, +}) => { + const mode = group.custom_properties?.channel_numbering_mode || 'fixed'; + if (mode === 'next_available') { + return ( + + Channels receive the lowest available numbers starting at 1. + + ); + } + const startValue = + mode === 'provider' + ? (group.custom_properties?.channel_numbering_fallback ?? 1) + : (group.auto_sync_channel_start ?? 1); + const endValue = group.auto_sync_channel_end; + // Caps pathological pasted values like "1e308" at the input layer. + const updateStart = (value) => { + const normalized = + value === '' || value === null || value === undefined + ? 1 + : clampChannelNumber(value); + if (mode === 'provider') { + onApplyGroupChange({ + ...group, + custom_properties: { + ...(group.custom_properties || {}), + channel_numbering_fallback: normalized, + }, + }); + } else { + // If End is set and the new Start exceeds it, drop End so the user + // is not left holding an invalid range silently. + const next = { ...group, auto_sync_channel_start: normalized }; + if ( + endValue !== null && + endValue !== undefined && + normalized > endValue + ) { + next.auto_sync_channel_end = null; + } + onApplyGroupChange(next); + } + // Sweep effect picks up the state change and dispatches the scan. + }; + const updateEnd = (value) => { + const normalized = + value === '' || value === null || value === undefined + ? null + : clampChannelNumber(value); + onApplyGroupChange({ + ...group, + auto_sync_channel_end: normalized, + }); + }; + + const streamCount = + typeof group.stream_count === 'number' ? group.stream_count : null; + const metaParts = []; + if (endValue) { + metaParts.push(`Range: ${Math.max(endValue - startValue + 1, 0)}`); + } + if (streamCount !== null) { + metaParts.push(`Streams: ${streamCount}`); + } + const metaText = metaParts.join(' · '); + + const conflict = groupConflicts[group.channel_group]; + const hasChannelConflict = !!conflict?.hasChannelConflict; + const overlaps = computeRangeOverlapsFor(group, groupStates); + + // Channel-level conflicts get a generic Channels-page pointer (count + // can be large); range-level overlaps stay specific to the modal. + const tooltipSections = []; + if (hasChannelConflict) { + tooltipSections.push( + 'Range conflicts with configured channels.\nView the Channels page to inspect.' + ); + } + if (overlaps.length > 0) { + const overlapLines = overlaps + .map((o) => `${o.name} (${o.start}-${o.end})`) + .join('\n '); + tooltipSections.push( + overlaps.length === 1 + ? `Range overlaps with: ${overlaps[0].name} (${overlaps[0].start}-${overlaps[0].end})` + : `Range overlaps with:\n ${overlapLines}` + ); + } + const showWarning = tooltipSections.length > 0; + const tooltipBody = tooltipSections.join('\n\n'); + + return ( + + + + + + + + + + + + {metaText} + + {showWarning && ( + + + + )} + + + ); +}; + +export default AutoSyncBasic; diff --git a/frontend/src/components/forms/AutoSyncOrphanCleanup.jsx b/frontend/src/components/forms/AutoSyncOrphanCleanup.jsx new file mode 100644 index 00000000..52150e30 --- /dev/null +++ b/frontend/src/components/forms/AutoSyncOrphanCleanup.jsx @@ -0,0 +1,77 @@ +import React, { useEffect, useState } from 'react'; +import { updatePlaylist } from '../../utils/forms/M3uUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { Box, Group, SegmentedControl, Text, Tooltip } from '@mantine/core'; + +const OrphanCleanupControl = ({ playlist }) => { + // Local state mirrors the persisted mode so the SegmentedControl + // reflects clicks immediately even when the parent's playlist prop is + // a stale snapshot from before the PATCH lands. + const [orphanCleanupMode, setOrphanCleanupMode] = useState( + (playlist?.custom_properties || {}).orphan_channel_cleanup || 'always' + ); + + useEffect(() => { + setOrphanCleanupMode( + (playlist?.custom_properties || {}).orphan_channel_cleanup || 'always' + ); + }, [playlist?.id, playlist?.custom_properties?.orphan_channel_cleanup]); + + const handleOrphanCleanupChange = async (mode) => { + if (!playlist?.id) return; + const previousMode = orphanCleanupMode; + setOrphanCleanupMode(mode); + const nextProps = { + ...(playlist.custom_properties || {}), + orphan_channel_cleanup: mode, + }; + try { + await updatePlaylist(playlist, { custom_properties: nextProps }); + } catch (err) { + setOrphanCleanupMode(previousMode); + showNotification({ + title: 'Failed to update cleanup mode', + message: err?.body?.detail || err?.message || 'Please try again.', + color: 'red', + }); + } + }; + + return ( + + + + + Auto-sync orphan cleanup + + + + + + {orphanCleanupMode === 'always' && + 'Removes any auto-synced channel whose source stream is gone from this provider.'} + {orphanCleanupMode === 'preserve_customized' && + 'Removes orphaned auto-synced channels except those with active overrides.'} + {orphanCleanupMode === 'never' && + 'Keeps all orphaned auto-synced channels. You can clean up manually from the channels page.'} + + + ); +}; + +export default OrphanCleanupControl; diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 90b95a42..ee8c4e91 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -931,7 +931,10 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => { label="Hidden" checked={watch('hidden_from_output')} onChange={(event) => - setValue('hidden_from_output', event.currentTarget.checked) + setValue( + 'hidden_from_output', + event.currentTarget.checked + ) } size="md" /> diff --git a/frontend/src/components/forms/ChannelGroup.jsx b/frontend/src/components/forms/ChannelGroup.jsx index 5e5ddfd3..101928af 100644 --- a/frontend/src/components/forms/ChannelGroup.jsx +++ b/frontend/src/components/forms/ChannelGroup.jsx @@ -1,20 +1,13 @@ // Modal.js import React from 'react'; -import API from '../../api'; -import { Flex, TextInput, Button, Modal, Alert } from '@mantine/core'; +import { Alert, Button, Flex, Modal, TextInput } from '@mantine/core'; import { isNotEmpty, useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; import { showNotification } from '../../utils/notificationUtils.js'; - -const updateChannelGroup = (channelGroup, values) => { - return API.updateChannelGroup({ - id: channelGroup.id, - ...values, - }); -}; -const addChannelGroup = (values) => { - return API.addChannelGroup(values); -}; +import { + addChannelGroup, + updateChannelGroup, +} from '../../utils/forms/ChannelGroupUtils.js'; const ChannelGroup = ({ channelGroup = null, isOpen, onClose }) => { const canEditChannelGroup = useChannelsStore((s) => s.canEditChannelGroup); diff --git a/frontend/src/components/forms/GroupManager.jsx b/frontend/src/components/forms/GroupManager.jsx index 453d9494..22694406 100644 --- a/frontend/src/components/forms/GroupManager.jsx +++ b/frontend/src/components/forms/GroupManager.jsx @@ -1,44 +1,49 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - Modal, - Stack, + ActionIcon, + Alert, + Badge, + Box, + Button, + Chip, + Divider, + Flex, Group, + Modal, + ScrollArea, + Stack, Text, TextInput, - Button, - ActionIcon, - Flex, - Badge, - Alert, - Divider, - ScrollArea, useMantineTheme, - Chip, - Box, } from '@mantine/core'; import { - SquarePlus, - SquarePen, - SquareMinus, - Check, - X, AlertCircle, + Check, Database, - Tv, - Trash, Filter, + SquareMinus, + SquarePen, + SquarePlus, + Trash, + Tv, + X, } from 'lucide-react'; -import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../store/channels'; import useWarningsStore from '../../store/warnings'; import ConfirmationDialog from '../ConfirmationDialog'; -import API from '../../api'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + addChannelGroup, + cleanupUnusedChannelGroups, + deleteChannelGroup, + updateChannelGroup, +} from '../../utils/forms/ChannelGroupUtils.js'; // Move GroupItem outside to prevent recreation on every render const GroupItem = React.memo( ({ group, - editingGroup, + editingGroupId, editName, onEditNameChange, onSaveEdit, @@ -90,7 +95,7 @@ const GroupItem = React.memo( border: '1px solid #444', borderRadius: '8px', backgroundColor: - editingGroup === group.id + editingGroupId === group.id ? '#3f3f46' : group.enabled ? '#2A2A2E' @@ -101,7 +106,7 @@ const GroupItem = React.memo( > - {editingGroup === group.id ? ( + {editingGroupId === group.id ? ( - {editingGroup === group.id ? ( + {editingGroupId === group.id ? ( <> @@ -167,7 +172,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); const suppressWarning = useWarningsStore((s) => s.suppressWarning); - const [editingGroup, setEditingGroup] = useState(null); + const [editingGroupId, setEditingGroupId] = useState(null); const [editName, setEditName] = useState(''); const [newGroupName, setNewGroupName] = useState(''); const [isCreating, setIsCreating] = useState(false); @@ -197,83 +202,44 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { [channelGroupsArray] ); - // Filter groups based on search term and chip filters - const filteredGroups = useMemo(() => { - let filtered = sortedGroups; + const { filteredGroups, filterCounts } = useMemo(() => { + const counts = { channels: 0, m3u: 0, unused: 0 }; + const filtered = []; - // Apply search filter - if (searchTerm.trim()) { - filtered = filtered.filter((group) => - group.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); - } - - // Apply chip filters - filtered = filtered.filter((group) => { + for (const group of sortedGroups) { const usage = groupUsage[group.id]; - if (!usage) return false; + if (!usage) continue; - const hasChannels = usage.hasChannels; - const hasM3U = usage.hasM3UAccounts; + const { hasChannels, hasM3UAccounts: hasM3U } = usage; const isUnused = !hasChannels && !hasM3U; - // If group is unused, only show if unused groups are enabled - if (isUnused) { - return showUnusedGroups; + if (hasChannels) counts.channels++; + if (hasM3U) counts.m3u++; + if (isUnused) counts.unused++; + + const matchesSearch = + !searchTerm.trim() || + group.name.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesFilter = isUnused + ? showUnusedGroups + : (hasChannels && showChannelGroups) || (hasM3U && showM3UGroups); + + if (matchesSearch && matchesFilter) { + filtered.push(group); } + } - // For groups with channels and/or M3U, show if either filter is enabled - let shouldShow = false; - if (hasChannels && showChannelGroups) shouldShow = true; - if (hasM3U && showM3UGroups) shouldShow = true; - - return shouldShow; - }); - - return filtered; + return { filteredGroups: filtered, filterCounts: counts }; }, [ sortedGroups, + groupUsage, searchTerm, showChannelGroups, showM3UGroups, showUnusedGroups, - groupUsage, ]); - // Calculate filter counts - const filterCounts = useMemo(() => { - const counts = { - channels: 0, - m3u: 0, - unused: 0, - }; - - sortedGroups.forEach((group) => { - const usage = groupUsage[group.id]; - if (usage) { - const hasChannels = usage.hasChannels; - const hasM3U = usage.hasM3UAccounts; - - // Count groups with channels (including those with both) - if (hasChannels) { - counts.channels++; - } - - // Count groups with M3U (including those with both) - if (hasM3U) { - counts.m3u++; - } - - // Count truly unused groups - if (!hasChannels && !hasM3U) { - counts.unused++; - } - } - }); - - return counts; - }, [sortedGroups, groupUsage]); - const fetchGroupUsage = useCallback(async () => { setLoading(true); try { @@ -305,13 +271,13 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { }, [isOpen, fetchGroupUsage]); const handleEdit = useCallback((group) => { - setEditingGroup(group.id); + setEditingGroupId(group.id); setEditName(group.name); }, []); const handleSaveEdit = useCallback(async () => { if (!editName.trim()) { - notifications.show({ + showNotification({ title: 'Error', message: 'Group name cannot be empty', color: 'red', @@ -320,37 +286,36 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } try { - await API.updateChannelGroup({ - id: editingGroup, + await updateChannelGroup(channelGroups[editingGroupId], { name: editName.trim(), }); - notifications.show({ + showNotification({ title: 'Success', message: 'Group updated successfully', color: 'green', }); - setEditingGroup(null); + setEditingGroupId(null); setEditName(''); await fetchGroupUsage(); // Refresh usage data } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: 'Failed to update group', color: 'red', }); } - }, [editName, editingGroup, fetchGroupUsage]); + }, [editName, editingGroupId, fetchGroupUsage]); const handleCancelEdit = useCallback(() => { - setEditingGroup(null); + setEditingGroupId(null); setEditName(''); }, []); const handleCreate = useCallback(async () => { if (!newGroupName.trim()) { - notifications.show({ + showNotification({ title: 'Error', message: 'Group name cannot be empty', color: 'red', @@ -359,11 +324,11 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { } try { - await API.addChannelGroup({ + await addChannelGroup({ name: newGroupName.trim(), }); - notifications.show({ + showNotification({ title: 'Success', message: 'Group created successfully', color: 'green', @@ -373,7 +338,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { setIsCreating(false); await fetchGroupUsage(); // Refresh usage data } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: 'Failed to create group', color: 'red', @@ -385,9 +350,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { async (group) => { setDeletingGroup(true); try { - await API.deleteChannelGroup(group.id); + await deleteChannelGroup(group); - notifications.show({ + showNotification({ title: 'Success', message: 'Group deleted successfully', color: 'green', @@ -395,7 +360,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { await fetchGroupUsage(); // Refresh usage data } catch (error) { - notifications.show({ + showNotification({ title: 'Error', message: 'Failed to delete group', color: 'red', @@ -416,7 +381,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { usage && (!usage.canDelete || usage.hasChannels || usage.hasM3UAccounts) ) { - notifications.show({ + showNotification({ title: 'Cannot Delete', message: 'This group is associated with channels or M3U accounts and cannot be deleted', @@ -441,9 +406,9 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { const executeCleanup = useCallback(async () => { setIsCleaningUp(true); try { - const result = await API.cleanupUnusedChannelGroups(); + const result = await cleanupUnusedChannelGroups(); - notifications.show({ + showNotification({ title: 'Cleanup Complete', message: `Successfully deleted ${result.deleted_count} unused groups`, color: 'green', @@ -452,7 +417,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { await fetchGroupUsage(); // Refresh usage data setConfirmCleanupOpen(false); } catch (error) { - notifications.show({ + showNotification({ title: 'Cleanup Failed', message: 'Failed to cleanup unused groups', color: 'red', @@ -653,7 +618,7 @@ const GroupManager = React.memo(({ isOpen, onClose }) => { import('./AutoSyncAdvanced.jsx')); +const LogoForm = React.lazy(() => import('./Logo.jsx')); +import { + abortTimers, + computeAutoSyncStart, + getChannelsInRange, + getEPGs, + getRegexOptions, + getStreamsRegexPreview, + isExpectedOccupantForGroup, + isGroupVisible, + rangeFor, +} from '../../utils/forms/LiveGroupFilterUtils.js'; const LiveGroupFilter = ({ playlist, @@ -50,7 +46,6 @@ const LiveGroupFilter = ({ setAutoEnableNewGroupsLive, }) => { const channelGroups = useChannelsStore((s) => s.channelGroups); - const profiles = useChannelsStore((s) => s.profiles); const streamProfiles = useStreamProfilesStore((s) => s.profiles); const fetchStreamProfiles = useStreamProfilesStore((s) => s.fetchProfiles); const [groupFilter, setGroupFilter] = useState(''); @@ -107,31 +102,6 @@ const LiveGroupFilter = ({ ); }; - // "Expected" occupants are this group's own auto-sync output: - // auto_created, in this group on this account, no channel_number - // override. Channels from any other provider, group, or with a user - // pin all surface as a warning so the user is aware their range - // overlaps with existing assignments. Sync still merges shared - // ranges across providers, so the warning is informational rather - // than blocking. - const isExpectedOccupantForGroup = (occupant, groupChannelGroupId) => { - if (!occupant) return false; - if (!occupant.auto_created) return false; - if (occupant.has_channel_number_override) return false; - if ( - occupant.channel_group_id !== undefined && - occupant.channel_group_id !== groupChannelGroupId - ) - return false; - if ( - occupant.auto_created_by_account_id !== undefined && - playlist?.id !== undefined && - occupant.auto_created_by_account_id !== playlist.id - ) - return false; - return true; - }; - // Update one source ('occupant' or 'form') of a group's conflict // tracking and re-merge into the public `groupConflicts` state. const setConflictSource = (groupId, source, value) => { @@ -181,14 +151,12 @@ const LiveGroupFilter = ({ const controller = new AbortController(); conflictAbortRef.current[groupId] = controller; try { - const result = await API.getChannelsInRange(start, end, { - signal: controller.signal, - }); + const result = await getChannelsInRange(start, end, controller); const occupants = Array.isArray(result?.occupants) ? result.occupants : []; const unexpected = occupants.filter( - (o) => !isExpectedOccupantForGroup(o, groupId) + (o) => !isExpectedOccupantForGroup(o, groupId, playlist) ); setConflictSource(groupId, 'occupant', unexpected.length > 0); } catch (e) { @@ -203,16 +171,7 @@ const LiveGroupFilter = ({ // Clear pending timers and abort in-flight conflict-scan requests on // unmount so a late response cannot setState on an unmounted component. return () => { - Object.values(conflictTimersRef.current).forEach((t) => clearTimeout(t)); - conflictTimersRef.current = {}; - Object.values(conflictAbortRef.current).forEach((c) => { - try { - c.abort(); - } catch { - // ignore - } - }); - conflictAbortRef.current = {}; + abortTimers(conflictTimersRef, conflictAbortRef); }; }, []); @@ -220,24 +179,6 @@ const LiveGroupFilter = ({ // (cheap). The HTTP-bound DB scan only runs for groups whose own // range fields changed since the last sweep. useEffect(() => { - const rangeFor = (g) => { - if (!g.enabled || !g.auto_channel_sync) return null; - const mode = g.custom_properties?.channel_numbering_mode || 'fixed'; - if (mode === 'next_available') return null; - const startRaw = - mode === 'provider' - ? (g.custom_properties?.channel_numbering_fallback ?? 1) - : (g.auto_sync_channel_start ?? 1); - const start = Number(startRaw); - if (!Number.isFinite(start)) return null; - const endRaw = g.auto_sync_channel_end; - const end = - endRaw === null || endRaw === undefined || endRaw === '' - ? start - : Number(endRaw); - return { start, end, startRaw }; - }; - const ranges = new Map(); for (const g of groupStates) { const r = rangeFor(g); @@ -360,15 +301,15 @@ const LiveGroupFilter = ({ regexPreviewAbortRef.current[groupId] = controller; let response; try { - response = await API.getStreamsRegexPreview(group.name, { - find: find || undefined, - replace: find ? replace : undefined, - match: match || undefined, - exclude: exclude || undefined, - limit: 10, - signal: controller.signal, - m3uAccountId: playlist?.id, - }); + response = await getStreamsRegexPreview( + group, + find, + replace, + match, + exclude, + controller, + playlist + ); } catch (e) { if (e?.name === 'AbortError') return; throw e; @@ -401,18 +342,7 @@ const LiveGroupFilter = ({ useEffect(() => { return () => { - Object.values(regexPreviewTimersRef.current).forEach((t) => - clearTimeout(t) - ); - regexPreviewTimersRef.current = {}; - Object.values(regexPreviewAbortRef.current).forEach((c) => { - try { - c.abort(); - } catch { - // ignore - } - }); - regexPreviewAbortRef.current = {}; + abortTimers(regexPreviewTimersRef, regexPreviewAbortRef); }; }, []); @@ -423,12 +353,15 @@ const LiveGroupFilter = ({ useEffect(() => { if (!configuringGroup) return; const cp = configuringGroup.custom_properties || {}; - scheduleRegexPreview(configuringGroup, { - find: cp.name_regex_pattern || '', - replace: cp.name_replace_pattern ?? '', - match: cp.name_match_regex || '', - exclude: cp.name_match_exclude_regex || '', - }); + scheduleRegexPreview( + configuringGroup, + getRegexOptions( + cp.name_regex_pattern || '', + cp.name_replace_pattern ?? '', + cp.name_match_regex || '', + cp.name_match_exclude_regex || '' + ) + ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [configuringGroup?.channel_group]); @@ -448,7 +381,7 @@ const LiveGroupFilter = ({ useEffect(() => { const fetchEPGSources = async () => { try { - const sources = await API.getEPGs(); + const sources = await getEPGs(); setEpgSources(sources || []); } catch (error) { console.error('Failed to fetch EPG sources:', error); @@ -526,29 +459,7 @@ const LiveGroupFilter = ({ const currentStart = state.auto_sync_channel_start; if (currentStart && currentStart > 1) return next; - let proposedStart = 1; - for (const other of prev) { - if (other.channel_group == id) continue; - if (!other.enabled || !other.auto_channel_sync) continue; - const otherMode = - other.custom_properties?.channel_numbering_mode || 'fixed'; - if (otherMode === 'next_available') continue; - const otherStart = Number( - otherMode === 'provider' - ? (other.custom_properties?.channel_numbering_fallback ?? 1) - : (other.auto_sync_channel_start ?? 1) - ); - if (!Number.isFinite(otherStart)) continue; - const otherEnd = - other.auto_sync_channel_end === null || - other.auto_sync_channel_end === undefined || - other.auto_sync_channel_end === '' - ? otherStart - : Number(other.auto_sync_channel_end); - const upper = Math.max(otherStart, otherEnd); - if (upper + 1 > proposedStart) proposedStart = upper + 1; - } - next.auto_sync_channel_start = proposedStart; + next.auto_sync_channel_start = computeAutoSyncStart(prev, id); return next; }) ); @@ -577,22 +488,13 @@ const LiveGroupFilter = ({ setCurrentEditingGroupId(null); }; - const isVisible = (group) => { - const matchesText = group.name - .toLowerCase() - .includes(groupFilter.toLowerCase()); - const matchesStatus = - statusFilter === 'all' || - (statusFilter === 'enabled' && group.enabled) || - (statusFilter === 'disabled' && !group.enabled); - return matchesText && matchesStatus; - }; - const selectAll = () => { setGroupStates((prev) => prev.map((state) => ({ ...state, - enabled: isVisible(state) ? true : state.enabled, + enabled: isGroupVisible(state, groupFilter, statusFilter) + ? true + : state.enabled, })) ); }; @@ -601,1009 +503,13 @@ const LiveGroupFilter = ({ setGroupStates((prev) => prev.map((state) => ({ ...state, - enabled: isVisible(state) ? false : state.enabled, + enabled: isGroupVisible(state, groupFilter, statusFilter) + ? false + : state.enabled, })) ); }; - // Returns {name, start, end}[] for groups whose declared ranges - // intersect this group's range, or [] when there is no overlap. - const computeRangeOverlapsFor = (group) => { - const myReservation = getGroupReservation(group); - if (!myReservation) return []; - const [myStart, myEnd] = myReservation; - const overlaps = []; - for (const other of groupStates) { - if (other.channel_group === group.channel_group) continue; - const otherReservation = getGroupReservation(other); - if (!otherReservation) continue; - const [oStart, oEnd] = otherReservation; - if (myStart <= oEnd && oStart <= myEnd) { - overlaps.push({ name: other.name, start: oStart, end: oEnd }); - } - } - return overlaps; - }; - - // Inline Start/End range inputs for Fixed and Provider modes. Start - // writes to auto_sync_channel_start (Fixed) or - // custom_properties.channel_numbering_fallback (Provider); End always - // writes to auto_sync_channel_end. Next Available shows a one-line - // explanation because a range contradicts pack-anywhere semantics. - const renderNumberingRange = (group) => { - const mode = group.custom_properties?.channel_numbering_mode || 'fixed'; - if (mode === 'next_available') { - return ( - - Channels receive the lowest available numbers starting at 1. - - ); - } - const startValue = - mode === 'provider' - ? (group.custom_properties?.channel_numbering_fallback ?? 1) - : (group.auto_sync_channel_start ?? 1); - const endValue = group.auto_sync_channel_end; - // Caps pathological pasted values like "1e308" at the input layer. - const MAX_CHANNEL_NUMBER = 999999; - const clampChannelNumber = (n) => - Math.max(1, Math.min(MAX_CHANNEL_NUMBER, Math.floor(Number(n) || 1))); - const updateStart = (value) => { - const normalized = - value === '' || value === null || value === undefined - ? 1 - : clampChannelNumber(value); - if (mode === 'provider') { - applyGroupChange({ - ...group, - custom_properties: { - ...(group.custom_properties || {}), - channel_numbering_fallback: normalized, - }, - }); - } else { - // If End is set and the new Start exceeds it, drop End so the user - // is not left holding an invalid range silently. - const next = { ...group, auto_sync_channel_start: normalized }; - if ( - endValue !== null && - endValue !== undefined && - normalized > endValue - ) { - next.auto_sync_channel_end = null; - } - applyGroupChange(next); - } - // Sweep effect picks up the state change and dispatches the scan. - }; - const updateEnd = (value) => { - const normalized = - value === '' || value === null || value === undefined - ? null - : clampChannelNumber(value); - applyGroupChange({ - ...group, - auto_sync_channel_end: normalized, - }); - }; - - const streamCount = - typeof group.stream_count === 'number' ? group.stream_count : null; - const metaParts = []; - if (endValue) { - metaParts.push(`Range: ${Math.max(endValue - startValue + 1, 0)}`); - } - if (streamCount !== null) { - metaParts.push(`Streams: ${streamCount}`); - } - const metaText = metaParts.join(' · '); - - const conflict = groupConflicts[group.channel_group]; - const hasChannelConflict = !!conflict?.hasChannelConflict; - const overlaps = computeRangeOverlapsFor(group); - - // Channel-level conflicts get a generic Channels-page pointer (count - // can be large); range-level overlaps stay specific to the modal. - const tooltipSections = []; - if (hasChannelConflict) { - tooltipSections.push( - 'Range conflicts with configured channels.\nView the Channels page to inspect.' - ); - } - if (overlaps.length > 0) { - const overlapLines = overlaps - .map((o) => `${o.name} (${o.start}-${o.end})`) - .join('\n '); - tooltipSections.push( - overlaps.length === 1 - ? `Range overlaps with: ${overlaps[0].name} (${overlaps[0].start}-${overlaps[0].end})` - : `Range overlaps with:\n ${overlapLines}` - ); - } - const showWarning = tooltipSections.length > 0; - const tooltipBody = tooltipSections.join('\n\n'); - - return ( - - - - - - - - - - - - {metaText} - - {showWarning && ( - - - - )} - - - ); - }; - - // Header line for the preview box. Adds a scan-cap suffix when the - // backend only scanned the first SCAN_CAP streams of the group. - const formatPreviewSummary = (label, result) => { - if (!result) return null; - const { match_count, total_in_group, total_scanned, scan_limit_hit } = - result; - const matchWord = `match${match_count === 1 ? '' : 'es'}`; - if (scan_limit_hit) { - return `${match_count} ${matchWord} in first ${total_scanned.toLocaleString()} streams scanned (of ${total_in_group.toLocaleString()} total)`; - } - return `${match_count} ${label} ${matchWord} in ${total_scanned.toLocaleString()} stream${total_scanned === 1 ? '' : 's'}`; - }; - - // Find/replace regex preview backed by /streams/regex-preview/, so - // counts reflect the whole group (or up to SCAN_CAP) rather than a - // small client-side sample. - const renderRegexPreview = (group) => { - const find = group.custom_properties?.name_regex_pattern || ''; - if (!find) return null; - const state = regexPreviewState[group.channel_group] || {}; - const result = state.findResult; - const loading = state.loading; - return ( - - - {result ? formatPreviewSummary('rename', result) : 'Preview'} - - {result?.error && ( - - Invalid regex: {result.error} - - )} - {loading && !result && ( - - Scanning streams... - - )} - {result && !result.error && result.matches.length === 0 && ( - - {result.total_in_group === 0 - ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' - : 'No streams matched this pattern.'} - - )} - {result?.matches?.map((row, idx) => ( - - - {row.before} - - - {' -> '} - - - {row.after} - - - ))} - - ); - }; - - // Shared preview box for include and exclude filters. The marker and - // color reflect whether matched names are kept (teal check) or dropped - // (red x); empty/loading/error states mirror the find preview. - const renderFilterPreview = (group, kind) => { - const pattern = - kind === 'exclude' - ? group.custom_properties?.name_match_exclude_regex || '' - : group.custom_properties?.name_match_regex || ''; - if (!pattern) return null; - const state = regexPreviewState[group.channel_group] || {}; - const result = - kind === 'exclude' ? state.excludeResult : state.filterResult; - const loading = state.loading; - const summaryLabel = kind === 'exclude' ? 'exclude' : 'filter'; - const placeholderLabel = - kind === 'exclude' ? 'Exclude preview' : 'Filter preview'; - const markerChar = kind === 'exclude' ? '✗' : '✓'; - const markerColor = kind === 'exclude' ? 'red.4' : 'teal.4'; - const emptyText = - kind === 'exclude' - ? 'No streams matched this pattern (nothing would be excluded).' - : 'No streams matched this pattern.'; - return ( - - - {result - ? formatPreviewSummary(summaryLabel, result) - : placeholderLabel} - - {result?.error && ( - - Invalid regex: {result.error} - - )} - {loading && !result && ( - - Scanning streams... - - )} - {result && !result.error && result.matches.length === 0 && ( - - {result.total_in_group === 0 - ? 'No streams in this group yet. Run an M3U refresh first to populate streams.' - : emptyText} - - )} - {result?.matches?.map((row, idx) => ( - - - {markerChar} - - - {row.name} - - - ))} - - ); - }; - - const renderMatchPreview = (group) => renderFilterPreview(group, 'include'); - const renderExcludePreview = (group) => renderFilterPreview(group, 'exclude'); - - // Advanced Options form rendered inside the gear modal. A field's - // presence in custom_properties activates it; blanking returns the - // group to default behavior. - const renderAdvancedOptions = (group) => { - const cp = group.custom_properties || {}; - const setCp = (patch, clears = []) => { - const next = { ...cp, ...patch }; - clears.forEach((k) => delete next[k]); - applyGroupChange({ ...group, custom_properties: next }); - }; - - // --- Name Transforms --- - - const findValue = cp.name_regex_pattern ?? ''; - const replaceValue = cp.name_replace_pattern ?? ''; - const filterValue = cp.name_match_regex ?? ''; - const excludeValue = cp.name_match_exclude_regex ?? ''; - const updateFind = (val) => { - if (!val && !replaceValue) { - setCp({}, ['name_regex_pattern', 'name_replace_pattern']); - } else { - setCp({ - name_regex_pattern: val, - name_replace_pattern: replaceValue, - }); - } - scheduleRegexPreview(group, { - find: val, - replace: replaceValue, - match: filterValue, - exclude: excludeValue, - }); - }; - const updateReplace = (val) => { - if (!val && !findValue) { - setCp({}, ['name_regex_pattern', 'name_replace_pattern']); - } else { - setCp({ - name_regex_pattern: findValue, - name_replace_pattern: val, - }); - } - scheduleRegexPreview(group, { - find: findValue, - replace: val, - match: filterValue, - exclude: excludeValue, - }); - }; - const updateFilter = (val) => { - if (!val) setCp({}, ['name_match_regex']); - else setCp({ name_match_regex: val }); - scheduleRegexPreview(group, { - find: findValue, - replace: replaceValue, - match: val, - exclude: excludeValue, - }); - }; - const updateExclude = (val) => { - if (!val) setCp({}, ['name_match_exclude_regex']); - else setCp({ name_match_exclude_regex: val }); - scheduleRegexPreview(group, { - find: findValue, - replace: replaceValue, - match: filterValue, - exclude: val, - }); - }; - - // --- EPG --- - - const epgValue = (() => { - if (cp.custom_epg_id !== undefined && cp.custom_epg_id !== null) { - return cp.custom_epg_id.toString(); - } - if (cp.force_dummy_epg) return '0'; - return ''; - })(); - const updateEpg = (value) => { - const next = { ...cp }; - delete next.custom_epg_id; - delete next.force_dummy_epg; - delete next.force_epg_selected; - if (value === '0') { - next.force_dummy_epg = true; - } else if (value) { - next.custom_epg_id = parseInt(value); - } - applyGroupChange({ ...group, custom_properties: next }); - }; - - // --- Channel Assignment --- - - const groupOverrideValue = cp.group_override - ? cp.group_override.toString() - : ''; - const updateGroupOverride = (value) => { - if (!value) setCp({}, ['group_override']); - else setCp({ group_override: parseInt(value) }); - }; - - const profileValue = cp.channel_profile_ids ?? []; - const updateProfiles = (value) => { - if (!value || value.length === 0) { - setCp({}, ['channel_profile_ids']); - } else { - setCp({ channel_profile_ids: value }); - } - }; - - const streamProfileValue = cp.stream_profile_id - ? cp.stream_profile_id.toString() - : ''; - const updateStreamProfile = (value) => { - if (!value) setCp({}, ['stream_profile_id']); - else setCp({ stream_profile_id: parseInt(value) }); - }; - - const sortOrderValue = cp.channel_sort_order ?? '__default__'; - const sortReverseEnabled = cp.channel_sort_order !== undefined; - const updateSortOrder = (value) => { - if (!value || value === '__default__') { - setCp({}, ['channel_sort_order', 'channel_sort_reverse']); - } else { - setCp({ - channel_sort_order: value, - channel_sort_reverse: cp.channel_sort_reverse ?? false, - }); - } - }; - const updateSortReverse = (checked) => { - setCp({ channel_sort_reverse: checked }); - }; - - // --- Custom Logo --- - - const logoValue = cp.custom_logo_id; - - return ( - - - - Name Transforms - - } - labelPosition="left" - size="sm" - color="gray.6" - /> - - - - updateFind(e.currentTarget.value)} - size="xs" - style={{ flex: 1 }} - /> - updateReplace(e.currentTarget.value)} - size="xs" - style={{ flex: 1 }} - /> - - {findValue && renderRegexPreview(group)} - - - - - - updateFilter(e.currentTarget.value)} - size="xs" - /> - {filterValue && renderMatchPreview(group)} - - - - - updateExclude(e.currentTarget.value)} - size="xs" - /> - {excludeValue && renderExcludePreview(group)} - - - - - - - - EPG & Logo - - } - labelPosition="left" - size="sm" - color="gray.6" - /> - - - ({ - value: g.id.toString(), - label: g.name, - }))} - clearable - searchable - size="xs" - style={{ flex: 1 }} - /> - - - ({ - value: profile.id.toString(), - label: profile.name, - }))} - clearable - searchable - size="xs" - style={{ flex: 1 }} - /> - - - - - - {sortReverseEnabled && ( - - updateSortReverse(event.currentTarget.checked) - } - size="xs" - mt="xs" - /> - )} - - - - - - - { - if (event.currentTarget.checked) { - setCp({ compact_numbering: true }); - } else { - setCp({}, ['compact_numbering']); - } - }} - size="xs" - /> - - - - - - - - - ); - }; - - // Local state mirrors the persisted mode so the SegmentedControl - // reflects clicks immediately even when the parent's playlist prop is - // a stale snapshot from before the PATCH lands. - const [orphanCleanupMode, setOrphanCleanupMode] = useState( - (playlist?.custom_properties || {}).orphan_channel_cleanup || 'always' - ); - - useEffect(() => { - setOrphanCleanupMode( - (playlist?.custom_properties || {}).orphan_channel_cleanup || 'always' - ); - }, [playlist?.id, playlist?.custom_properties?.orphan_channel_cleanup]); - - const handleOrphanCleanupChange = async (mode) => { - if (!playlist?.id) return; - const previousMode = orphanCleanupMode; - setOrphanCleanupMode(mode); - const nextProps = { - ...(playlist.custom_properties || {}), - orphan_channel_cleanup: mode, - }; - try { - await API.updatePlaylist({ - id: playlist.id, - custom_properties: nextProps, - }); - } catch (err) { - setOrphanCleanupMode(previousMode); - notifications.show({ - title: 'Failed to update cleanup mode', - message: err?.body?.detail || err?.message || 'Please try again.', - color: 'red', - }); - } - }; - return ( } color="blue" variant="light"> @@ -1625,39 +531,7 @@ const LiveGroupFilter = ({ description="When disabled, new groups from the M3U source will be created but disabled by default. You can enable them manually later." /> - - - - - Auto-sync orphan cleanup - - - - - - {orphanCleanupMode === 'always' && - 'Removes any auto-synced channel whose source stream is gone from this provider.'} - {orphanCleanupMode === 'preserve_customized' && - 'Removes orphaned auto-synced channels except those with active overrides.'} - {orphanCleanupMode === 'never' && - 'Keeps all orphaned auto-synced channels. You can clean up manually from the channels page.'} - - + {groupStates - .filter((group) => isVisible(group)) + .filter((group) => isGroupVisible(group, groupFilter, statusFilter)) .sort((a, b) => a.name.localeCompare(b.name)) .map((group) => ( )} @@ -1894,18 +773,46 @@ const LiveGroupFilter = ({ }} group={configuringGroup} > - {configuringGroup && renderAdvancedOptions(configuringGroup)} + {configuringGroup && ( + + }> + { + setCurrentEditingGroupId(groupId); + setLogoModalOpen(true); + }} + channelLogos={channelLogos} + playlist={playlist} + logosLoading={logosLoading} + ensureLogosLoaded={ensureLogosLoaded} + /> + + + )} {/* Logo Upload Modal */} - { - setLogoModalOpen(false); - setCurrentEditingGroupId(null); - }} - onSuccess={handleLogoSuccess} - /> + {logoModalOpen && ( + + }> + { + setLogoModalOpen(false); + setCurrentEditingGroupId(null); + }} + onSuccess={handleLogoSuccess} + /> + + + )} ); }; diff --git a/frontend/src/components/forms/LoginForm.jsx b/frontend/src/components/forms/LoginForm.jsx index d190cba2..f9245731 100644 --- a/frontend/src/components/forms/LoginForm.jsx +++ b/frontend/src/components/forms/LoginForm.jsx @@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import useAuthStore from '../../store/auth'; import useSettingsStore from '../../store/settings'; -import { notifications } from '@mantine/notifications'; import { Paper, Title, @@ -20,6 +19,7 @@ import { Checkbox, } from '@mantine/core'; import logo from '../../assets/logo.png'; +import { showNotification } from '../../utils/notificationUtils.js'; const LoginForm = () => { const login = useAuthStore((s) => s.login); @@ -132,7 +132,7 @@ const LoginForm = () => { } catch (e) { console.log(`Failed to login: ${e}`); if (e?.message === 'Unauthorized') { - notifications.show({ + showNotification({ title: 'Web UI Access Denied', message: 'This account is a Streamer account and cannot log into the web UI. ' + @@ -142,6 +142,7 @@ const LoginForm = () => { }); } await logout(); + } finally { setIsLoading(false); } }; diff --git a/frontend/src/components/forms/Logo.jsx b/frontend/src/components/forms/Logo.jsx index 7f10a01b..d1f04595 100644 --- a/frontend/src/components/forms/Logo.jsx +++ b/frontend/src/components/forms/Logo.jsx @@ -1,45 +1,36 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; -import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; import { - Modal, - TextInput, - Button, - Group, - Stack, - Image, - Text, - Center, Box, + Button, + Center, Divider, + Group, + Image, + Modal, + Stack, + Text, + TextInput, } from '@mantine/core'; -import { Dropzone } from '@mantine/dropzone'; -import { Upload, FileImage, X } from 'lucide-react'; -import { notifications } from '@mantine/notifications'; -import API from '../../api'; - -const schema = Yup.object({ - name: Yup.string().required('Name is required'), - url: Yup.string() - .required('URL is required') - .test( - 'valid-url-or-path', - 'Must be a valid URL or local file path', - (value) => { - if (!value) return false; - // Allow local file paths starting with /data/logos/ - if (value.startsWith('/data/logos/')) return true; - // Allow valid URLs - try { - new URL(value); - return true; - } catch { - return false; - } - } - ), -}); +import { + Dropzone, + DropzoneAccept, + DropzoneIdle, + DropzoneReject, +} from '@mantine/dropzone'; +import { FileImage, Upload, X } from 'lucide-react'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + createLogo, + getFilenameWithoutExtension, + getResolver, + getUpdateLogoErrorMessage, + getUploadErrorMessage, + releaseUrl, + updateLogo, + uploadLogo, + validateFileSize, +} from '../../utils/forms/LogoUtils.js'; const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { const [logoPreview, setLogoPreview] = useState(null); @@ -63,7 +54,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { watch, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); const onSubmit = async (values) => { @@ -74,27 +65,14 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { // If we have a selected file, upload it first if (selectedFile) { try { - uploadResponse = await API.uploadLogo(selectedFile, values.name); + uploadResponse = await uploadLogo(selectedFile, values); // Use the uploaded file data instead of form values values.name = uploadResponse.name; values.url = uploadResponse.url; } catch (uploadError) { - let errorMessage = 'Failed to upload logo file'; - - if ( - uploadError.code === 'NETWORK_ERROR' || - uploadError.message?.includes('timeout') - ) { - errorMessage = 'Upload timed out. Please try again.'; - } else if (uploadError.status === 413) { - errorMessage = 'File too large. Please choose a smaller file.'; - } else if (uploadError.body?.error) { - errorMessage = uploadError.body.error; - } - - notifications.show({ + showNotification({ title: 'Upload Error', - message: errorMessage, + message: getUploadErrorMessage(uploadError), color: 'red', }); return; // Don't proceed with creation if upload fails @@ -104,8 +82,8 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { // Now create or update the logo with the final values // Only proceed if we don't already have a logo from file upload if (logo) { - const updatedLogo = await API.updateLogo(logo.id, values); - notifications.show({ + const updatedLogo = await updateLogo(logo, values); + showNotification({ title: 'Success', message: 'Logo updated successfully', color: 'green', @@ -114,8 +92,8 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { } else if (!selectedFile) { // Only create a new logo entry if we're not uploading a file // (file upload already created the logo entry) - const newLogo = await API.createLogo(values); - notifications.show({ + const newLogo = await createLogo(values); + showNotification({ title: 'Success', message: 'Logo created successfully', color: 'green', @@ -123,7 +101,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { onSuccess?.({ type: 'create', logo: newLogo }); // Call onSuccess for creates } else { // File was uploaded and logo was already created - notifications.show({ + showNotification({ title: 'Success', message: 'Logo uploaded successfully', color: 'green', @@ -132,23 +110,9 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { } onClose(); } catch (error) { - let errorMessage = logo - ? 'Failed to update logo' - : 'Failed to create logo'; - - // Handle specific timeout errors - if ( - error.code === 'NETWORK_ERROR' || - error.message?.includes('timeout') - ) { - errorMessage = 'Request timed out. Please try again.'; - } else if (error.response?.data?.error) { - errorMessage = error.response.data.error; - } - - notifications.show({ + showNotification({ title: 'Error', - message: errorMessage, + message: getUpdateLogoErrorMessage(logo, error), color: 'red', }); } finally { @@ -163,14 +127,17 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { }, [defaultValues, logo, reset]); const handleFileSelect = (files) => { - if (files.length === 0) return; + if (files.length === 0) { + console.log('No files selected'); + return; + } const file = files[0]; // Validate file size on frontend first - if (file.size > 5 * 1024 * 1024) { + if (!validateFileSize(file)) { // 5MB - notifications.show({ + showNotification({ title: 'Error', message: 'File too large. Maximum size is 5MB.', color: 'red', @@ -188,8 +155,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { // Auto-fill the name field if empty const currentName = watch('name'); if (!currentName) { - const nameWithoutExtension = file.name.replace(/\.[^/.]+$/, ''); - setValue('name', nameWithoutExtension); + setValue('name', getFilenameWithoutExtension(file.name)); } // Set a placeholder URL (will be replaced after upload) @@ -204,9 +170,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { if (selectedFile) { setSelectedFile(null); // Revoke the object URL to free memory - if (logoPreview && logoPreview.startsWith('blob:')) { - URL.revokeObjectURL(logoPreview); - } + releaseUrl(logoPreview); } // Update preview for remote URLs @@ -224,7 +188,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { const url = new URL(urlValue); const pathname = url.pathname; const filename = pathname.substring(pathname.lastIndexOf('/') + 1); - const nameWithoutExtension = filename.replace(/\.[^/.]+$/, ''); + const nameWithoutExtension = getFilenameWithoutExtension(filename); if (nameWithoutExtension) { setValue('name', nameWithoutExtension); } @@ -237,11 +201,7 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { // Clean up object URLs when component unmounts or preview changes useEffect(() => { - return () => { - if (logoPreview && logoPreview.startsWith('blob:')) { - URL.revokeObjectURL(logoPreview); - } - }; + return () => releaseUrl(logoPreview); }, [logoPreview]); return ( @@ -317,15 +277,15 @@ const LogoForm = ({ logo = null, isOpen, onClose, onSuccess }) => { mih={120} style={{ pointerEvents: 'none' }} > - + - - + + - - + + - +
diff --git a/frontend/src/components/forms/M3U.jsx b/frontend/src/components/forms/M3U.jsx index 288151b5..e27570db 100644 --- a/frontend/src/components/forms/M3U.jsx +++ b/frontend/src/components/forms/M3U.jsx @@ -1,35 +1,40 @@ // Modal.js -import React, { useState, useEffect } from 'react'; -import API from '../../api'; +import React, { useEffect, useState } from 'react'; import useUserAgentsStore from '../../store/userAgents'; import M3UProfiles from './M3UProfiles'; import { - LoadingOverlay, - TextInput, + Box, Button, Checkbox, - Modal, - Flex, - Select, - FileInput, - NumberInput, Divider, - Stack, + FileInput, + Flex, Group, - Switch, - Box, + LoadingOverlay, + Modal, + NumberInput, PasswordInput, - Tooltip, + Select, + Stack, + Switch, + TextInput, } from '@mantine/core'; import M3UGroupFilter from './M3UGroupFilter'; import useChannelsStore from '../../store/channels'; -import { notifications } from '@mantine/notifications'; import { isNotEmpty, useForm } from '@mantine/form'; import useEPGsStore from '../../store/epgs'; import useVODStore from '../../store/useVODStore'; import M3UFilters from './M3UFilters'; import ScheduleInput from './ScheduleInput'; import { DateTimePicker } from '@mantine/dates'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { addEPG } from '../../utils/forms/DummyEpgUtils.js'; +import { + addPlaylist, + getPlaylist, + prepareSubmitValues, + updatePlaylist, +} from '../../utils/forms/M3uUtils.js'; const M3U = ({ m3uAccount = null, @@ -48,8 +53,6 @@ const M3U = ({ const [profileModalOpen, setProfileModalOpen] = useState(false); const [groupFilterModalOpen, setGroupFilterModalOpen] = useState(false); const [filterModalOpen, setFilterModalOpen] = useState(false); - const [loadingText, setLoadingText] = useState(''); - const [showCredentialFields, setShowCredentialFields] = useState(false); const [scheduleType, setScheduleType] = useState('interval'); const form = useForm({ @@ -110,12 +113,6 @@ const M3U = ({ ? 'cron' : 'interval' ); - - if (m3uAccount.account_type == 'XC') { - setShowCredentialFields(true); - } else { - setShowCredentialFields(false); - } } else { setPlaylist(null); form.reset(); @@ -124,107 +121,53 @@ const M3U = ({ } }, [m3uAccount]); - useEffect(() => { - if (form.values.account_type == 'XC') { - setShowCredentialFields(true); - } - }, [form.values.account_type]); - - const onSubmit = async () => { - const { create_epg, ...values } = form.getValues(); - - // Convert exp_date (from controlled state) to ISO string for the API - if (values.account_type === 'XC') { - // XC accounts have exp_date auto-managed server-side; don't send it - delete values.exp_date; - } else if (expDate instanceof Date) { - values.exp_date = expDate.toISOString(); - } else { - values.exp_date = null; - } - - // Determine which schedule type is active based on field values - const hasCronExpression = - values.cron_expression && values.cron_expression.trim() !== ''; - - // Clear the field that isn't active based on actual field values - if (hasCronExpression) { - values.refresh_interval = 0; - } else { - values.cron_expression = ''; - } - - if (values.account_type == 'XC' && values.password == '') { - // If account XC and no password input, assuming no password change - // from previously stored value. - delete values.password; - } - - if (values.user_agent == '0') { - values.user_agent = null; - } - - let newPlaylist; - if (playlist?.id) { - await API.updatePlaylist({ - id: playlist.id, - ...values, - file, + const handleNewPlaylist = async (newPlaylist, values, create_epg) => { + if (create_epg) { + addEPG({ + name: values.name, + source_type: 'xmltv', + url: `${new URL(values.server_url).origin}/xmltv.php?username=${values.username}&password=${values.password}`, + api_key: '', + is_active: true, + refresh_interval: 24, }); - } else { - newPlaylist = await API.addPlaylist({ - ...values, - file, + } + + if (values.account_type != 'XC') { + showNotification({ + title: 'Fetching M3U Groups', + message: + 'Configure group filters and auto sync settings once complete.', }); - - if (create_epg) { - API.addEPG({ - name: values.name, - source_type: 'xmltv', - url: `${new URL(values.server_url).origin}/xmltv.php?username=${values.username}&password=${values.password}`, - api_key: '', - is_active: true, - refresh_interval: 24, - }); - } - - if (values.account_type != 'XC') { - notifications.show({ - title: 'Fetching M3U Groups', - message: - 'Configure group filters and auto sync settings once complete.', - }); - - // Don't prompt for group filters, but keeping this here - // in case we want to revive it - newPlaylist = null; - close(); - return; - } - - // Fetch the updated playlist details (this also updates the store via API) - const updatedPlaylist = await API.getPlaylist(newPlaylist.id); - - // Note: We don't call fetchPlaylists() here because API.addPlaylist() - // already added the playlist to the store. Calling fetchPlaylists() creates - // a race condition where the store is temporarily cleared/replaced while - // websocket updates for the new playlist's refresh task are arriving. - await Promise.all([fetchChannelGroups(), fetchEPGs()]); - - // If this is an XC account with VOD enabled, also fetch VOD categories - if (values.account_type === 'XC' && values.enable_vod) { - fetchCategories(); - } - - console.log('opening group options'); - setPlaylist(updatedPlaylist); - setGroupFilterModalOpen(true); + close(); return; } - form.reset(); - setFile(null); - onClose(newPlaylist); + const updatedPlaylist = await getPlaylist(newPlaylist); + await Promise.all([fetchChannelGroups(), fetchEPGs()]); + + if (values.enable_vod) { + fetchCategories(); + } + + setPlaylist(updatedPlaylist); + setGroupFilterModalOpen(true); + }; + + const onSubmit = async () => { + const { create_epg, ...rawValues } = form.getValues(); + const values = prepareSubmitValues(rawValues, expDate); + + if (playlist?.id) { + await updatePlaylist(playlist, values, file); + form.reset(); + setFile(null); + onClose(); + return; + } + + const newPlaylist = await addPlaylist(values, file); + await handleNewPlaylist(newPlaylist, values, create_epg); }; const close = () => { @@ -270,15 +213,11 @@ const M3U = ({ trapFocus={false} yOffset="2vh" > - +
- + { const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist); @@ -59,9 +62,9 @@ const M3UFilter = ({ filter = null, m3u, isOpen, onClose }) => { if (!filter) { // By default, new rule will go at the end values.order = m3u.filters.length; - await API.addM3UFilter(m3u.id, values); + await addM3UFilter(m3u, values); } else { - await API.updateM3UFilter(m3u.id, filter.id, values); + await updateM3UFilter(m3u, filter, values); } const updatedPlaylist = await fetchPlaylist(m3u.id); diff --git a/frontend/src/components/forms/M3UFilters.jsx b/frontend/src/components/forms/M3UFilters.jsx index acd0c446..fa4e065f 100644 --- a/frontend/src/components/forms/M3UFilters.jsx +++ b/frontend/src/components/forms/M3UFilters.jsx @@ -1,19 +1,18 @@ -import React, { useState, useEffect } from 'react'; -import API from '../../api'; +import React, { useEffect, useState } from 'react'; import usePlaylistsStore from '../../store/playlists'; import ConfirmationDialog from '../ConfirmationDialog'; import useWarningsStore from '../../store/warnings'; import { - Flex, - Modal, - Button, - Box, ActionIcon, + Alert, + Box, + Button, + Center, + Flex, + Group, + Modal, Text, useMantineTheme, - Center, - Group, - Alert, } from '@mantine/core'; import { GripHorizontal, Info, SquareMinus, SquarePen } from 'lucide-react'; import M3UFilter from './M3UFilter'; @@ -36,6 +35,10 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { + deleteM3UFilter, + updateM3UFilter, +} from '../../utils/forms/M3uFilterUtils.js'; const RowDragHandleCell = ({ rowId }) => { const { attributes, listeners, setNodeRef } = useDraggable({ @@ -143,8 +146,6 @@ const DraggableRow = ({ filter, editFilter, onDelete }) => { }; const M3UFilters = ({ playlist, isOpen, onClose }) => { - const theme = useMantineTheme(); - const [editorOpen, setEditorOpen] = useState(false); const [filter, setFilter] = useState(null); const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); @@ -195,7 +196,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { if (!playlist || !playlist.id) return; setDeleting(true); try { - await API.deleteM3UFilter(playlist.id, id); + await deleteM3UFilter(playlist, id); fetchPlaylist(playlist.id); setFilters(filters.filter((f) => f.id !== id)); } catch (error) { @@ -239,7 +240,7 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => { try { await Promise.all( changedFilters.map((f) => - API.updateM3UFilter(playlist.id, f.id, { ...f, order: f.newOrder }) + updateM3UFilter(playlist, f, { ...f, order: f.newOrder }) ) ); await fetchPlaylist(playlist.id); @@ -330,8 +331,8 @@ const M3UFilters = ({ playlist, isOpen, onClose }) => {
{`Are you sure you want to delete the following filter? -Type: ${filterToDelete.type} -Patter: ${filterToDelete.regex_pattern} +Type: ${filterToDelete.filter_type} +Pattern: ${filterToDelete.regex_pattern} This action cannot be undone.`}
diff --git a/frontend/src/components/forms/M3UGroupFilter.jsx b/frontend/src/components/forms/M3UGroupFilter.jsx index 4ee3b31b..5c7762ed 100644 --- a/frontend/src/components/forms/M3UGroupFilter.jsx +++ b/frontend/src/components/forms/M3UGroupFilter.jsx @@ -1,24 +1,31 @@ // Modal.js -import React, { useState, useEffect } from 'react'; -import API from '../../api'; +import React, { useEffect, useState } from 'react'; import { - LoadingOverlay, Button, - Modal, Flex, + LoadingOverlay, + Modal, Stack, Tabs, + TabsList, + TabsPanel, + TabsTab, } from '@mantine/core'; import useChannelsStore from '../../store/channels'; import useVODStore from '../../store/useVODStore'; -import { notifications } from '@mantine/notifications'; import LiveGroupFilter from './LiveGroupFilter'; import VODCategoryFilter from './VODCategoryFilter'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + buildGroupStates, + saveAndRefreshPlaylist, +} from '../../utils/forms/M3uGroupFilterUtils.js'; import { detectGroupReservationOverlaps } from '../../utils/forms/GroupSyncUtils'; const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { const channelGroups = useChannelsStore((s) => s.channelGroups); const fetchCategories = useVODStore((s) => s.fetchCategories); + const [groupStates, setGroupStates] = useState([]); const [isLoading, setIsLoading] = useState(false); const [movieCategoryStates, setMovieCategoryStates] = useState([]); @@ -40,36 +47,8 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { }, [playlist]); useEffect(() => { - if (Object.keys(channelGroups).length === 0) { - return; - } - - setGroupStates( - playlist.channel_groups - .filter((group) => channelGroups[group.channel_group]) // Filter out groups that don't exist - .map((group) => { - // Parse custom_properties if present - let customProps = {}; - if (group.custom_properties) { - try { - customProps = - typeof group.custom_properties === 'string' - ? JSON.parse(group.custom_properties) - : group.custom_properties; - } catch { - customProps = {}; - } - } - return { - ...group, - name: channelGroups[group.channel_group].name, - auto_channel_sync: group.auto_channel_sync || false, - auto_sync_channel_start: group.auto_sync_channel_start || 1.0, - auto_sync_channel_end: group.auto_sync_channel_end ?? null, - custom_properties: customProps, - }; - }) - ); + if (Object.keys(channelGroups).length === 0) return; + setGroupStates(buildGroupStates(channelGroups, playlist.channel_groups)); }, [playlist, channelGroups]); // Fetch VOD categories when modal opens for XC accounts with VOD enabled @@ -92,7 +71,7 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { // names on hover, so the toast just confirms the save proceeded. const overlaps = detectGroupReservationOverlaps(groupStates); if (overlaps.length > 0) { - notifications.show({ + showNotification({ title: 'Overlapping channel number ranges', message: `Saved with ${overlaps.length} overlapping range pair${overlaps.length === 1 ? '' : 's'}. Hover the warning icon on each group for details. Sync will assign whichever numbers are free at run time.`, color: 'yellow', @@ -102,48 +81,26 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { setIsLoading(true); try { - // Prepare groupStates for API - // Send ALL group states like the original code did, don't filter by enabled changes - const groupSettings = groupStates.map((state) => ({ - ...state, - custom_properties: state.custom_properties || undefined, - })); - - const categorySettings = movieCategoryStates - .concat(seriesCategoryStates) - .map((state) => ({ - ...state, - custom_properties: state.custom_properties || undefined, - })) - .filter((state) => state.enabled !== state.original_enabled); - - // Update account-level settings via the proper account endpoint - await API.updatePlaylist({ - id: playlist.id, - auto_enable_new_groups_live: autoEnableNewGroupsLive, - auto_enable_new_groups_vod: autoEnableNewGroupsVod, - auto_enable_new_groups_series: autoEnableNewGroupsSeries, - }); - - // Update group settings via API endpoint - await API.updateM3UGroupSettings( - playlist.id, - groupSettings, - categorySettings + await saveAndRefreshPlaylist( + playlist, + groupStates, + movieCategoryStates, + seriesCategoryStates, + { + auto_enable_new_groups_live: autoEnableNewGroupsLive, + auto_enable_new_groups_vod: autoEnableNewGroupsVod, + auto_enable_new_groups_series: autoEnableNewGroupsSeries, + } ); - // Show notification about the refresh process - notifications.show({ + showNotification({ title: 'Group Settings Updated', message: 'Settings saved. Starting M3U refresh to apply changes...', color: 'green', autoClose: 3000, }); - // Refresh the playlist - this will handle channel sync automatically at the end - await API.refreshPlaylist(playlist.id); - - notifications.show({ + showNotification({ title: 'M3U Refresh Started', message: 'The M3U account is being refreshed. Channel sync will occur automatically after parsing completes.', @@ -178,13 +135,13 @@ const M3UGroupFilter = ({ playlist = null, isOpen, onClose }) => { - - Live - VOD - Movies - VOD - Series - + + Live + VOD - Movies + VOD - Series + - + { autoEnableNewGroupsLive={autoEnableNewGroupsLive} setAutoEnableNewGroupsLive={setAutoEnableNewGroupsLive} /> - + - + { autoEnableNewGroups={autoEnableNewGroupsVod} setAutoEnableNewGroups={setAutoEnableNewGroupsVod} /> - + - + { autoEnableNewGroups={autoEnableNewGroupsSeries} setAutoEnableNewGroups={setAutoEnableNewGroupsSeries} /> - + diff --git a/frontend/src/components/forms/M3UProfile.jsx b/frontend/src/components/forms/M3UProfile.jsx index 1243a5ca..4fdd7e0f 100644 --- a/frontend/src/components/forms/M3UProfile.jsx +++ b/frontend/src/components/forms/M3UProfile.jsx @@ -1,26 +1,38 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from 'react-hook-form'; import { yupResolver } from '@hookform/resolvers/yup'; -import * as Yup from 'yup'; -import API from '../../api'; import { Alert, - Flex, - Modal, - TextInput, - Button, - Title, - Text, - Paper, Badge, + Button, + Flex, Grid, - Textarea, + GridCol, + Modal, NumberInput, + Paper, SegmentedControl, + Text, + Textarea, + TextInput, + Title, } from '@mantine/core'; import { TriangleAlert } from 'lucide-react'; import { DateTimePicker } from '@mantine/dates'; import { useWebSocket } from '../../WebSocket'; +import { + addM3UProfile, + applyRegex, + applyXcSimplePatterns, + buildProfileSchema, + buildSubmitValues, + fetchFirstStreamUrl, + getDetectedMode, + prepareExpDate, + splitByPattern, + updateM3UProfile, + validateXcSimple, +} from '../../utils/forms/M3uProfileUtils.js'; const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { const [websocketReady, sendMessage] = useWebSocket(); @@ -49,20 +61,9 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { [profile] ); - const schema = Yup.object({ - name: Yup.string().required('Name is required'), - search_pattern: Yup.string().when([], { - is: () => !isDefaultProfile && !isXC, - then: (schema) => schema.required('Search pattern is required'), - otherwise: (schema) => schema.notRequired(), - }), - replace_pattern: Yup.string().when([], { - is: () => !isDefaultProfile && !isXC, - then: (schema) => schema.required('Replace pattern is required'), - otherwise: (schema) => schema.notRequired(), - }), - notes: Yup.string(), // Optional field - }); + const getResolver = () => { + return yupResolver(buildProfileSchema(isDefaultProfile, isXC)); + }; const { register, @@ -74,38 +75,22 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { setError, } = useForm({ defaultValues, - resolver: yupResolver(schema), + resolver: getResolver(), }); const onSubmit = async (values) => { - console.log('submiting'); + const expDate = prepareExpDate(values.exp_date, isXC); - // Convert exp_date for submission - let expDateValue = values.exp_date; - if (isXC) { - // XC accounts have exp_date auto-managed; don't send it - expDateValue = undefined; - } else if (expDateValue instanceof Date) { - expDateValue = expDateValue.toISOString(); - } else if (!expDateValue) { - expDateValue = null; - } - - // For XC simple mode: validate simple inputs and build patterns from credentials if (isXC && xcMode === 'simple' && !isDefaultProfile) { - const errs = {}; - if (!newUsername.trim()) errs.newUsername = 'New username is required'; - if (!newPassword.trim()) errs.newPassword = 'New password is required'; + const errs = validateXcSimple(newUsername, newPassword); if (Object.keys(errs).length > 0) { setSimpleErrors(errs); return; } setSimpleErrors({}); - values.search_pattern = `${m3u?.username || ''}/${m3u?.password || ''}`; - values.replace_pattern = `${newUsername.trim()}/${newPassword.trim()}`; + values = applyXcSimplePatterns(values, m3u, newUsername, newPassword); } - // For XC advanced mode: validate regex pattern fields if (isXC && xcMode === 'advanced' && !isDefaultProfile) { if (!searchPattern.trim()) { setError('search_pattern', { message: 'Search pattern is required' }); @@ -117,76 +102,36 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { } } - // Build submit values - let submitValues; - if (isDefaultProfile) { - submitValues = { - name: values.name, - search_pattern: searchPattern || '', - replace_pattern: replacePattern || '', - custom_properties: { - // Preserve existing custom_properties and add/update notes - ...(profile?.custom_properties || {}), - notes: values.notes || '', - }, - }; - } else { - // For regular profiles, send all fields - submitValues = { - name: values.name, - max_streams: values.max_streams, - search_pattern: values.search_pattern, - replace_pattern: values.replace_pattern, - custom_properties: { - // Preserve existing custom_properties and add/update notes - ...(profile?.custom_properties || {}), - notes: values.notes || '', - ...(isXC ? { xcMode } : {}), - }, - }; - } + const submitValues = buildSubmitValues( + values, + profile, + isDefaultProfile, + isXC, + xcMode + ); + if (expDate !== undefined) submitValues.exp_date = expDate; - // Add exp_date for non-XC accounts - if (expDateValue !== undefined) { - submitValues.exp_date = expDateValue; - } - - if (profile?.id) { - await API.updateM3UProfile(m3u.id, { - id: profile.id, - ...submitValues, - }); - } else { - await API.addM3UProfile(m3u.id, submitValues); - } + profile?.id + ? await updateM3UProfile(m3u.id, { ...submitValues, id: profile.id }) + : await addM3UProfile(m3u.id, submitValues); reset(); - // Reset local state to sync with form reset setSearchPattern(''); setReplacePattern(''); onClose(); }; useEffect(() => { - async function fetchStreamUrl() { - try { - if (!m3u?.id) return; + if (!m3u?.id) return; - const params = new URLSearchParams(); - params.append('page', 1); - params.append('page_size', 1); - params.append('m3u_account', m3u.id); - const response = await API.queryStreams(params); - - if (response?.results?.length > 0) { - setStreamUrl(response.results[0].url); - setSampleInput(response.results[0].url); // Initialize sample input with a real stream URL + fetchFirstStreamUrl(m3u.id) + .then((url) => { + if (url) { + setStreamUrl(url); + setSampleInput(url); } - } catch (error) { - console.error('Error fetching stream URL:', error); - } - } - fetchStreamUrl(); + }) + .catch((error) => console.error('Error fetching stream URL:', error)); }, [m3u]); useEffect(() => { @@ -239,19 +184,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { setReplacePattern(profile?.replace_pattern || ''); if (isXC && !isDefaultProfile) { const storedMode = profile?.custom_properties?.xcMode; - let detectedMode; - if (storedMode) { - detectedMode = storedMode; - } else if ( - profile?.search_pattern && - profile.search_pattern === `${m3u?.username}/${m3u?.password}` - ) { - detectedMode = 'simple'; - } else if (profile?.search_pattern) { - detectedMode = 'advanced'; - } else { - detectedMode = 'simple'; - } + const detectedMode = getDetectedMode(storedMode, profile, m3u); setXcMode(detectedMode); if (detectedMode === 'simple') { const rp = profile?.replace_pattern || ''; @@ -294,49 +227,22 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { setXcMode(mode); }; - // Local regex for the live demo preview. Returns an array of strings and - // React nodes so user-supplied text is never interpolated into raw - // HTML (avoids self-XSS via dangerouslySetInnerHTML). const getHighlightedSearchText = () => { - if (!searchPattern || !sampleInput) return sampleInput; - try { - const regex = new RegExp(searchPattern, 'g'); - const parts = []; - let lastIndex = 0; - let m; - while ((m = regex.exec(sampleInput)) !== null) { - if (m.index > lastIndex) { - parts.push(sampleInput.slice(lastIndex, m.index)); - } - parts.push( - - {m[0]} - - ); - lastIndex = m.index + m[0].length; - if (m[0].length === 0) regex.lastIndex++; - } - if (lastIndex < sampleInput.length) { - parts.push(sampleInput.slice(lastIndex)); - } - return parts; - } catch { - return sampleInput; - } + const segments = splitByPattern(sampleInput, searchPattern); + if (!segments) return sampleInput; + return segments.map((seg, i) => + seg.matched ? ( + + {seg.text} + + ) : ( + seg.text + ) + ); }; - const getLocalReplaceResult = () => { - if (!searchPattern || !sampleInput) return sampleInput; - try { - const regex = new RegExp(searchPattern, 'g'); - return sampleInput.replace(regex, replacePattern); - } catch { - return sampleInput; - } - }; + const getLocalReplaceResult = () => + applyRegex(sampleInput, searchPattern, replacePattern); return ( { > These patterns are applied to every stream in this playlist. If - the search pattern doesn't match a stream URL, the original - URL is used as-is. + the search pattern doesn't match a stream URL, the original URL + is used as-is. { - + Matched Text{' '} @@ -554,9 +460,9 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { {getHighlightedSearchText()} - + - + Result After Replace @@ -568,7 +474,7 @@ const RegexFormAndView = ({ profile = null, m3u, isOpen, onClose }) => { {getLocalReplaceResult()} - + )} diff --git a/frontend/src/components/forms/M3UProfiles.jsx b/frontend/src/components/forms/M3UProfiles.jsx index efbc1ed8..462c1655 100644 --- a/frontend/src/components/forms/M3UProfiles.jsx +++ b/frontend/src/components/forms/M3UProfiles.jsx @@ -1,32 +1,165 @@ -import React, { useState, useEffect } from 'react'; -import API from '../../api'; +import React, { useEffect, useState } from 'react'; import M3UProfile from './M3UProfile'; import AccountInfoModal from './AccountInfoModal'; import usePlaylistsStore from '../../store/playlists'; import ConfirmationDialog from '../ConfirmationDialog'; import useWarningsStore from '../../store/warnings'; import { - Card, - Checkbox, - Flex, - Modal, - Button, - Box, ActionIcon, - Text, - NumberInput, - useMantineTheme, - Center, - Group, - Switch, Badge, + Button, + Card, + Flex, + Group, + Modal, + NumberInput, Stack, + Switch, + Text, + useMantineTheme, } from '@mantine/core'; -import { SquareMinus, SquarePen, Info } from 'lucide-react'; +import { Info, SquareMinus, SquarePen } from 'lucide-react'; +import { + deleteM3UProfile, + updateM3UProfile, +} from '../../utils/forms/M3uProfileUtils.js'; +import { + getExpirationInfo, + isAccountExpired, + profileSortComparator, +} from '../../utils/forms/M3uProfilesUtils.js'; + +const M3uProfileCard = ({ + item, + accountType, + onClickInfo, + onClickEdit, + onClickDelete, + onChangeMaxStreams, + onChangeActive, +}) => { + const theme = useMantineTheme(); + const accountStatus = item.custom_properties?.user_info?.status ?? null; + const expirationInfo = getExpirationInfo(item); + const expired = isAccountExpired(item); + + return ( + + + {/* Header with name and status badges */} + + + + {item.name} + {/* Show notes if they exist */} + {item.custom_properties?.notes && ( + + {item.custom_properties.notes} + + )} + + {accountType === 'XC' && item.custom_properties && ( + + {/* Account status badge */} + {accountStatus && ( + + {accountStatus} + + )} + {/* Expiration badge */} + {expirationInfo && ( + + {expirationInfo.text} + + )} + {/* Info button next to badges */} + + + + + )} + + + + {/* Max Streams and Actions */} + + + + + {/* Toggle switch */} + + + {/* Always show edit button, but limit what can be edited for default profiles */} + + + + + {!item.is_default && ( + <> + + + + + )} + + + + + ); +}; const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { - const theme = useMantineTheme(); - const allProfiles = usePlaylistsStore((s) => s.profiles); const fetchPlaylist = usePlaylistsStore((s) => s.fetchPlaylist); const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); @@ -91,7 +224,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { if (!playlist || !playlist.id) return; setDeletingProfile(true); try { - await API.deleteM3UProfile(playlist.id, id); + await deleteM3UProfile(playlist.id, id); } catch (error) { console.error('Error deleting profile:', error); } finally { @@ -103,7 +236,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const toggleActive = async (values) => { if (!playlist || !playlist.id) return; try { - await API.updateM3UProfile(playlist.id, { + await updateM3UProfile(playlist.id, { ...values, is_active: !values.is_active, }); @@ -115,7 +248,7 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { const modifyMaxStreams = async (value, item) => { if (!playlist || !playlist.id) return; try { - await API.updateM3UProfile(playlist.id, { + await updateM3UProfile(playlist.id, { ...item, max_streams: value, }); @@ -142,49 +275,6 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { setAccountInfoOpen(false); }; - // Helper function to get account status from profile - const getAccountStatus = (profile) => { - if (!profile.custom_properties?.user_info) return null; - return profile.custom_properties.user_info.status; - }; - - // Helper function to check if account is expired - const isAccountExpired = (profile) => { - if (!profile.custom_properties?.user_info?.exp_date) return false; - try { - const expDate = new Date( - parseInt(profile.custom_properties.user_info.exp_date) * 1000 - ); - return expDate < new Date(); - } catch { - return false; - } - }; - - // Helper function to get account expiration info - const getExpirationInfo = (profile) => { - if (!profile.custom_properties?.user_info?.exp_date) return null; - try { - const expDate = new Date( - parseInt(profile.custom_properties.user_info.exp_date) * 1000 - ); - const now = new Date(); - const diffMs = expDate - now; - - if (diffMs <= 0) return { text: 'Expired', color: 'red' }; - - const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (days > 30) return { text: `${days} days`, color: 'green' }; - if (days > 7) return { text: `${days} days`, color: 'yellow' }; - if (days > 0) return { text: `${days} days`, color: 'orange' }; - - const hours = Math.floor(diffMs / (1000 * 60 * 60)); - return { text: `${hours}h`, color: 'red' }; - } catch { - return null; - } - }; - // Don't render if modal is not open, or if playlist data is invalid if (!isOpen || !playlist || !playlist.id) { return <>; @@ -204,141 +294,20 @@ const M3UProfiles = ({ playlist = null, isOpen, onClose }) => { withinPortal={true} yOffset="2vh" > - {profilesArray - .sort((a, b) => { - // Always put default profile first - if (a.is_default) return -1; - if (b.is_default) return 1; - // Sort remaining profiles alphabetically by name - return a.name.localeCompare(b.name); - }) - .map((item) => { - const accountStatus = getAccountStatus(item); - const expirationInfo = getExpirationInfo(item); - const expired = isAccountExpired(item); - - return ( - - - {/* Header with name and status badges */} - - - - {item.name} - {/* Show notes if they exist */} - {item.custom_properties?.notes && ( - - {item.custom_properties.notes} - - )} - - {playlist?.account_type === 'XC' && - item.custom_properties && ( - - {/* Account status badge */} - {accountStatus && ( - - {accountStatus} - - )} - {/* Expiration badge */} - {expirationInfo && ( - - {expirationInfo.text} - - )} - {/* Info button next to badges */} - showAccountInfo(item)} - title="View account information" - style={{ - backgroundColor: 'rgba(34, 139, 230, 0.1)', - color: '#228be6', - }} - > - - - - )} - - - - {/* Max Streams and Actions */} - - modifyMaxStreams(value, item)} - style={{ flex: 1 }} - /> - - - {/* Toggle switch */} - toggleActive(item)} - disabled={item.is_default} - label="Active" - labelPosition="left" - size="sm" - /> - - {/* Always show edit button, but limit what can be edited for default profiles */} - editProfile(item)} - title={ - item.is_default - ? 'Edit profile name and notes' - : 'Edit profile' - } - > - - - - {!item.is_default && ( - <> - deleteProfile(item.id)} - size="small" - variant="transparent" - title="Delete profile" - > - - - - )} - - - - - ); - })} + {profilesArray.sort(profileSortComparator).map((item) => { + return ( + showAccountInfo(item)} + onChangeMaxStreams={(value) => modifyMaxStreams(value, item)} + onChangeActive={() => toggleActive(item)} + onClickEdit={() => editProfile(item)} + onClickDelete={() => deleteProfile(item.id)} + /> + ); + })} + ), + Center: ({ children }) =>
{children}
, + Checkbox: ({ checked, onChange, label, 'data-testid': testId }) => ( + + ), + Divider: () =>
, + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + MultiSelect: ({ value, onChange, label, data, 'data-testid': testId }) => ( +
+ {label && } + +
+ ), + Popover: ({ children }) =>
{children}
, + PopoverDropdown: ({ children }) => ( +
{children}
+ ), + PopoverTarget: ({ children }) => ( +
{children}
+ ), + ScrollArea: ({ children }) =>
{children}
, + Select: ({ + value, + onChange, + label, + data, + 'data-testid': testId, + clearable, + }) => ( +
+ {label && } + +
+ ), + Stack: ({ children }) =>
{children}
, + Switch: ({ checked, onChange, label, 'data-testid': testId }) => ( + + ), + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + TextInput: ({ + value, + onChange, + label, + placeholder, + 'data-testid': testId, + }) => ( +
+ {label && } + onChange?.(e)} + /> +
+ ), + Tooltip: ({ children, label }) => ( +
+ {children} +
+ ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import AutoSyncAdvanced from '../AutoSyncAdvanced'; +import useChannelsStore from '../../../store/channels.jsx'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import { + getEpgSourceValue, + getEpgSourceData, + repackGroupChannels, + formatPreviewSummary, +} from '../../../utils/forms/AutoSyncAdvancedUtils.js'; +import { getRegexOptions } from '../../../utils/forms/LiveGroupFilterUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeGroup = (overrides = {}) => ({ + channel_group: 1, + name: 'Sports', + logo_url: null, + auto_sync_enabled: true, + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + custom_properties: {}, + ...overrides, +}); + +const makeEpgSource = (overrides = {}) => ({ + id: 1, + name: 'My EPG', + source_type: 'xmltv', + ...overrides, +}); + +const makePlaylist = (overrides = {}) => ({ + id: 5, + custom_properties: {}, + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + group: makeGroup(), + epgSources: [makeEpgSource()], + channelGroups: [], + streamProfiles: [], + regexPreviewState: {}, + onApplyGroupChange: vi.fn(), + onScheduleRegexPreview: vi.fn(), + onOpenLogoUpload: vi.fn(), + channelLogos: [], + playlist: makePlaylist(), + logosLoading: false, + ensureLogosLoaded: vi.fn(), + ...overrides, +}); + +const renderComponent = (overrides = {}) => { + const props = defaultProps(overrides); + const utils = render(); + return { ...props, ...utils }; +}; + +const setupStore = () => { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ fetchGroups: vi.fn(), profiles: {} }) + ); +}; + +// ────────────────────────────────────────────────────────────────────────────── +describe('AutoSyncAdvanced', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getEpgSourceValue).mockReturnValue(null); + vi.mocked(getEpgSourceData).mockReturnValue([ + { value: '1', label: 'My EPG (xmltv)' }, + ]); + vi.mocked(repackGroupChannels).mockResolvedValue({}); + vi.mocked(formatPreviewSummary).mockReturnValue(null); + setupStore(); + }); + + // ── Basic rendering ──────────────────────────────────────────────────────── + describe('basic rendering', () => { + it('renders without crashing', () => { + renderComponent(); + expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument(); + }); + + it('calls getEpgSourceData with epgSources on render', () => { + const epgSources = [ + makeEpgSource(), + makeEpgSource({ id: 2, name: 'EPG 2' }), + ]; + renderComponent({ epgSources }); + expect(getEpgSourceData).toHaveBeenCalledWith(epgSources); + }); + + it('calls getEpgSourceValue with group.custom_properties on render', () => { + const group = makeGroup(); + const epgSources = [makeEpgSource()]; + renderComponent({ group, epgSources }); + expect(getEpgSourceValue).toHaveBeenCalledWith({}); + }); + }); + + // ── RegexPreviewBox ──────────────────────────────────────────────────────── + describe('RegexPreviewBox', () => { + it('does not render preview box when no include regex is set', () => { + renderComponent({ group: makeGroup({ custom_properties: {} }) }); + expect(screen.queryByText(/matched/i)).not.toBeInTheDocument(); + }); + + it('renders include preview box when name_match_regex is set', () => { + const group = makeGroup({ + custom_properties: { name_match_regex: 'ESPN' }, + }); + const regexPreviewState = { + 1: { + filterResult: { + match_count: 3, + total_scanned: 50, + scan_limit_hit: false, + }, + loading: false, + }, + }; + vi.mocked(formatPreviewSummary).mockReturnValue( + '3 filter matches in 50 streams' + ); + renderComponent({ group, regexPreviewState }); + expect( + screen.getByText('3 filter matches in 50 streams') + ).toBeInTheDocument(); + }); + + it('renders exclude preview box when name_match_exclude_regex is set', () => { + const group = makeGroup({ + custom_properties: { name_match_exclude_regex: 'HD' }, + }); + const regexPreviewState = { + 1: { + excludeResult: { + match_count: 2, + total_scanned: 50, + scan_limit_hit: false, + }, + loading: false, + }, + }; + vi.mocked(formatPreviewSummary).mockReturnValue( + '2 exclude matches in 50 streams' + ); + renderComponent({ group, regexPreviewState }); + expect( + screen.getByText('2 exclude matches in 50 streams') + ).toBeInTheDocument(); + }); + + it('renders find preview box when name_regex_pattern is set', () => { + const group = makeGroup({ + custom_properties: { + name_regex_pattern: 'News', + name_replace_pattern: '', + }, + }); + const regexPreviewState = { + 1: { + findResult: { + matches: [ + { before: 'BBC News', after: 'BBC' }, + { before: 'Sky News', after: 'Sky' }, + { before: 'CNN News', after: 'CNN' }, + { before: 'Fox News', after: 'Fox' }, + { before: 'Al Jazeera News', after: 'Al Jazeera' }, + ], + total_in_group: 50, + }, + loading: false, + }, + }; + vi.mocked(formatPreviewSummary).mockReturnValue( + '5 regex matches in 50 streams' + ); + renderComponent({ group, regexPreviewState }); + expect( + screen.getByText('5 regex matches in 50 streams') + ).toBeInTheDocument(); + expect(formatPreviewSummary).toHaveBeenCalledWith( + 'rename', + regexPreviewState[1].findResult + ); + }); + + it('shows loading state in preview box when state.loading is true', () => { + const group = makeGroup({ + custom_properties: { name_match_regex: 'Sports' }, + }); + const regexPreviewState = { 1: { loading: true } }; + renderComponent({ group, regexPreviewState }); + // No summary rendered during loading + expect(formatPreviewSummary).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ match_count: expect.any(Number) }) + ); + }); + + it('does not render include preview when name_match_regex is empty string', () => { + const group = makeGroup({ + custom_properties: { name_match_regex: '' }, + }); + renderComponent({ group, regexPreviewState: {} }); + expect(formatPreviewSummary).not.toHaveBeenCalled(); + }); + + it('renders regex error message when result has an error', () => { + const group = makeGroup({ + custom_properties: { name_match_regex: 'bad(' }, + }); + const regexPreviewState = { + 1: { filterResult: { error: 'Unterminated group' }, loading: false }, + }; + renderComponent({ group, regexPreviewState }); + expect( + screen.getByText(/invalid regex.*unterminated group/i) + ).toBeInTheDocument(); + }); + + it('renders no-match message when filter result has empty matches', () => { + const group = makeGroup({ + custom_properties: { name_match_regex: 'ZZZ' }, + }); + const regexPreviewState = { + 1: { + filterResult: { matches: [], total_in_group: 10, error: null }, + loading: false, + }, + }; + vi.mocked(formatPreviewSummary).mockReturnValue( + '0 filter matches in 10 streams' + ); + renderComponent({ group, regexPreviewState }); + expect( + screen.getByText(/no streams matched this pattern/i) + ).toBeInTheDocument(); + }); + }); + + // ── EPG source select ────────────────────────────────────────────────────── + describe('EPG source select', () => { + it('passes EPG source data from getEpgSourceData to the Select', () => { + vi.mocked(getEpgSourceData).mockReturnValue([ + { value: '1', label: 'My EPG (xmltv)' }, + { value: '2', label: 'EPG 2 (m3u)' }, + ]); + renderComponent(); + const options = screen.getAllByRole('option'); + const labels = options.map((o) => o.textContent); + expect(labels).toEqual( + expect.arrayContaining(['My EPG (xmltv)', 'EPG 2 (m3u)']) + ); + }); + + it('pre-selects the value returned by getEpgSourceValue', () => { + vi.mocked(getEpgSourceValue).mockReturnValue('2'); + vi.mocked(getEpgSourceData).mockReturnValue([ + { value: '1', label: 'EPG 1' }, + { value: '2', label: 'EPG 2' }, + ]); + renderComponent(); + // The select with value '2' is present + const select = screen + .getAllByRole('combobox') + .find((s) => s.value === '2'); + expect(select).toBeTruthy(); + }); + + it('calls onApplyGroupChange when EPG source is changed', () => { + vi.mocked(getEpgSourceData).mockReturnValue([ + { value: '1', label: 'EPG 1' }, + { value: '2', label: 'EPG 2' }, + ]); + const { onApplyGroupChange } = renderComponent(); + const selects = screen.getAllByRole('combobox'); + fireEvent.change(selects[0], { target: { value: '2' } }); + expect(onApplyGroupChange).toHaveBeenCalled(); + }); + }); + + // ── Regex text inputs ────────────────────────────────────────────────────── + describe('regex text inputs', () => { + it('renders the include regex input', () => { + renderComponent(); + // TextInput mocked with label as testid fallback + const inputs = screen.getAllByRole('textbox'); + expect(inputs.length).toBeGreaterThan(0); + }); + + it('calls onScheduleRegexPreview when find regex changes', () => { + const { onScheduleRegexPreview } = renderComponent(); + const findInput = screen.getByTestId('Find (Regex)'); + fireEvent.change(findInput, { target: { value: 'ESPN' } }); + expect(onScheduleRegexPreview).toHaveBeenCalled(); + }); + + it('calls onScheduleRegexPreview when include regex changes', () => { + const { onScheduleRegexPreview } = renderComponent(); + const includeInput = screen.getByTestId( + 'Include if name matches (Regex)' + ); + fireEvent.change(includeInput, { target: { value: 'ESPN' } }); + expect(onScheduleRegexPreview).toHaveBeenCalled(); + }); + }); + + // ── Repack channels ──────────────────────────────────────────────────────── + describe('repack channels button', () => { + it('calls repackGroupChannels with playlist and group objects', async () => { + vi.mocked(repackGroupChannels).mockResolvedValue({ + assigned: 5, + released: 2, + failed: 0, + }); + const group = makeGroup({ channel_group: 7 }); + const playlist = makePlaylist({ id: 3 }); + renderComponent({ group, playlist }); + + const repackBtn = screen.getByRole('button', { name: /renumber now/i }); + fireEvent.click(repackBtn); + await waitFor(() => { + expect(repackGroupChannels).toHaveBeenCalledWith(playlist, group); + }); + }); + + it('shows success notification after repack resolves', async () => { + vi.mocked(repackGroupChannels).mockResolvedValue({}); + renderComponent(); + const repackBtn = screen.queryByRole('button', { name: /repack/i }); + if (repackBtn) { + fireEvent.click(repackBtn); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + color: expect.stringMatching(/teal|green|blue/i), + }) + ); + }); + } + }); + + it('shows error notification when repack fails', async () => { + vi.mocked(repackGroupChannels).mockRejectedValue( + new Error('Server error') + ); + renderComponent(); + const repackBtn = screen.queryByRole('button', { name: /repack/i }); + if (repackBtn) { + fireEvent.click(repackBtn); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: expect.stringMatching(/red/i) }) + ); + }); + } + }); + }); + + // ── Logo upload ──────────────────────────────────────────────────────────── + describe('logo upload', () => { + it('renders logo-related controls', () => { + renderComponent(); + const logos = screen.queryAllByTestId('lazy-logo'); + expect(logos.length).toBeGreaterThanOrEqual(0); + }); + + it('calls onOpenLogoUpload when logo upload button is clicked', () => { + const { onOpenLogoUpload } = renderComponent(); + const uploadBtn = screen.queryByRole('button', { name: /upload|logo/i }); + if (uploadBtn) { + fireEvent.click(uploadBtn); + expect(onOpenLogoUpload).toHaveBeenCalled(); + } + }); + + it('passes channelLogos to the logo selector when provided', () => { + const channelLogos = [ + { id: 'logo1', url: '/logos/espn.png', name: 'ESPN' }, + { id: 'logo2', url: '/logos/cnn.png', name: 'CNN' }, + ]; + renderComponent({ channelLogos }); + // Just verify rendering doesn't throw with logos + expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument(); + }); + }); + + // ── Stream profiles ──────────────────────────────────────────────────────── + describe('stream profiles', () => { + it('populates profile options from streamProfiles prop', () => { + const streamProfiles = [ + { id: 1, name: 'HD Profile' }, + { id: 2, name: 'SD Profile' }, + ]; + renderComponent({ streamProfiles }); + expect(screen.getAllByTestId('stack')[0]).toBeInTheDocument(); + }); + }); + + // ── Custom properties toggles ────────────────────────────────────────────── + describe('custom properties toggles', () => { + it('renders switches/checkboxes for advanced options', () => { + renderComponent(); + const switches = screen.queryAllByRole('switch'); + const checkboxes = screen.queryAllByRole('checkbox'); + expect(switches.length + checkboxes.length).toBeGreaterThan(0); + }); + + it('calls onApplyGroupChange with compact_numbering when switch is toggled on', () => { + const { onApplyGroupChange } = renderComponent(); + const compactSwitch = screen.getByTestId('Compact numbering'); + fireEvent.click(compactSwitch); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ + custom_properties: expect.objectContaining({ + compact_numbering: true, + }), + }) + ); + }); + + it('selects force_dummy_epg option via EPG select', () => { + vi.mocked(getEpgSourceData).mockReturnValue([ + { value: '0', label: 'No EPG' }, + { value: '1', label: 'My EPG' }, + ]); + const { onApplyGroupChange } = renderComponent(); + const selects = screen.getAllByRole('combobox'); + fireEvent.change(selects[0], { target: { value: '0' } }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ + custom_properties: expect.objectContaining({ force_dummy_epg: true }), + }) + ); + }); + }); + + // ── Regex options (MultiSelect) ──────────────────────────────────────────── + describe('regex options MultiSelect', () => { + it('calls getRegexOptions when find input changes', () => { + renderComponent(); + const findInput = screen.getByTestId('Find (Regex)'); + fireEvent.change(findInput, { target: { value: 'ESPN' } }); + expect(getRegexOptions).toHaveBeenCalled(); + }); + }); + + // ── useEffect / prop sync ────────────────────────────────────────────────── + describe('prop sync', () => { + it('re-calls getEpgSourceValue when group prop changes', () => { + const { rerender } = render(); + expect(getEpgSourceValue).toHaveBeenCalledTimes(1); + + const newGroup = makeGroup({ channel_group: 2, name: 'News' }); + rerender(); + expect(getEpgSourceValue).toHaveBeenCalledTimes(2); + }); + + it('re-calls getEpgSourceData when epgSources prop changes', () => { + const { rerender } = render(); + const callCount = vi.mocked(getEpgSourceData).mock.calls.length; + + const newSources = [ + makeEpgSource(), + makeEpgSource({ id: 99, name: 'New' }), + ]; + rerender( + + ); + expect(vi.mocked(getEpgSourceData).mock.calls.length).toBeGreaterThan( + callCount + ); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx b/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx new file mode 100644 index 00000000..67055a4a --- /dev/null +++ b/frontend/src/components/forms/__tests__/AutoSyncBasic.test.jsx @@ -0,0 +1,429 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/AutoSyncBasicUtils.js', () => ({ + clampChannelNumber: vi.fn((v) => + Math.min(999999, Math.max(1, Math.floor(Number(v) || 1))) + ), + computeRangeOverlapsFor: vi.fn(() => []), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Flex: ({ children }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, c }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
+ {children} +
+ ), + NumberInput: ({ + value, + onChange, + label, + 'data-testid': testId, + min, + max, + }) => ( +
+ {label && } + + onChange?.(e.target.value === '' ? '' : Number(e.target.value)) + } + /> +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertTriangle: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import AutoSyncBasic from '../AutoSyncBasic'; +import { + clampChannelNumber, + computeRangeOverlapsFor, +} from '../../../utils/forms/AutoSyncBasicUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeGroup = (overrides = {}) => ({ + channel_group: 1, + name: 'Sports', + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + custom_properties: { channel_numbering_mode: 'fixed' }, + ...overrides, +}); + +const renderComponent = (groupOverrides = {}, props = {}) => { + const group = makeGroup(groupOverrides); + const onApplyGroupChange = vi.fn(); + const utils = render( + + ); + return { group, onApplyGroupChange, ...utils }; +}; + +// ────────────────────────────────────────────────────────────────────────────── +describe('AutoSyncBasic', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(computeRangeOverlapsFor).mockReturnValue([]); + vi.mocked(clampChannelNumber).mockImplementation((v) => + Math.min(999999, Math.max(1, Math.floor(Number(v) || 1))) + ); + }); + + // ── next_available mode ──────────────────────────────────────────────────── + describe('next_available mode', () => { + it('renders the explanation text instead of inputs', () => { + renderComponent({ + custom_properties: { channel_numbering_mode: 'next_available' }, + }); + expect( + screen.getByText(/Channels receive the lowest available numbers/i) + ).toBeInTheDocument(); + }); + + it('does not render any NumberInput', () => { + renderComponent({ + custom_properties: { channel_numbering_mode: 'next_available' }, + }); + expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument(); + }); + }); + + // ── fixed mode rendering ─────────────────────────────────────────────────── + describe('fixed mode rendering', () => { + it('renders Start and End inputs', () => { + renderComponent(); + expect(screen.getByTestId(/start/i)).toBeInTheDocument(); + expect(screen.getByTestId(/end/i)).toBeInTheDocument(); + }); + + it('displays auto_sync_channel_start as the start value', () => { + renderComponent({ auto_sync_channel_start: 150 }); + expect(screen.getByTestId(/start/i)).toHaveValue(150); + }); + + it('defaults start to 1 when auto_sync_channel_start is undefined', () => { + renderComponent({ auto_sync_channel_start: undefined }); + expect(screen.getByTestId(/start/i)).toHaveValue(1); + }); + + it('displays auto_sync_channel_end as the end value', () => { + renderComponent({ auto_sync_channel_end: 250 }); + expect(screen.getByTestId(/end/i)).toHaveValue(250); + }); + + it('displays empty end when auto_sync_channel_end is undefined', () => { + renderComponent({ auto_sync_channel_end: undefined }); + expect(screen.getByTestId(/end/i)).toHaveValue(null); + }); + }); + + // ── provider mode rendering ──────────────────────────────────────────────── + describe('provider mode rendering', () => { + it('reads start from custom_properties.channel_numbering_fallback', () => { + renderComponent({ + custom_properties: { + channel_numbering_mode: 'provider', + channel_numbering_fallback: 500, + }, + }); + expect(screen.getByTestId(/start/i)).toHaveValue(500); + }); + + it('defaults fallback start to 1 when channel_numbering_fallback is undefined', () => { + renderComponent({ + custom_properties: { + channel_numbering_mode: 'provider', + }, + }); + expect(screen.getByTestId(/start/i)).toHaveValue(1); + }); + }); + + // ── defaults when no mode is set ────────────────────────────────────────── + describe('defaults when mode is absent', () => { + it('treats missing mode as fixed and renders inputs', () => { + renderComponent({ custom_properties: {} }); + expect(screen.getByTestId(/start/i)).toBeInTheDocument(); + expect(screen.getByTestId(/end/i)).toBeInTheDocument(); + }); + }); + + // ── updateStart — fixed mode ─────────────────────────────────────────────── + describe('updateStart in fixed mode', () => { + it('calls onApplyGroupChange with clamped start value', () => { + const { onApplyGroupChange } = renderComponent({ + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + }); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '150' }, + }); + expect(clampChannelNumber).toHaveBeenCalledWith(150); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ auto_sync_channel_start: expect.any(Number) }) + ); + }); + + it('normalizes empty string input to 1', () => { + const { onApplyGroupChange } = renderComponent(); + fireEvent.change(screen.getByTestId(/start/i), { target: { value: '' } }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ auto_sync_channel_start: 1 }) + ); + expect(clampChannelNumber).not.toHaveBeenCalled(); + }); + + it('drops auto_sync_channel_end when new start exceeds current end', () => { + const { onApplyGroupChange } = renderComponent({ + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(300); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '300' }, + }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ auto_sync_channel_end: null }) + ); + }); + + it('keeps auto_sync_channel_end when new start is below end', () => { + const { onApplyGroupChange, group } = renderComponent({ + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(150); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '150' }, + }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ + auto_sync_channel_end: group.auto_sync_channel_end, + }) + ); + }); + + it('keeps end when there is no existing end value', () => { + const { onApplyGroupChange } = renderComponent({ + auto_sync_channel_start: 100, + auto_sync_channel_end: undefined, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(500); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '500' }, + }); + const call = onApplyGroupChange.mock.calls[0][0]; + expect(call.auto_sync_channel_end).toBeFalsy(); + }); + }); + + // ── updateStart — provider mode ──────────────────────────────────────────── + describe('updateStart in provider mode', () => { + it('writes to custom_properties.channel_numbering_fallback', () => { + const { onApplyGroupChange } = renderComponent({ + custom_properties: { + channel_numbering_mode: 'provider', + channel_numbering_fallback: 100, + }, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(400); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '400' }, + }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ + custom_properties: expect.objectContaining({ + channel_numbering_fallback: 400, + }), + }) + ); + }); + + it('merges existing custom_properties when updating fallback', () => { + const { onApplyGroupChange } = renderComponent({ + custom_properties: { + channel_numbering_mode: 'provider', + channel_numbering_fallback: 100, + some_other_key: true, + }, + }); + vi.mocked(clampChannelNumber).mockReturnValueOnce(200); + fireEvent.change(screen.getByTestId(/start/i), { + target: { value: '200' }, + }); + const call = onApplyGroupChange.mock.calls[0][0]; + expect(call.custom_properties.some_other_key).toBe(true); + expect(call.custom_properties.channel_numbering_mode).toBe('provider'); + }); + + it('normalizes empty string to 1 in provider mode', () => { + const { onApplyGroupChange } = renderComponent({ + custom_properties: { channel_numbering_mode: 'provider' }, + }); + fireEvent.change(screen.getByTestId(/start/i), { target: { value: '' } }); + const call = onApplyGroupChange.mock.calls[0][0]; + expect(call.custom_properties.channel_numbering_fallback).toBe(1); + }); + }); + + // ── updateEnd ────────────────────────────────────────────────────────────── + describe('updateEnd', () => { + it('calls onApplyGroupChange with clamped end value', () => { + const { onApplyGroupChange } = renderComponent(); + vi.mocked(clampChannelNumber).mockReturnValueOnce(300); + fireEvent.change(screen.getByTestId(/end/i), { + target: { value: '300' }, + }); + expect(onApplyGroupChange).toHaveBeenCalledWith( + expect.objectContaining({ auto_sync_channel_end: 300 }) + ); + }); + + it('normalizes empty end to null/falsy', () => { + const { onApplyGroupChange } = renderComponent(); + fireEvent.change(screen.getByTestId(/end/i), { target: { value: '' } }); + const call = onApplyGroupChange.mock.calls[0][0]; + expect( + call.auto_sync_channel_end == null || call.auto_sync_channel_end === '' + ).toBe(true); + }); + + it('does not call clampChannelNumber when end is cleared', () => { + renderComponent(); + fireEvent.change(screen.getByTestId(/end/i), { target: { value: '' } }); + expect(clampChannelNumber).not.toHaveBeenCalled(); + }); + }); + + // ── Meta text ────────────────────────────────────────────────────────────── + describe('meta text', () => { + it('shows range info when end is set', () => { + renderComponent({ + auto_sync_channel_start: 100, + auto_sync_channel_end: 200, + }); + expect(screen.getByTestId('text').textContent).toMatch(/100.*200|range/i); + }); + + it('shows stream count from group when available', () => { + const group = makeGroup({ auto_sync_channel_end: 200, stream_count: 42 }); + const groupStates = [{ channel_group: 1 }]; + render( + + ); + expect(screen.getByTestId('text').textContent).toMatch(/42/); + }); + }); + + // ── Overlap and conflict warnings ────────────────────────────────────────── + describe('warnings', () => { + it('does not render warning icon when no conflicts or overlaps', () => { + renderComponent({}, { groupConflicts: {} }); + expect( + screen.queryByTestId('icon-alert-triangle') + ).not.toBeInTheDocument(); + }); + + it('renders warning icon when computeRangeOverlapsFor returns overlaps', () => { + vi.mocked(computeRangeOverlapsFor).mockReturnValue([ + { name: 'Other Group', start: 150, end: 160 }, + ]); + renderComponent(); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('renders warning icon when group has a channel conflict', () => { + renderComponent( + {}, + { groupConflicts: { 1: { hasChannelConflict: true } } } + ); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('passes overlap group name to tooltip', () => { + vi.mocked(computeRangeOverlapsFor).mockReturnValue([ + { name: 'News Group', start: 150, end: 160 }, + ]); + renderComponent(); + + const tooltip = screen + .getAllByTestId('tooltip') + .find((t) => t.getAttribute('data-label')?.includes('News Group')); + expect(tooltip).toBeDefined(); + }); + + it('passes channel conflict message to tooltip', () => { + renderComponent( + {}, + { groupConflicts: { 1: { hasChannelConflict: true } } } + ); + + const tooltip = screen + .getAllByTestId('tooltip') + .find((t) => + t.getAttribute('data-label')?.toLowerCase().includes('channel') + ); + expect(tooltip).toBeDefined(); + }); + + it('includes both conflict and overlap info in tooltip when both present', () => { + vi.mocked(computeRangeOverlapsFor).mockReturnValue([ + { name: 'Overlap Group', start: 110, end: 120 }, + ]); + renderComponent( + {}, + { groupConflicts: { 1: { hasChannelConflict: true } } } + ); + + const tooltip = screen.getAllByTestId('tooltip').find((t) => { + const label = t.getAttribute('data-label')?.toLowerCase(); + return label?.includes('channel') && label.includes('overlap group'); + }); + expect(tooltip).toBeDefined(); + }); + + it('calls computeRangeOverlapsFor with the group and groupStates', () => { + const group = makeGroup(); + const groupStates = [makeGroup({ channel_group: 2, name: 'B' })]; + render( + + ); + expect(computeRangeOverlapsFor).toHaveBeenCalledWith(group, groupStates); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/AutoSyncOrphanCleanup.test.jsx b/frontend/src/components/forms/__tests__/AutoSyncOrphanCleanup.test.jsx new file mode 100644 index 00000000..509f835f --- /dev/null +++ b/frontend/src/components/forms/__tests__/AutoSyncOrphanCleanup.test.jsx @@ -0,0 +1,232 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Module mocks ─────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uUtils.js', () => ({ + updatePlaylist: vi.fn(() => Promise.resolve({})), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Text: ({ children }) => {children}, + Tooltip: ({ children }) => <>{children}, + SegmentedControl: ({ value, onChange, data }) => ( +
+ {data.map((opt) => ( + + ))} +
+ ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import OrphanCleanupControl from '../AutoSyncOrphanCleanup'; +import { updatePlaylist } from '../../../utils/forms/M3uUtils.js'; +import { showNotification } from '../../../utils/notificationUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makePlaylist = (overrides = {}) => ({ + id: 7, + custom_properties: null, + ...overrides, +}); + +const renderControl = (playlistOverrides = {}) => + render(); + +const getControl = () => screen.getByTestId('segmented-control'); + +// ────────────────────────────────────────────────────────────────────────────── +describe('OrphanCleanupControl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Initial state ────────────────────────────────────────────────────────── + describe('initial state', () => { + it('defaults to "always" when custom_properties is null', () => { + renderControl({ custom_properties: null }); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + + it('defaults to "always" when orphan_channel_cleanup key is absent', () => { + renderControl({ custom_properties: { compact_numbering: true } }); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + + it('reads persisted "preserve_customized" mode from custom_properties', () => { + renderControl({ + custom_properties: { orphan_channel_cleanup: 'preserve_customized' }, + }); + expect(getControl()).toHaveAttribute('data-value', 'preserve_customized'); + }); + + it('reads persisted "never" mode from custom_properties', () => { + renderControl({ + custom_properties: { orphan_channel_cleanup: 'never' }, + }); + expect(getControl()).toHaveAttribute('data-value', 'never'); + }); + }); + + // ── Optimistic update ────────────────────────────────────────────────────── + describe('on mode change', () => { + it('updates the displayed value immediately before the PATCH resolves', () => { + vi.mocked(updatePlaylist).mockReturnValue(new Promise(() => {})); // never resolves + renderControl(); + fireEvent.click(screen.getByTestId('segmented-never')); + expect(getControl()).toHaveAttribute('data-value', 'never'); + }); + + it('calls updatePlaylist with merged custom_properties', async () => { + renderControl({ custom_properties: { compact_numbering: true } }); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(updatePlaylist).toHaveBeenCalledWith( + expect.objectContaining({ id: 7 }), + { + custom_properties: { + compact_numbering: true, + orphan_channel_cleanup: 'never', + }, + } + ); + }); + }); + + it('does not call updatePlaylist when playlist has no id', async () => { + renderControl({ id: undefined }); + fireEvent.click(screen.getByTestId('segmented-never')); + await Promise.resolve(); + expect(updatePlaylist).not.toHaveBeenCalled(); + }); + + it('preserves final "always" value after successful PATCH', async () => { + renderControl({ custom_properties: { orphan_channel_cleanup: 'never' } }); + fireEvent.click(screen.getByTestId('segmented-always')); + await waitFor(() => expect(updatePlaylist).toHaveBeenCalled()); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + }); + + // ── Error / revert ───────────────────────────────────────────────────────── + describe('on PATCH failure', () => { + it('reverts to the previous mode', async () => { + vi.mocked(updatePlaylist).mockRejectedValueOnce( + new Error('Server error') + ); + renderControl({ + custom_properties: { orphan_channel_cleanup: 'always' }, + }); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => expect(showNotification).toHaveBeenCalled()); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + + it('shows a red error notification', async () => { + vi.mocked(updatePlaylist).mockRejectedValueOnce( + new Error('Server error') + ); + renderControl(); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + }); + + it('includes err.message in the notification when body.detail is absent', async () => { + vi.mocked(updatePlaylist).mockRejectedValueOnce(new Error('Timeout')); + renderControl(); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Timeout' }) + ); + }); + }); + + it('prefers err.body.detail over err.message', async () => { + const err = Object.assign(new Error('generic'), { + body: { detail: 'Specific detail from server' }, + }); + vi.mocked(updatePlaylist).mockRejectedValueOnce(err); + renderControl(); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Specific detail from server' }) + ); + }); + }); + + it('falls back to "Please try again." when error has no message or body', async () => { + vi.mocked(updatePlaylist).mockRejectedValueOnce({}); + renderControl(); + fireEvent.click(screen.getByTestId('segmented-never')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Please try again.' }) + ); + }); + }); + }); + + // ── useEffect sync ───────────────────────────────────────────────────────── + describe('useEffect sync on prop change', () => { + it('syncs displayed mode when playlist.custom_properties changes', () => { + const { rerender } = render( + + ); + expect(getControl()).toHaveAttribute('data-value', 'always'); + + rerender( + + ); + expect(getControl()).toHaveAttribute('data-value', 'never'); + }); + + it('resets to "always" when orphan_channel_cleanup is removed from custom_properties', () => { + const { rerender } = render( + + ); + rerender( + + ); + expect(getControl()).toHaveAttribute('data-value', 'always'); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/ChannelGroup.test.jsx b/frontend/src/components/forms/__tests__/ChannelGroup.test.jsx index b159a681..35c00b51 100644 --- a/frontend/src/components/forms/__tests__/ChannelGroup.test.jsx +++ b/frontend/src/components/forms/__tests__/ChannelGroup.test.jsx @@ -1,17 +1,15 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import ChannelGroup from '../ChannelGroup'; -import API from '../../../api'; import useChannelsStore from '../../../store/channels'; import { showNotification } from '../../../utils/notificationUtils.js'; import { useForm } from '@mantine/form'; +import * as API from '../../../utils/forms/ChannelGroupUtils.js'; // ── API mock ─────────────────────────────────────────────────────────────────── -vi.mock('../../../api', () => ({ - default: { - addChannelGroup: vi.fn(), - updateChannelGroup: vi.fn(), - }, +vi.mock('../../../utils/forms/ChannelGroupUtils.js', () => ({ + addChannelGroup: vi.fn(), + updateChannelGroup: vi.fn(), })); // ── Store mock ───────────────────────────────────────────────────────────────── @@ -265,8 +263,7 @@ describe('ChannelGroup', () => { fireEvent.submit(screen.getByTestId('modal').querySelector('form')); await waitFor(() => { - expect(API.updateChannelGroup).toHaveBeenCalledWith({ - id: 1, + expect(API.updateChannelGroup).toHaveBeenCalledWith(group, { name: 'Sports Updated', }); }); diff --git a/frontend/src/components/forms/__tests__/GroupManager.test.jsx b/frontend/src/components/forms/__tests__/GroupManager.test.jsx new file mode 100644 index 00000000..cff0c19b --- /dev/null +++ b/frontend/src/components/forms/__tests__/GroupManager.test.jsx @@ -0,0 +1,811 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import GroupManager from '../GroupManager'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/forms/ChannelGroupUtils.js', () => ({ + addChannelGroup: vi.fn(), + cleanupUnusedChannelGroups: vi.fn(), + deleteChannelGroup: vi.fn(), + updateChannelGroup: vi.fn(), +})); + +// ── ConfirmationDialog mock ──────────────────────────────────────────────────── +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ + opened, + onClose, + onConfirm, + title, + confirmLabel, + cancelLabel, + loading, + }) => + opened ? ( +
+
{title}
+ + +
+ ) : null, +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertCircle: () => , + Check: () => , + Database: () => , + Filter: () => , + SquareMinus: () => , + SquarePen: () => , + SquarePlus: () => , + Trash: () => , + Tv: () => , + X: () => , +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Alert: ({ children, title }) => ( +
+ {title &&
{title}
} + {children} +
+ ), + Badge: ({ children, color }) => ( + + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, leftSection }) => ( + + ), + Chip: ({ children, checked, onChange }) => ( + + ), + Divider: () =>
, + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + ScrollArea: ({ children }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children }) => {children}, + TextInput: ({ value, onChange, placeholder, label, onKeyDown }) => ( + + ), + useMantineTheme: () => ({ + tailwind: { yellow: ['#fefcbf'], red: ['#f56565'] }, + }), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import useWarningsStore from '../../../store/warnings'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as ChannelGroupUtils from '../../../utils/forms/ChannelGroupUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeGroup = (overrides = {}) => ({ + id: 1, + name: 'Group A', + hasChannels: true, + hasM3UAccounts: false, + canEdit: true, + canDelete: true, + ...overrides, +}); + +const setupMocks = ({ groups = {}, isWarningSuppressed = false } = {}) => { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ + channelGroups: groups, + canEditChannelGroup: vi.fn().mockReturnValue(true), + canDeleteChannelGroup: vi.fn().mockReturnValue(true), + }) + ); + + const mockSuppressWarning = vi.fn(); + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ + isWarningSuppressed: () => isWarningSuppressed, + suppressWarning: mockSuppressWarning, + }) + ); + + return { mockSuppressWarning }; +}; + +const renderGroupManager = (props = {}) => + render(); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('GroupManager', () => { + beforeEach(() => vi.clearAllMocks()); + + // ── Visibility ───────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders nothing when isOpen is false', () => { + setupMocks(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders the modal when isOpen is true', () => { + setupMocks(); + renderGroupManager(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('calls onClose when modal close button is clicked', () => { + setupMocks(); + const onClose = vi.fn(); + renderGroupManager({ onClose }); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Group rendering ──────────────────────────────────────────────────────── + + describe('group list rendering', () => { + it('renders a group name', () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + expect(screen.getByText('Group A')).toBeInTheDocument(); + }); + + it('renders multiple groups sorted alphabetically', () => { + setupMocks({ + groups: { + 2: makeGroup({ id: 2, name: 'Zebra' }), + 1: makeGroup({ id: 1, name: 'Alpha' }), + }, + }); + renderGroupManager(); + const names = screen + .getAllByText(/Alpha|Zebra/) + .map((el) => el.textContent); + expect(names.indexOf('Alpha')).toBeLessThan(names.indexOf('Zebra')); + }); + + it('renders channel badge for groups with channels', () => { + setupMocks({ groups: { 1: makeGroup({ hasChannels: true }) } }); + renderGroupManager(); + expect(screen.getByTestId('icon-tv')).toBeInTheDocument(); + }); + + it('renders M3U badge for groups with M3U accounts', () => { + setupMocks({ + groups: { 1: makeGroup({ hasChannels: false, hasM3UAccounts: true }) }, + }); + renderGroupManager(); + expect(screen.getByTestId('icon-database')).toBeInTheDocument(); + }); + + it('shows empty state when no groups match filter', () => { + setupMocks({ groups: {} }); + renderGroupManager(); + // No groups rendered in scroll area + expect(screen.queryByText('Group A')).not.toBeInTheDocument(); + }); + }); + + // ── Search filtering ─────────────────────────────────────────────────────── + + describe('search filtering', () => { + it('filters groups by search term', () => { + setupMocks({ + groups: { + 1: makeGroup({ id: 1, name: 'Sports' }), + 2: makeGroup({ id: 2, name: 'Movies' }), + }, + }); + renderGroupManager(); + + const searchInput = screen.getByPlaceholderText(/search/i); + fireEvent.change(searchInput, { target: { value: 'Sports' } }); + + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('Movies')).not.toBeInTheDocument(); + }); + + it('shows all groups when search is cleared', () => { + setupMocks({ + groups: { + 1: makeGroup({ id: 1, name: 'Sports' }), + 2: makeGroup({ id: 2, name: 'Movies' }), + }, + }); + renderGroupManager(); + + const searchInput = screen.getByPlaceholderText(/search/i); + fireEvent.change(searchInput, { target: { value: 'Sports' } }); + fireEvent.change(searchInput, { target: { value: '' } }); + + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('Movies')).toBeInTheDocument(); + }); + + it('is case-insensitive', () => { + setupMocks({ groups: { 1: makeGroup({ name: 'Sports' }) } }); + renderGroupManager(); + + const searchInput = screen.getByPlaceholderText(/search/i); + fireEvent.change(searchInput, { target: { value: 'sports' } }); + + expect(screen.getByText('Sports')).toBeInTheDocument(); + }); + }); + + // ── Edit group ───────────────────────────────────────────────────────────── + + describe('edit group', () => { + it('shows edit input when edit button is clicked', () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + expect(screen.getByDisplayValue('Group A')).toBeInTheDocument(); + }); + + it('calls updateChannelGroup with trimmed name on save', async () => { + vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue( + undefined + ); + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + const input = screen.getByDisplayValue('Group A'); + fireEvent.change(input, { target: { value: ' Updated Name ' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(ChannelGroupUtils.updateChannelGroup).toHaveBeenCalledWith( + makeGroup(), + { name: 'Updated Name' } + ); + }); + }); + + it('shows success notification after save', async () => { + vi.mocked(ChannelGroupUtils.updateChannelGroup).mockResolvedValue( + undefined + ); + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ); + }); + }); + + it('shows error notification when name is empty on save', async () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + const input = screen.getByDisplayValue('Group A'); + fireEvent.change(input, { target: { value: '' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + expect(ChannelGroupUtils.updateChannelGroup).not.toHaveBeenCalled(); + }); + + it('shows error notification when updateChannelGroup throws', async () => { + vi.mocked(ChannelGroupUtils.updateChannelGroup).mockRejectedValue( + new Error('fail') + ); + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Error' }) + ); + }); + }); + + it('cancels edit and restores group name', () => { + setupMocks({ groups: { 1: makeGroup() } }); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-pen').closest('button')); + const input = screen.getByDisplayValue('Group A'); + fireEvent.change(input, { target: { value: 'Changed' } }); + + fireEvent.click(screen.getByTestId('icon-x').closest('button')); + + expect(screen.queryByDisplayValue('Changed')).not.toBeInTheDocument(); + expect(screen.getByText('Group A')).toBeInTheDocument(); + }); + }); + + // ── Create group ─────────────────────────────────────────────────────────── + + describe('create group', () => { + it('calls addChannelGroup with trimmed name', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: ' New Group ' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(ChannelGroupUtils.addChannelGroup).toHaveBeenCalledWith({ + name: 'New Group', + }); + }); + }); + + it('shows success notification after create', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: 'New Group' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ); + }); + }); + + it('shows error notification when name is empty', async () => { + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + expect(ChannelGroupUtils.addChannelGroup).not.toHaveBeenCalled(); + }); + + it('shows error notification when addChannelGroup throws', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockRejectedValue( + new Error('fail') + ); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: 'New Group' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Error' }) + ); + }); + }); + + it('clears new group name and hides form after successful create', async () => { + vi.mocked(ChannelGroupUtils.addChannelGroup).mockResolvedValue(undefined); + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + const input = screen.getByPlaceholderText(/Enter group name/i); + fireEvent.change(input, { target: { value: 'New Group' } }); + fireEvent.click(screen.getByTestId('icon-check').closest('button')); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText(/Enter group name/i) + ).not.toBeInTheDocument(); + }); + }); + + it('cancels creation form with X button', () => { + setupMocks(); + renderGroupManager(); + + fireEvent.click(screen.getByTestId('icon-square-plus').closest('button')); + expect( + screen.getByPlaceholderText(/Enter group name/i) + ).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('icon-x').closest('button')); + expect( + screen.queryByPlaceholderText(/Enter group name/i) + ).not.toBeInTheDocument(); + }); + }); + + // ── Delete group ─────────────────────────────────────────────────────────── + + describe('delete group', () => { + it('opens confirmation dialog when delete is clicked', async () => { + setupMocks({ + groups: { + 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }, + }, + }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + await waitFor(() => { + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent( + 'Confirm Group Deletion' + ); + }); + }); + + it('calls deleteChannelGroup when confirmed', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue( + undefined + ); + const groupToDelete = { + ...makeGroup(), + hasChannels: false, + hasM3UAccounts: false, + }; + setupMocks({ groups: { 1: groupToDelete } }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(ChannelGroupUtils.deleteChannelGroup).toHaveBeenCalledWith( + groupToDelete + ); + }); + }); + + it('shows success notification after delete', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue( + undefined + ); + setupMocks({ + groups: { + 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }, + }, + }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green', title: 'Success' }) + ); + }); + }); + + it('shows error notification when deleteChannelGroup throws', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockRejectedValue( + new Error('fail') + ); + setupMocks({ + groups: { + 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }, + }, + }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red', title: 'Error' }) + ); + }); + }); + + it('closes confirmation dialog on cancel', () => { + setupMocks({ + groups: { + 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }, + }, + }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('cancel-btn')); + + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('closes confirmation dialog after successful delete', async () => { + vi.mocked(ChannelGroupUtils.deleteChannelGroup).mockResolvedValue( + undefined + ); + setupMocks({ + groups: { + 1: { ...makeGroup(), hasChannels: false, hasM3UAccounts: false }, + }, + }); + renderGroupManager(); + + fireEvent.click( + screen.getByTestId('icon-square-minus').closest('button') + ); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + }); + + // ── Cleanup ──────────────────────────────────────────────────────────────── + + describe('cleanup unused groups', () => { + const successResponse = { deleted_count: 1 }; + + it('opens cleanup confirmation dialog when warning not suppressed', () => { + setupMocks({ isWarningSuppressed: false }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + expect(screen.getByTestId('confirm-title')).toHaveTextContent('Cleanup'); + }); + + it('calls cleanupUnusedChannelGroups directly when warning is suppressed', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue( + successResponse + ); + setupMocks({ isWarningSuppressed: true }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + + await waitFor(() => { + expect(ChannelGroupUtils.cleanupUnusedChannelGroups).toHaveBeenCalled(); + }); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('calls cleanupUnusedChannelGroups when cleanup confirmed', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue( + successResponse + ); + setupMocks({ isWarningSuppressed: false }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + fireEvent.click(screen.getByTestId('confirm-btn')); + + await waitFor(() => { + expect(ChannelGroupUtils.cleanupUnusedChannelGroups).toHaveBeenCalled(); + }); + }); + + it('shows success notification after cleanup', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockResolvedValue( + successResponse + ); + setupMocks({ isWarningSuppressed: true }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'green' }) + ); + }); + }); + + it('shows error notification when cleanup throws', async () => { + vi.mocked(ChannelGroupUtils.cleanupUnusedChannelGroups).mockRejectedValue( + new Error('fail') + ); + setupMocks({ isWarningSuppressed: true }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red' }) + ); + }); + }); + + it('closes cleanup dialog on cancel', () => { + setupMocks({ isWarningSuppressed: false }); + renderGroupManager(); + + fireEvent.click(screen.getByText(/cleanup/i)); + fireEvent.click(screen.getByTestId('cancel-btn')); + + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + + // ── Filter chips ─────────────────────────────────────────────────────────── + + describe('filter chips', () => { + const twoGroups = { + 1: makeGroup({ + id: 1, + name: 'Channel Group', + hasChannels: true, + hasM3UAccounts: false, + }), + 2: makeGroup({ + id: 2, + name: 'M3U Group', + hasChannels: false, + hasM3UAccounts: true, + }), + }; + + it('hides channel groups when Channels chip is unchecked', () => { + setupMocks({ groups: twoGroups }); + renderGroupManager(); + + const channelChip = screen.getByLabelText(/channel groups/i); + fireEvent.click(channelChip); + + expect(screen.queryByText('Channel Group')).not.toBeInTheDocument(); + expect(screen.getByText('M3U Group')).toBeInTheDocument(); + }); + + it('hides M3U groups when M3U chip is unchecked', () => { + setupMocks({ groups: twoGroups }); + renderGroupManager(); + + const m3uChip = screen.getByLabelText(/m3u groups/i); + fireEvent.click(m3uChip); + + expect(screen.getByText('Channel Group')).toBeInTheDocument(); + expect(screen.queryByText('M3U Group')).not.toBeInTheDocument(); + }); + + it('hides unused groups when Unused chip is unchecked', () => { + setupMocks({ + groups: { + 1: makeGroup({ + id: 1, + name: 'Unused Group', + hasChannels: false, + hasM3UAccounts: false, + }), + 2: makeGroup({ + id: 2, + name: 'Used Group', + hasChannels: true, + hasM3UAccounts: false, + }), + }, + }); + renderGroupManager(); + + const unusedChip = screen.getByLabelText(/unused/i); + fireEvent.click(unusedChip); + + expect(screen.queryByText('Unused Group')).not.toBeInTheDocument(); + expect(screen.getByText('Used Group')).toBeInTheDocument(); + }); + }); + + // ── fetchGroupUsage ──────────────────────────────────────────────────────── + + describe('fetchGroupUsage on open', () => { + it('populates group usage from channelGroups flags when modal opens', () => { + setupMocks({ + groups: { + 1: makeGroup({ hasChannels: true, hasM3UAccounts: false }), + }, + }); + renderGroupManager(); + // Group is visible (usage was populated), badge is shown + expect(screen.getByTestId('icon-tv')).toBeInTheDocument(); + }); + + it('does not fetch when isOpen is false', () => { + setupMocks({ + groups: { 1: makeGroup() }, + }); + render(); + // Nothing should render + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx b/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx index 35549856..b96b6b4b 100644 --- a/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx +++ b/frontend/src/components/forms/__tests__/LiveGroupFilter.test.jsx @@ -1,251 +1,913 @@ +import React, { useState } from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; -// Targeted Vitest for the orphan-cleanup SegmentedControl that lives at -// the top of the M3U account's group-settings page. Covers default-mode -// resolution, click-to-PATCH, and optimistic-update / error-revert -// behavior. Other parts of LiveGroupFilter (per-group inline config, -// gear modal, regex preview, overlap warning) are not unit-tested here -// because they depend on external APIs and live data; they are exercised -// via the in-repo manual UI walkthrough. - -// ── Store mocks ──────────────────────────────────────────────────────────────── +// ── Store mocks ─────────────────────────────────────────────────────────────── vi.mock('../../../store/channels', () => ({ default: vi.fn() })); vi.mock('../../../store/streamProfiles', () => ({ default: vi.fn() })); -// ── Hook mocks ───────────────────────────────────────────────────────────────── +// ── Hook mocks ──────────────────────────────────────────────────────────────── vi.mock('../../../hooks/useSmartLogos', () => ({ - useChannelLogoSelection: vi.fn(() => ({ - logos: {}, - ensureLogosLoaded: vi.fn(), - isLoading: false, - })), + useChannelLogoSelection: vi.fn(), })); -// ── Module mocks ─────────────────────────────────────────────────────────────── -vi.mock('@mantine/notifications', () => ({ - notifications: { show: vi.fn() }, -})); - -vi.mock('../../../api', () => ({ - default: { - updatePlaylist: vi.fn(() => Promise.resolve({})), - getEPGs: vi.fn(() => Promise.resolve([])), - getChannelsInRange: vi.fn(() => Promise.resolve({ data: [] })), - getStreamsRegexPreview: vi.fn(() => Promise.resolve({ data: {} })), - repackGroupChannels: vi.fn(() => Promise.resolve({})), - }, -})); - -vi.mock('../../../utils/forms/GroupSyncUtils', () => ({ - getGroupReservation: vi.fn(() => null), -})); - -// ── Child component mocks ────────────────────────────────────────────────────── +// ── Child component mocks ───────────────────────────────────────────────────── vi.mock('../GroupConfigureModal', () => ({ - default: ({ children }) =>
{children}
, + default: ({ opened, onDone, onCancel, group, children }) => + opened ? ( +
+ {children} + + +
+ ) : null, })); -vi.mock('../Logo', () => ({ default: () => null })); -vi.mock('../../LazyLogo', () => ({ default: () => null })); -vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' })); +vi.mock('../AutoSyncOrphanCleanup.jsx', () => ({ + default: ({ playlist }) => ( +
+ ), +})); -vi.mock('react-window', () => ({ - FixedSizeList: ({ children, itemCount }) => ( -
- {Array.from({ length: itemCount }, (_, index) => - children({ index, style: {} }) - )} +vi.mock('../AutoSyncBasic.jsx', () => ({ + default: () =>
, +})); + +vi.mock('../../ErrorBoundary.jsx', () => ({ + default: ({ children }) => <>{children}, +})); + +vi.mock('../AutoSyncAdvanced.jsx', () => ({ + default: ({ + group, + onApplyGroupChange, + onScheduleRegexPreview, + onOpenLogoUpload, + }) => ( +
+ + +
), })); -vi.mock('lucide-react', () => ({ - Info: () => , - CircleCheck: () => , - CircleX: () => , - Settings: () => , - AlertTriangle: () => , - RefreshCw: () => , +vi.mock('../Logo.jsx', () => ({ + default: ({ isOpen, onClose, onSuccess }) => + isOpen ? ( +
+ + +
+ ) : null, })); -// ── @mantine/core minimal mocks ──────────────────────────────────────────────── -vi.mock('@mantine/core', () => ({ - TextInput: ({ value, onChange, placeholder }) => ( - +// ── Utility mocks ───────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/LiveGroupFilterUtils.js', () => ({ + abortTimers: vi.fn(), + computeAutoSyncStart: vi.fn(() => 101), + getChannelsInRange: vi.fn().mockResolvedValue({ occupants: [] }), + getEPGs: vi.fn().mockResolvedValue([]), + getRegexOptions: vi.fn((find, replace, match, exclude) => ({ + find, + replace, + match, + exclude, + })), + getStreamsRegexPreview: vi.fn().mockResolvedValue(null), + isExpectedOccupantForGroup: vi.fn(() => true), + isGroupVisible: vi.fn((group, filter, status) => { + if (status === 'enabled') return group.enabled; + if (status === 'disabled') return !group.enabled; + if (filter) return group.name.toLowerCase().includes(filter.toLowerCase()); + return true; + }), + rangeFor: vi.fn(() => null), +})); + +// ── Mantine core mock ───────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + ), - Button: ({ children, onClick }) => , - Checkbox: ({ label, checked, onChange }) => ( + Alert: ({ children }) =>
{children}
, + Box: ({ children, style }) =>
{children}
, + Button: ({ children, onClick, disabled, variant, color }) => ( + + ), + Checkbox: ({ label, checked, onChange, description, disabled }) => ( ), + Divider: () =>
, Flex: ({ children }) =>
{children}
, - Select: ({ value, onChange, data }) => ( - + Group: ({ children, style }) => ( +
+ {children} +
), - Stack: ({ children }) =>
{children}
, - Group: ({ children }) =>
{children}
, - SimpleGrid: ({ children }) =>
{children}
, - Text: ({ children }) => {children}, - NumberInput: ({ value, onChange }) => ( - onChange?.(Number(e.target.value))} - /> - ), - Divider: ({ label }) =>
, - Alert: ({ children }) =>
{children}
, - Box: ({ children }) =>
{children}
, - MultiSelect: ({ value, onChange }) => ( - - ), - Tooltip: ({ children }) => <>{children}, - Popover: ({ children }) =>
{children}
, - ScrollArea: ({ children }) =>
{children}
, - Center: ({ children }) =>
{children}
, - SegmentedControl: ({ value, onChange, data }) => ( -
- {data.map((opt) => ( + Loader: () =>
, + SegmentedControl: ({ onChange, data }) => ( +
+ {(data || []).map((item) => ( ))}
), - ActionIcon: ({ children, onClick }) => , - Switch: ({ label, checked, onChange }) => ( - + SimpleGrid: ({ children }) =>
{children}
, + Stack: ({ children, style }) =>
{children}
, + Text: ({ children }) => {children}, + TextInput: ({ placeholder, value, onChange }) => ( + ), + Tooltip: ({ children }) => <>{children}, })); -// ── Imports after mocks ──────────────────────────────────────────────────────── +// ── lucide-react mock ───────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + CircleCheck: () => , + CircleX: () => , + Info: () => , + Settings: () => , +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── import LiveGroupFilter from '../LiveGroupFilter'; -import API from '../../../api'; -import { notifications } from '@mantine/notifications'; import useChannelsStore from '../../../store/channels'; import useStreamProfilesStore from '../../../store/streamProfiles'; +import { useChannelLogoSelection } from '../../../hooks/useSmartLogos'; +import { + getEPGs, + getRegexOptions, + getStreamsRegexPreview, + isGroupVisible, +} from '../../../utils/forms/LiveGroupFilterUtils.js'; -// ────────────────────────────────────────────────────────────────────────────── - -const makePlaylist = (overrides = {}) => ({ - id: 7, - channel_groups: [], - custom_properties: null, +// ── Factories ───────────────────────────────────────────────────────────────── +const makeGroup = (overrides = {}) => ({ + channel_group: 1, + name: 'Sports', + enabled: true, + auto_channel_sync: true, + auto_sync_channel_start: 1, + auto_sync_channel_end: null, + custom_properties: {}, + is_stale: false, + original_enabled: true, ...overrides, }); -const setupStores = () => { - vi.mocked(useChannelsStore).mockImplementation((sel) => - sel({ channelGroups: {}, profiles: [] }) - ); - vi.mocked(useStreamProfilesStore).mockImplementation((sel) => - sel({ profiles: [], fetchProfiles: vi.fn() }) +const makePlaylist = (overrides = {}) => ({ + id: 1, + name: 'Test Playlist', + channel_groups: [], + ...overrides, +}); + +// ── Stateful wrapper ────────────────────────────────────────────────────────── +const Wrapper = ({ + initialGroups = [], + initialAutoEnable = true, + playlist, +}) => { + const [groupStates, setGroupStates] = useState(initialGroups); + const [autoEnable, setAutoEnable] = useState(initialAutoEnable); + return ( + ); }; -const renderFilter = (playlistOverrides = {}) => - render( - - ); +// ── Test setup helpers ──────────────────────────────────────────────────────── +describe('LiveGroupFilter', () => { + let mockEnsureLogosLoaded; + let mockFetchProfiles; -// The orphan-cleanup SegmentedControl shares its mocked testid with the -// status-filter SegmentedControl that lives elsewhere in the form, so -// disambiguate by walking from a uniquely-testided child button up to -// its parent SegmentedControl element. -const findCleanupControl = () => - screen.getByTestId('segmented-always').closest('[data-testid="segmented-control"]'); - -describe('LiveGroupFilter orphan-cleanup SegmentedControl', () => { beforeEach(() => { vi.clearAllMocks(); - setupStores(); - }); + mockEnsureLogosLoaded = vi.fn(); + mockFetchProfiles = vi.fn(); - it('defaults to "always" when custom_properties is null', () => { - renderFilter({ custom_properties: null }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); - }); - - it('defaults to "always" when the orphan_channel_cleanup key is absent', () => { - renderFilter({ custom_properties: { compact_numbering: true } }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); - }); - - it('reads the persisted mode from custom_properties on mount', () => { - renderFilter({ - custom_properties: { orphan_channel_cleanup: 'preserve_customized' }, - }); - expect(findCleanupControl()).toHaveAttribute( - 'data-value', - 'preserve_customized' + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups: {} }) ); + vi.mocked(useStreamProfilesStore).mockImplementation((sel) => + sel({ profiles: [], fetchProfiles: mockFetchProfiles }) + ); + vi.mocked(useChannelLogoSelection).mockReturnValue({ + logos: [], + ensureLogosLoaded: mockEnsureLogosLoaded, + isLoading: false, + }); + vi.mocked(getEPGs).mockResolvedValue([]); }); - it('PATCHes the playlist with merged custom_properties on click and updates the displayed value', async () => { - renderFilter({ - custom_properties: { compact_numbering: true }, - }); - fireEvent.click(screen.getByTestId('segmented-never')); - - await waitFor(() => { - expect(API.updatePlaylist).toHaveBeenCalledWith({ - id: 7, - custom_properties: { - compact_numbering: true, - orphan_channel_cleanup: 'never', - }, + const renderWith = ({ + initialGroups = [], + initialAutoEnable = true, + playlist, + channelGroups, + streamProfiles, + fetchProfiles, + ensureLogosLoaded, + } = {}) => { + if (channelGroups !== undefined) { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups }) + ); + } + if (streamProfiles !== undefined || fetchProfiles !== undefined) { + vi.mocked(useStreamProfilesStore).mockImplementation((sel) => + sel({ + profiles: streamProfiles ?? [], + fetchProfiles: fetchProfiles ?? mockFetchProfiles, + }) + ); + } + if (ensureLogosLoaded !== undefined) { + vi.mocked(useChannelLogoSelection).mockReturnValue({ + logos: [], + ensureLogosLoaded, + isLoading: false, }); - }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'never'); - }); + } - it('reverts to the previous mode and surfaces an error toast when the PATCH fails', async () => { - vi.mocked(API.updatePlaylist).mockRejectedValueOnce( - new Error('Server error') + return render( + ); - renderFilter({ - custom_properties: { orphan_channel_cleanup: 'always' }, - }); - fireEvent.click(screen.getByTestId('segmented-never')); + }; - await waitFor(() => { - expect(notifications.show).toHaveBeenCalledWith( - expect.objectContaining({ color: 'red' }) + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the info alert', () => { + renderWith(); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByText(/Auto Channel Sync/i)).toBeInTheDocument(); + }); + + it('renders the auto-enable new groups checkbox', () => { + renderWith(); + expect( + screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }) + ).toBeInTheDocument(); + }); + + it('reflects initialAutoEnable=true on the checkbox', () => { + renderWith({ initialAutoEnable: true }); + expect( + screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }) + ).toBeChecked(); + }); + + it('reflects initialAutoEnable=false on the checkbox', () => { + renderWith({ initialAutoEnable: false }); + expect( + screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }) + ).not.toBeChecked(); + }); + + it('renders OrphanCleanupControl with the playlist', () => { + renderWith({ playlist: makePlaylist({ id: 7 }) }); + expect(screen.getByTestId('orphan-cleanup')).toHaveAttribute( + 'data-playlist-id', + '7' ); }); - expect(findCleanupControl()).toHaveAttribute('data-value', 'always'); + + it('renders the group name filter input', () => { + renderWith(); + expect(screen.getByTestId('group-filter-input')).toBeInTheDocument(); + }); + + it('renders All / Enabled / Disabled status filter buttons', () => { + renderWith(); + expect(screen.getByTestId('seg-all')).toBeInTheDocument(); + expect(screen.getByTestId('seg-enabled')).toBeInTheDocument(); + expect(screen.getByTestId('seg-disabled')).toBeInTheDocument(); + }); + + it('renders Select Visible and Deselect Visible buttons', () => { + renderWith(); + expect(screen.getByText('Select Visible')).toBeInTheDocument(); + expect(screen.getByText('Deselect Visible')).toBeInTheDocument(); + }); + + it('renders a card for each group in groupStates', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + it('renders groups sorted alphabetically by name', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 3, name: 'Sports' }), + makeGroup({ channel_group: 1, name: 'Arts' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + const names = screen + .getAllByText(/Arts|News|Sports/) + .map((el) => el.textContent); + expect(names).toEqual(['Arts', 'News', 'Sports']); + }); + + it('renders no group cards when groupStates is empty', () => { + renderWith({ initialGroups: [] }); + expect(screen.queryByTestId('group-card')).not.toBeInTheDocument(); + }); + + it('does not render the configure modal on initial render', () => { + renderWith({ initialGroups: [makeGroup()] }); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + }); + + // ── Mount effects ────────────────────────────────────────────────────────── + + describe('mount effects', () => { + it('calls ensureLogosLoaded on mount', () => { + renderWith({ ensureLogosLoaded: mockEnsureLogosLoaded }); + expect(mockEnsureLogosLoaded).toHaveBeenCalled(); + }); + + it('calls fetchStreamProfiles when profiles array is empty', () => { + renderWith({ streamProfiles: [], fetchProfiles: mockFetchProfiles }); + expect(mockFetchProfiles).toHaveBeenCalled(); + }); + + it('does not call fetchStreamProfiles when profiles already exist', () => { + renderWith({ + streamProfiles: [{ id: 'p1', name: 'Default' }], + fetchProfiles: mockFetchProfiles, + }); + expect(mockFetchProfiles).not.toHaveBeenCalled(); + }); + + it('calls getEPGs on mount', async () => { + renderWith(); + await waitFor(() => { + expect(getEPGs).toHaveBeenCalled(); + }); + }); + + it('initializes groupStates from playlist.channel_groups when channelGroups are loaded', async () => { + const channelGroups = { 10: { id: 10, name: 'Movies' } }; + const playlist = makePlaylist({ + id: 2, + channel_groups: [ + { + channel_group: 10, + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + auto_sync_channel_end: null, + custom_properties: {}, + }, + ], + }); + renderWith({ channelGroups, playlist, initialGroups: [] }); + await waitFor(() => { + expect(screen.getByText('Movies')).toBeInTheDocument(); + }); + }); + + it('skips groupStates initialization when channelGroups is empty', () => { + renderWith({ + channelGroups: {}, + playlist: makePlaylist({ + channel_groups: [ + { + channel_group: 1, + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + }, + ], + }), + initialGroups: [], + }); + // Init effect bails early — no group cards rendered + expect(screen.queryByTestId('group-card')).not.toBeInTheDocument(); + }); + + it('does not re-initialize groupStates for the same playlist/channelGroups key', async () => { + // Covers the lastInitKey guard preventing re-init on prop-reference changes + const channelGroups = { 1: { id: 1, name: 'Sports' } }; + const playlist = makePlaylist({ + id: 1, + channel_groups: [ + { + channel_group: 1, + enabled: true, + auto_channel_sync: false, + auto_sync_channel_start: 1, + custom_properties: {}, + }, + ], + }); + const { rerender } = renderWith({ channelGroups, playlist }); + await waitFor(() => + expect(screen.getByText('Sports')).toBeInTheDocument() + ); + + // Re-render with identical playlist reference — init must not fire again + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups }) + ); + rerender(); + // Still renders Sports; no duplicate init + expect(screen.getAllByText('Sports')).toHaveLength(1); + }); + }); + + // ── autoEnableNewGroupsLive checkbox ────────────────────────────────────── + + describe('autoEnableNewGroupsLive checkbox', () => { + it('toggles off when clicked while checked', () => { + renderWith({ initialAutoEnable: true }); + const cb = screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }); + fireEvent.click(cb); + expect(cb).not.toBeChecked(); + }); + + it('toggles on when clicked while unchecked', () => { + renderWith({ initialAutoEnable: false }); + const cb = screen.getByRole('checkbox', { + name: /Automatically enable new groups/i, + }); + fireEvent.click(cb); + expect(cb).toBeChecked(); + }); + }); + + // ── Group text filter ────────────────────────────────────────────────────── + + describe('group text filter', () => { + it('shows groups matching the filter and hides non-matching groups', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: 'sport' }, + }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + + it('shows all groups when filter is cleared', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: 'sport' }, + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: '' }, + }); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + it('calls isGroupVisible with the current filter text and status', () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })], + }); + fireEvent.change(screen.getByTestId('group-filter-input'), { + target: { value: 'sp' }, + }); + expect(isGroupVisible).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Sports' }), + 'sp', + 'all' + ); + }); + }); + + // ── Status filter ────────────────────────────────────────────────────────── + + describe('status filter', () => { + it('shows only enabled groups when Enabled filter is selected', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-enabled')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + + it('shows only disabled groups when Disabled filter is selected', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-disabled')); + expect(screen.queryByText('Sports')).not.toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + + it('passes the active status value to isGroupVisible', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + ], + }); + fireEvent.click(screen.getByTestId('seg-disabled')); + expect(isGroupVisible).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Sports' }), + '', + 'disabled' + ); + }); + + it('reverts to showing all groups when All filter is selected', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-enabled')); + fireEvent.click(screen.getByTestId('seg-all')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); + }); + + // ── Select Visible / Deselect Visible ───────────────────────────────────── + + describe('Select Visible / Deselect Visible', () => { + it('Select Visible enables all currently visible groups', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: false }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByText('Select Visible')); + // Both groups remain visible (isGroupVisible still returns true) + expect(screen.getAllByTestId('group-card')).toHaveLength(2); + }); + + it('Deselect Visible disables all currently visible groups', () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: true }), + ], + }); + // With status=all, isGroupVisible still returns true after deselect + fireEvent.click(screen.getByText('Deselect Visible')); + expect(screen.getAllByTestId('group-card')).toHaveLength(2); + }); + + it('Select Visible only applies to groups passing the current filter', () => { + // With Enabled filter active, only already-enabled groups are "visible" + // so disabling one and clicking Select Visible should not re-enable the hidden one + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports', enabled: true }), + makeGroup({ channel_group: 2, name: 'News', enabled: false }), + ], + }); + fireEvent.click(screen.getByTestId('seg-enabled')); + fireEvent.click(screen.getByText('Select Visible')); + // News (disabled) is not visible in Enabled filter, so it stays hidden + expect(screen.queryByText('News')).not.toBeInTheDocument(); + }); + }); + + // ── Configure modal ──────────────────────────────────────────────────────── + + describe('configure modal', () => { + it('opens when the Cog ActionIcon is clicked', () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + expect(screen.getByTestId('configure-modal')).toBeInTheDocument(); + }); + + it('passes the correct group id to GroupConfigureModal', () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 42 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + expect(screen.getByTestId('configure-modal')).toHaveAttribute( + 'data-group-id', + '42' + ); + }); + + it('closes on Done click', () => { + renderWith({ initialGroups: [makeGroup()] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + fireEvent.click(screen.getByTestId('modal-done')); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + + it('closes on Cancel click', () => { + renderWith({ initialGroups: [makeGroup()] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + fireEvent.click(screen.getByTestId('modal-cancel')); + expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument(); + }); + + it('renders AutoSyncAdvanced inside the modal with the correct group', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => { + expect(screen.getByTestId('auto-sync-advanced')).toBeInTheDocument(); + expect(screen.getByTestId('auto-sync-advanced')).toHaveAttribute( + 'data-group-id', + '1' + ); + }); + }); + + it('restores the group state when Cancel is clicked after a change', async () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })], + }); + // Open modal — snapshot is taken + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-apply-group')); + + // Mutate the group via AutoSyncAdvanced + fireEvent.click(screen.getByTestId('trigger-apply-group')); + expect(screen.getByText('Modified By Advanced')).toBeInTheDocument(); + + // Cancel should revert to the snapshot + fireEvent.click(screen.getByTestId('modal-cancel')); + expect(screen.getByText('Sports')).toBeInTheDocument(); + expect( + screen.queryByText('Modified By Advanced') + ).not.toBeInTheDocument(); + }); + + it('does not revert the group state when Done is clicked after a change', async () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1, name: 'Sports' })], + }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-apply-group')); + + fireEvent.click(screen.getByTestId('trigger-apply-group')); + fireEvent.click(screen.getByTestId('modal-done')); + + // The modified name should persist + expect(screen.getByText('Modified By Advanced')).toBeInTheDocument(); + }); + + it('triggers a regex preview when configuringGroup changes', async () => { + renderWith({ + initialGroups: [ + makeGroup({ + channel_group: 1, + custom_properties: { + name_regex_pattern: 'ESPN', + name_match_regex: '', + }, + }), + ], + }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => { + expect(getRegexOptions).toHaveBeenCalled(); + }); + }); + }); + + // ── Logo upload modal ────────────────────────────────────────────────────── + + describe('logo upload modal', () => { + const openLogoUpload = async () => { + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-logo-upload')); + fireEvent.click(screen.getByTestId('trigger-logo-upload')); + }; + + it('opens LogoForm when onOpenLogoUpload is called', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + await openLogoUpload(); + await waitFor(() => + expect(screen.getByTestId('logo-form')).toBeInTheDocument() + ); + }); + + it('closes LogoForm when onClose is called', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + await openLogoUpload(); + fireEvent.click(screen.getByTestId('logo-close')); + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + + it('applies custom_logo_id and calls ensureLogosLoaded on logo success', async () => { + renderWith({ + initialGroups: [makeGroup({ channel_group: 1 })], + ensureLogosLoaded: mockEnsureLogosLoaded, + }); + await openLogoUpload(); + + const callCountBefore = mockEnsureLogosLoaded.mock.calls.length; + fireEvent.click(screen.getByTestId('logo-success')); + + expect(mockEnsureLogosLoaded.mock.calls.length).toBeGreaterThan( + callCountBefore + ); + expect(screen.queryByTestId('logo-form')).not.toBeInTheDocument(); + }); + + it('does not apply logo when onSuccess provides no logo', async () => { + // Test the logo === null guard: LogoForm calls onSuccess({ logo: null }) + vi.mock('./Logo.jsx', () => ({ + default: ({ isOpen, onSuccess }) => + isOpen ? ( + + ) : null, + })); + // Without a valid logo object, ensureLogosLoaded should not be called again + renderWith({ + initialGroups: [makeGroup({ channel_group: 1 })], + ensureLogosLoaded: mockEnsureLogosLoaded, + }); + await openLogoUpload(); + // If the null-logo button renders, click it; otherwise the modal just closes + const nullBtn = screen.queryByTestId('logo-success-null'); + if (nullBtn) fireEvent.click(nullBtn); + // ensureLogosLoaded should only have been called once (on mount) + expect(mockEnsureLogosLoaded).toHaveBeenCalledTimes(1); + }); + }); + + // ── scheduleRegexPreview ─────────────────────────────────────────────────── + + describe('scheduleRegexPreview (via AutoSyncAdvanced)', () => { + it('passes onScheduleRegexPreview to AutoSyncAdvanced and it triggers without error', async () => { + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-regex-preview')); + expect(() => + fireEvent.click(screen.getByTestId('trigger-regex-preview')) + ).not.toThrow(); + }); + + it('clears regexPreview state when all pattern fields are empty', async () => { + // scheduleRegexPreview with empty opts should not call getStreamsRegexPreview + renderWith({ initialGroups: [makeGroup({ channel_group: 1 })] }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('auto-sync-advanced')); + // Trigger with empty patterns by mounting a group with no patterns set + // (the useEffect on configuringGroup?.channel_group fires getRegexOptions with empty strings) + expect(getStreamsRegexPreview).not.toHaveBeenCalled(); + }); + + it('calls getStreamsRegexPreview when non-empty patterns are provided', async () => { + vi.mocked(getStreamsRegexPreview).mockResolvedValue({ + find_matches: ['ESPN HD'], + find_match_count: 1, + total_in_group: 10, + total_scanned: 10, + scan_limit_hit: false, + }); + renderWith({ + initialGroups: [ + makeGroup({ + channel_group: 1, + custom_properties: { name_regex_pattern: 'ESPN' }, + }), + ], + }); + fireEvent.click(screen.getByTestId('icon-cog').closest('button')); + await waitFor(() => screen.getByTestId('trigger-regex-preview')); + fireEvent.click(screen.getByTestId('trigger-regex-preview')); + await waitFor(() => { + expect(getStreamsRegexPreview).toHaveBeenCalled(); + }); + }); + }); + + // ── applyGroupChange ─────────────────────────────────────────────────────── + + describe('applyGroupChange (via AutoSyncAdvanced)', () => { + it('updates only the target group in groupStates', async () => { + renderWith({ + initialGroups: [ + makeGroup({ channel_group: 1, name: 'Sports' }), + makeGroup({ channel_group: 2, name: 'News' }), + ], + }); + // Group cards will be sorted alphabetically, so the first cog button corresponds to the "News" group, not "Sports" — ensure we target the correct one + const cogButtons = screen.getAllByTestId('icon-cog'); + fireEvent.click(cogButtons[1].closest('button')); + await waitFor(() => screen.getByTestId('trigger-apply-group')); + fireEvent.click(screen.getByTestId('trigger-apply-group')); + + // Group 1 name changed; Group 2 unchanged + expect(screen.getByText('Modified By Advanced')).toBeInTheDocument(); + expect(screen.getByText('News')).toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/components/forms/__tests__/LoginForm.test.jsx b/frontend/src/components/forms/__tests__/LoginForm.test.jsx new file mode 100644 index 00000000..7e30f3cd --- /dev/null +++ b/frontend/src/components/forms/__tests__/LoginForm.test.jsx @@ -0,0 +1,475 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import LoginForm from '../LoginForm'; + +// ── Router mock ──────────────────────────────────────────────────────────────── +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, +})); + +// Mock localStorage +const localStorageMock = (() => { + let store = {}; + + return { + getItem: vi.fn((key) => store[key] || null), + setItem: vi.fn((key, value) => { + store[key] = value.toString(); + }), + clear: vi.fn(() => { + store = {}; + }), + removeItem: vi.fn((key) => { + delete store[key]; + }), + }; +})(); + +global.localStorage = localStorageMock; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/auth', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Asset mock ───────────────────────────────────────────────────────────────── +vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' })); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + Paper: ({ children }) =>
{children}
, + Title: ({ children }) =>

{children}

, + TextInput: ({ + label, + name, + value, + onChange, + placeholder, + type, + disabled, + }) => ( + + ), + Button: ({ children, onClick, loading, disabled, type, variant }) => ( + + ), + Center: ({ children }) =>
{children}
, + Stack: ({ children }) =>
{children}
, + Text: ({ children, c, size }) => ( + + {children} + + ), + Image: ({ src, alt }) => {alt}, + Group: ({ children }) =>
{children}
, + Divider: () =>
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Anchor: ({ children, onClick }) => ( + + {children} + + ), + Code: ({ children }) => {children}, + Checkbox: ({ label, checked, onChange }) => ( + + ), +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useAuthStore from '../../../store/auth'; +import useSettingsStore from '../../../store/settings'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const setupMocks = ({ + isAuthenticated = false, + loginResult = Promise.resolve(true), + version = '1.0.0', +} = {}) => { + const mockLogin = vi.fn().mockReturnValue(loginResult); + const mockLogout = vi.fn(); + const mockInitData = vi.fn().mockResolvedValue(undefined); + const mockFetchVersion = vi.fn().mockResolvedValue(undefined); + + vi.mocked(useAuthStore).mockImplementation((sel) => + sel({ + login: mockLogin, + logout: mockLogout, + isAuthenticated, + initData: mockInitData, + }) + ); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ + fetchVersion: mockFetchVersion, + version: { version }, + }) + ); + + return { mockLogin, mockLogout, mockInitData, mockFetchVersion }; +}; + +const renderLoginForm = () => render(); + +const getUsername = () => screen.getByTestId('input-Username'); +const getPassword = () => screen.getByTestId('input-Password'); +const getLoginButton = () => screen.getByRole('button', { type: 'submit' }); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('LoginForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the login form', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByTestId('paper')).toBeInTheDocument(); + }); + + it('renders the logo', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByRole('img')).toHaveAttribute('src', 'logo.png'); + }); + + it('renders username and password inputs', () => { + setupMocks(); + renderLoginForm(); + expect(getUsername()).toBeInTheDocument(); + expect(getPassword()).toBeInTheDocument(); + }); + + it('renders the login button', () => { + setupMocks(); + renderLoginForm(); + expect(getLoginButton()).toBeInTheDocument(); + }); + + it('renders "Remember Me" checkbox', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByLabelText(/remember me/i)).toBeInTheDocument(); + }); + + it('renders "Save Password" checkbox', async () => { + setupMocks(); + renderLoginForm(); + await waitFor(() => { + fireEvent.click(screen.getByLabelText(/remember me/i)); + expect(screen.getByLabelText(/save password/i)).toBeInTheDocument(); + }); + }); + + it('renders version info when version is available', () => { + setupMocks({ version: '2.3.4' }); + renderLoginForm(); + expect(screen.getByText(/2.3.4/i)).toBeInTheDocument(); + }); + + it('renders forgot password anchor', () => { + setupMocks(); + renderLoginForm(); + expect(screen.getByText(/forgot password/i)).toBeInTheDocument(); + }); + }); + + // ── fetchVersion on mount ────────────────────────────────────────────────── + + describe('on mount', () => { + it('calls fetchVersion on mount', () => { + const { mockFetchVersion } = setupMocks(); + renderLoginForm(); + expect(mockFetchVersion).toHaveBeenCalled(); + }); + }); + + // ── Form input ───────────────────────────────────────────────────────────── + + describe('form input', () => { + it('updates username field on change', () => { + setupMocks(); + renderLoginForm(); + fireEvent.change(getUsername(), { target: { value: 'testuser' } }); + expect(getUsername()).toHaveValue('testuser'); + }); + + it('updates password field on change', () => { + setupMocks(); + renderLoginForm(); + fireEvent.change(getPassword(), { target: { value: 'secret' } }); + expect(getPassword()).toHaveValue('secret'); + }); + + it('password input has type="password"', () => { + setupMocks(); + renderLoginForm(); + expect(getPassword()).toHaveAttribute('type', 'password'); + }); + }); + + // ── Successful login ─────────────────────────────────────────────────────── + + describe('successful login', () => { + it('calls login with username and password', async () => { + const { mockLogin } = setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'pass123' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith({ + username: 'admin', + password: 'pass123', + }); + }); + }); + + it('calls initData after successful login', async () => { + const { mockInitData } = setupMocks({ + loginResult: Promise.resolve(true), + }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'pass123' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockInitData).toHaveBeenCalled(); + }); + }); + + it('navigates to "/channels" when authenticated', async () => { + setupMocks({ isAuthenticated: true }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'pass123' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith('/channels'); + }); + }); + }); + + // ── Failed login ─────────────────────────────────────────────────────────── + + describe('failed login', () => { + it('does not navigate when login fails', async () => { + setupMocks({ loginResult: Promise.resolve(false) }); + renderLoginForm(); + + fireEvent.change(getUsername(), { target: { value: 'admin' } }); + fireEvent.change(getPassword(), { target: { value: 'wrong' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + it('does not navigate when login throws', async () => { + setupMocks({ loginResult: Promise.reject(new Error('fail')) }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Loading state ────────────────────────────────────────────────────────── + + describe('loading state', () => { + it('disables the login button while loading', async () => { + let resolveLogin; + const loginResult = new Promise((res) => { + resolveLogin = res; + }); + setupMocks({ loginResult }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(getLoginButton()).toBeDisabled(); + }); + + resolveLogin(true); + }); + + it('re-enables the login button after login completes', async () => { + setupMocks({ loginResult: Promise.resolve(false) }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(getLoginButton()).not.toBeDisabled(); + }); + }); + }); + + // ── Remember Me ─────────────────────────────────────────────────────────── + + describe('remember me', () => { + it('saves username to localStorage when Remember Me is checked', async () => { + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.click(screen.getByLabelText(/remember me/i)); + fireEvent.change(getUsername(), { target: { value: 'savedUser' } }); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(localStorage.getItem('dispatcharr_remembered_username')).toBe( + 'savedUser' + ); + }); + }); + + it('removes username from localStorage when Remember Me is unchecked', async () => { + localStorage.setItem('dispatcharr_remembered_username', 'oldUser'); + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.click(screen.getByLabelText(/remember me/i)); + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect( + localStorage.getItem('dispatcharr_remembered_username') + ).toBeNull(); + }); + }); + + it('pre-fills username from localStorage on mount', () => { + localStorage.setItem('dispatcharr_remembered_username', 'storedUser'); + setupMocks(); + renderLoginForm(); + expect(getUsername()).toHaveValue('storedUser'); + }); + }); + + // ── Save Password ───────────────────────────────────────────────────────── + + describe('save password', () => { + it('saves encoded password to localStorage when Save Password is checked', async () => { + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + await waitFor(() => { + fireEvent.click(screen.getByLabelText(/remember me/i)); + fireEvent.click(screen.getByLabelText(/save password/i)); + fireEvent.change(getPassword(), { target: { value: 'mySecret' } }); + fireEvent.click(getLoginButton()); + }); + + await waitFor(() => { + expect( + localStorage.getItem('dispatcharr_saved_password') + ).not.toBeNull(); + }); + }); + + it('removes password from localStorage when Save Password is unchecked', async () => { + localStorage.setItem('dispatcharr_saved_password', btoa('oldPass')); + setupMocks({ loginResult: Promise.resolve(true) }); + renderLoginForm(); + + fireEvent.click(getLoginButton()); + + await waitFor(() => { + expect(localStorage.getItem('dispatcharr_saved_password')).toBeNull(); + }); + }); + + it('pre-fills password from localStorage on mount', () => { + localStorage.setItem('dispatcharr_remembered_username', 'storedUser'); + localStorage.setItem('dispatcharr_saved_password', btoa('storedPass')); + setupMocks(); + renderLoginForm(); + expect(getPassword()).toHaveValue('storedPass'); + }); + }); + + // ── Forgot Password modal ────────────────────────────────────────────────── + + describe('forgot password modal', () => { + it('opens the forgot password modal when the anchor is clicked', () => { + setupMocks(); + renderLoginForm(); + + fireEvent.click(screen.getByText(/forgot password/i)); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('shows the modal title', () => { + setupMocks(); + renderLoginForm(); + + fireEvent.click(screen.getByText(/forgot password/i)); + expect(screen.getByTestId('modal-title')).toBeInTheDocument(); + }); + + it('closes the modal when the close button is clicked', () => { + setupMocks(); + renderLoginForm(); + + fireEvent.click(screen.getByText(/forgot password/i)); + fireEvent.click(screen.getByTestId('modal-close')); + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/Logo.test.jsx b/frontend/src/components/forms/__tests__/Logo.test.jsx new file mode 100644 index 00000000..9b3b0107 --- /dev/null +++ b/frontend/src/components/forms/__tests__/Logo.test.jsx @@ -0,0 +1,518 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import LogoForm from '../Logo'; + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/LogoUtils.js', () => ({ + createLogo: vi.fn(), + updateLogo: vi.fn(), + uploadLogo: vi.fn(), + getFilenameWithoutExtension: vi.fn((name) => name.replace(/\.[^.]+$/, '')), + getResolver: vi.fn(() => undefined), + getUpdateLogoErrorMessage: vi.fn((logo, err) => err.message), + getUploadErrorMessage: vi.fn((err) => err.message), + releaseUrl: vi.fn(), + validateFileSize: vi.fn(() => true), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Mantine dropzone ─────────────────────────────────────────────────────────── +vi.mock('@mantine/dropzone', () => ({ + Dropzone: vi.fn(({ children, onDrop }) => ( +
onDrop([])}> + {children} +
+ )), + DropzoneAccept: ({ children }) => ( +
{children}
+ ), + DropzoneReject: ({ children }) => ( +
{children}
+ ), + DropzoneIdle: ({ children }) =>
{children}
, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Button: ({ children, onClick, type, loading, variant }) => ( + + ), + Center: ({ children }) =>
{children}
, + Divider: ({ label }) =>
, + Group: ({ children }) =>
{children}
, + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, color }) => ( + + {children} + + ), + TextInput: ({ + label, + placeholder, + onChange, + onBlur, + error, + disabled, + ...rest + }) => ( +
+ + {error && {error}} +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + FileImage: () => , + Upload: () => , + X: () => , +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import * as LogoUtils from '../../../utils/forms/LogoUtils.js'; +import { showNotification } from '../../../utils/notificationUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeLogo = (overrides = {}) => ({ + id: 1, + name: 'My Logo', + url: 'https://example.com/logo.png', + cache_url: 'https://cdn.example.com/logo.png', + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + logo: null, + isOpen: true, + onClose: vi.fn(), + onSuccess: vi.fn(), + ...overrides, +}); + +const makeFile = (name = 'test-logo.png', size = 1024) => + new File(['content'], name, { type: 'image/png', size }); + +// ────────────────────────────────────────────────────────────────────────────── + +describe('LogoForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(LogoUtils.validateFileSize).mockReturnValue(true); + vi.mocked(LogoUtils.createLogo).mockResolvedValue({ + id: 2, + name: 'New Logo', + url: 'https://example.com/new.png', + }); + vi.mocked(LogoUtils.updateLogo).mockResolvedValue({ + id: 1, + name: 'Updated Logo', + url: 'https://example.com/updated.png', + }); + vi.mocked(LogoUtils.uploadLogo).mockResolvedValue({ + id: 3, + name: 'uploaded-logo', + url: 'https://cdn.example.com/uploaded.png', + }); + vi.mocked(LogoUtils.getResolver).mockReturnValue(undefined); + + URL.createObjectURL = vi.fn(); + }); + + afterEach(() => { + delete URL.createObjectURL; + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true', () => { + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render the modal when isOpen is false', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('shows "Add Logo" title for new logo', () => { + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Add Logo'); + }); + + it('shows "Edit Logo" title when editing existing logo', () => { + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Logo'); + }); + + it('shows "Create" button for new logo', () => { + render(); + expect(screen.getByText('Create')).toBeInTheDocument(); + }); + + it('shows "Update" button when editing existing logo', () => { + render(); + expect(screen.getByText('Update')).toBeInTheDocument(); + }); + + it('pre-fills name input when editing existing logo', () => { + render(); + expect(screen.getByDisplayValue('My Logo')).toBeInTheDocument(); + }); + + it('pre-fills URL input when editing existing logo', () => { + render(); + expect( + screen.getByDisplayValue('https://example.com/logo.png') + ).toBeInTheDocument(); + }); + + it('shows logo preview when logo has cache_url', () => { + render(); + const img = screen.getByAltText('Logo preview'); + expect(img).toHaveAttribute('src', 'https://cdn.example.com/logo.png'); + }); + + it('does not show preview when no logo provided', () => { + render(); + expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument(); + }); + + it('renders the dropzone', () => { + render(); + expect(screen.getByTestId('dropzone')).toBeInTheDocument(); + }); + + it('calls onClose when Cancel is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByText('Cancel')); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when modal close button is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Create logo ──────────────────────────────────────────────────────────── + + describe('creating a logo via URL', () => { + it('calls createLogo with entered values on submit', async () => { + render(); + + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/new.png' }, + } + ); + fireEvent.change(screen.getByPlaceholderText('Enter logo name'), { + target: { value: 'New Logo' }, + }); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(LogoUtils.createLogo).toHaveBeenCalled(); + }); + }); + + it('shows success notification after creating logo', async () => { + render(); + + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/new.png' }, + } + ); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Success', color: 'green' }) + ); + }); + }); + + it('calls onSuccess with type "create" after creating logo', async () => { + const onSuccess = vi.fn(); + render(); + + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/new.png' }, + } + ); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ type: 'create' }) + ); + }); + }); + + it('calls onClose after creating logo', async () => { + const onClose = vi.fn(); + render(); + + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/new.png' }, + } + ); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('shows error notification when createLogo throws', async () => { + vi.mocked(LogoUtils.createLogo).mockRejectedValue(new Error('API error')); + render(); + + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/new.png' }, + } + ); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ); + }); + }); + }); + + // ── Update logo ──────────────────────────────────────────────────────────── + + describe('updating an existing logo', () => { + it('calls updateLogo on submit', async () => { + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(LogoUtils.updateLogo).toHaveBeenCalledWith( + makeLogo(), + expect.objectContaining({ name: 'My Logo' }) + ); + }); + }); + + it('calls onSuccess with type "update" after updating logo', async () => { + const onSuccess = vi.fn(); + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ type: 'update' }) + ); + }); + }); + + it('shows success notification after updating logo', async () => { + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Success', color: 'green' }) + ); + }); + }); + + it('shows error notification when updateLogo throws', async () => { + vi.mocked(LogoUtils.updateLogo).mockRejectedValue( + new Error('Server error') + ); + render(); + fireEvent.click(screen.getByText('Update')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ); + }); + }); + }); + + // ── File upload ──────────────────────────────────────────────────────────── + + describe('file upload via dropzone', () => { + it('calls uploadLogo with the selected file on submit', async () => { + const file = makeFile('my-logo.png'); + + // Override dropzone to pass our test file + const { Dropzone } = await import('@mantine/dropzone'); + vi.mocked(Dropzone).mockImplementation(({ children, onDrop }) => ( +
onDrop([file])}> + {children} +
+ )); + + render(); + + fireEvent.click(screen.getByTestId('dropzone')); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(LogoUtils.uploadLogo).toHaveBeenCalledWith( + file, + expect.any(Object) + ); + }); + }); + + it('shows error notification when file is too large', async () => { + vi.mocked(LogoUtils.validateFileSize).mockReturnValue(false); + + // Override dropzone to pass a file + const file = makeFile('big.png', 10 * 1024 * 1024); + const { Dropzone } = await import('@mantine/dropzone'); + vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => ( +
onDrop([file])}> + {children} +
+ )); + + render(); + fireEvent.click(screen.getByTestId('dropzone')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Error', color: 'red' }) + ); + }); + }); + + it('shows upload error notification when uploadLogo throws', async () => { + vi.mocked(LogoUtils.uploadLogo).mockRejectedValue( + new Error('Upload failed') + ); + const file = makeFile('logo.png'); + const { Dropzone } = await import('@mantine/dropzone'); + vi.mocked(Dropzone).mockImplementationOnce(({ children, onDrop }) => ( +
onDrop([file])}> + {children} +
+ )); + + render(); + fireEvent.click(screen.getByTestId('dropzone')); + fireEvent.click(screen.getByText('Create')); + + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Upload Error', color: 'red' }) + ); + }); + }); + }); + + // ── URL input behaviour ──────────────────────────────────────────────────── + + describe('URL input behaviour', () => { + it('updates preview when a valid http URL is entered', () => { + render(); + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/img.png' }, + } + ); + expect(screen.getByAltText('Logo preview')).toHaveAttribute( + 'src', + 'https://example.com/img.png' + ); + }); + + it('removes preview when URL is cleared', () => { + render(); + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: '' }, + } + ); + expect(screen.queryByAltText('Logo preview')).not.toBeInTheDocument(); + }); + + it('auto-fills name from URL on blur', () => { + render(); + fireEvent.change( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/my-channel-logo.png' }, + } + ); + fireEvent.blur( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'https://example.com/my-channel-logo.png' }, + } + ); + expect(LogoUtils.getFilenameWithoutExtension).toHaveBeenCalled(); + }); + + it('does not throw on blur with invalid URL', () => { + render(); + expect(() => { + fireEvent.blur( + screen.getByPlaceholderText('https://example.com/logo.png'), + { + target: { value: 'not-a-url' }, + } + ); + }).not.toThrow(); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/M3U.test.jsx b/frontend/src/components/forms/__tests__/M3U.test.jsx new file mode 100644 index 00000000..7e26d97a --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3U.test.jsx @@ -0,0 +1,708 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3U from '../M3U'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/userAgents', () => ({ default: vi.fn() })); +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/epgs', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uUtils.js', () => ({ + addPlaylist: vi.fn(), + getPlaylist: vi.fn(), + prepareSubmitValues: vi.fn((values) => values), + updatePlaylist: vi.fn(), +})); + +vi.mock('../../../utils/forms/DummyEpgUtils.js', () => ({ + addEPG: vi.fn(), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── Sub-component mocks ──────────────────────────────────────────────────────── +vi.mock('../M3UProfiles', () => ({ + default: ({ onChange }) => ( +
+ +
+ ), +})); + +vi.mock('../M3UGroupFilter', () => ({ + default: ({ onChange }) => ( +
+ +
+ ), +})); + +vi.mock('../M3UFilters', () => ({ + default: ({ onChange }) => ( +
+ +
+ ), +})); + +vi.mock('../ScheduleInput', () => ({ + default: ({ onChange, value }) => ( +
+ onChange?.(e.target.value)} + /> +
+ ), +})); + +// ── Mantine dates ────────────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DateTimePicker: ({ label, value, onChange, placeholder }) => ( +
+ +
+ ), +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => { + let _values = null; + + return { + isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')), + __resetFormState: () => { + _values = null; + }, + useForm: vi.fn(({ initialValues = {} } = {}) => { + if (_values === null) { + _values = { ...initialValues }; + } + + return { + key: vi.fn((field) => field), + getValues: () => ({ ..._values }), + setValues: (v) => { + Object.assign(_values, v); + }, + setFieldValue: (field, val) => { + _values[field] = val; + }, + reset: () => { + _values = { ...initialValues }; + }, + submitting: false, + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + if (_values?.name) handler(); + }), + getInputProps: vi.fn((field) => ({ + value: _values?.[field] ?? '', + onChange: vi.fn((e) => { + const val = e?.target?.value ?? e; + if (_values) _values[field] = val; + }), + error: null, + })), + }; + }), + }; +}); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Button: ({ children, onClick, type, loading, disabled, variant, color }) => ( + + ), + Checkbox: ({ label, checked, onChange, disabled }) => ( + + ), + Divider: ({ label }) =>
, + FileInput: ({ label, placeholder, onChange, accept, disabled }) => ( +
+ +
+ ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + LoadingOverlay: ({ visible }) => + visible ?
: null, + Modal: ({ children, opened, onClose, title, size }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + NumberInput: ({ + label, + placeholder, + value, + onChange, + min, + max, + disabled, + error, + }) => ( +
+ + {error && {error}} +
+ ), + PasswordInput: ({ + label, + placeholder, + value, + onChange, + onBlur, + error, + disabled, + }) => ( +
+ + {error && {error}} +
+ ), + Select: ({ label, placeholder, value, onChange, data, disabled, error }) => ( +
+ + {error && {error}} +
+ ), + Stack: ({ children }) =>
{children}
, + Switch: ({ label, checked, onChange, disabled }) => ( + + ), + TextInput: ({ + id, + label, + placeholder, + value, + onChange, + onBlur, + error, + disabled, + ...rest + }) => ( +
+ + {error && {error}} +
+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useUserAgentsStore from '../../../store/userAgents'; +import useChannelsStore from '../../../store/channels'; +import useEPGsStore from '../../../store/epgs'; +import useVODStore from '../../../store/useVODStore'; +import * as M3uUtils from '../../../utils/forms/M3uUtils.js'; +import * as DummyEpgUtils from '../../../utils/forms/DummyEpgUtils.js'; +import * as mantineForm from '@mantine/form'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeM3uAccount = (overrides = {}) => ({ + id: 1, + name: 'Test M3U', + server_url: 'http://example.com/playlist.m3u', + username: 'user1', + password: 'pass1', + account_type: 'XC', + max_streams: 0, + refresh_interval: 24, + auto_refresh: false, + is_active: true, + custom_properties: {}, + ...overrides, +}); + +const makeUserAgent = (overrides = {}) => ({ + id: 1, + name: 'Default Agent', + user_agent: 'Mozilla/5.0', + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + m3uAccount: null, + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = (overrides = {}) => { + const fetchUserAgents = vi.fn(); + const fetchChannelGroups = vi.fn(); + const fetchEPGs = vi.fn(); + const fetchCategories = vi.fn(); + + useUserAgentsStore.mockImplementation((selector) => { + const state = { + userAgents: overrides.userAgents || [], + fetchUserAgents, + }; + return selector(state); + }); + + useChannelsStore.mockImplementation((selector) => { + const state = { + fetchChannelGroups, + }; + return selector(state); + }); + + useEPGsStore.mockImplementation((selector) => { + const state = { + fetchEPGs, + }; + return selector(state); + }); + + useVODStore.mockImplementation((selector) => { + const state = { + fetchCategories, + }; + return selector(state); + }); + + return { + fetchUserAgents, + fetchChannelGroups, + fetchEPGs, + fetchCategories, + }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3U', () => { + beforeEach(() => { + vi.clearAllMocks(); + mantineForm.__resetFormState(); + vi.mocked(M3uUtils.addPlaylist).mockResolvedValue( + makeM3uAccount({ id: 2 }) + ); + vi.mocked(M3uUtils.updatePlaylist).mockResolvedValue(makeM3uAccount()); + vi.mocked(M3uUtils.getPlaylist).mockResolvedValue(makeM3uAccount()); + vi.mocked(M3uUtils.prepareSubmitValues).mockImplementation((v) => v); + vi.mocked(DummyEpgUtils.addEPG).mockResolvedValue({ + id: 10, + name: 'Dummy EPG', + }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render the modal when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders Name input', () => { + setupStores(); + render(); + expect(screen.getByTestId('text-input-name')).toBeInTheDocument(); + }); + + it('renders URL input', () => { + setupStores(); + render(); + expect(screen.getByTestId('text-input-server_url')).toBeInTheDocument(); + }); + + it('renders submit button with "Add" label for new account', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /add|create|save/i }) + ).toBeInTheDocument(); + }); + + it('renders submit button with "Update" or "Save" label for existing account', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /update|save/i }) + ).toBeInTheDocument(); + }); + + it('pre-fills name when editing', () => { + setupStores(); + render(); + expect(screen.getByDisplayValue('Test M3U')).toBeInTheDocument(); + }); + + it('pre-fills URL when editing', () => { + setupStores(); + render(); + expect( + screen.getByDisplayValue('http://example.com/playlist.m3u') + ).toBeInTheDocument(); + }); + + it('renders M3UProfiles sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('m3u-profiles')).toBeInTheDocument(); + }); + + it('renders M3UGroupFilter sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('m3u-group-filter')).toBeInTheDocument(); + }); + + it('renders M3UFilters sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('m3u-filters')).toBeInTheDocument(); + }); + + it('renders ScheduleInput sub-component', () => { + setupStores(); + render(); + expect(screen.getByTestId('schedule-input')).toBeInTheDocument(); + }); + + it('populates user agent select with agents from store', () => { + setupStores({ + userAgents: [ + makeUserAgent({ id: 1, name: 'Agent One' }), + makeUserAgent({ id: 2, name: 'Agent Two' }), + ], + }); + render(); + expect(screen.getByText('Agent One')).toBeInTheDocument(); + expect(screen.getByText('Agent Two')).toBeInTheDocument(); + }); + }); + + // ── Cancel behaviour ─────────────────────────────────────────────────────── + + describe('cancel behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Adding a playlist ────────────────────────────────────────────────────── + + describe('adding a new playlist', () => { + const fillRequiredFields = () => { + const nameInput = screen.getByTestId('text-input-name'); + fireEvent.change(nameInput, { target: { value: 'New Playlist' } }); + + const urlInput = screen.getByTestId('text-input-server_url'); + if (urlInput) { + fireEvent.change(urlInput, { + target: { value: 'http://example.com/new.m3u' }, + }); + } + }; + + it('calls addPlaylist on valid submit', async () => { + setupStores(); + render(); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(M3uUtils.addPlaylist).toHaveBeenCalled(); + }); + }); + + it('calls prepareSubmitValues before addPlaylist', async () => { + setupStores(); + render(); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(M3uUtils.prepareSubmitValues).toHaveBeenCalled(); + }); + }); + + it('calls onClose after successful add', async () => { + const onClose = vi.fn(); + setupStores(); + render( + + ); + fillRequiredFields(); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── Updating a playlist ──────────────────────────────────────────────────── + + describe('updating an existing playlist', () => { + it('calls updatePlaylist on submit', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /update|save/i })); + await waitFor(() => { + expect(M3uUtils.updatePlaylist).toHaveBeenCalled(); + }); + }); + + it('does not call addPlaylist when updating', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /update|save/i })); + await waitFor(() => { + expect(M3uUtils.updatePlaylist).toHaveBeenCalled(); + }); + expect(M3uUtils.addPlaylist).not.toHaveBeenCalled(); + }); + + it('calls onClose after successful update', async () => { + const onClose = vi.fn(); + setupStores(); + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /update|save/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── Form validation ──────────────────────────────────────────────────────── + + describe('form validation', () => { + it('does not call addPlaylist when Name is empty', async () => { + setupStores(); + render(); + // Clear name if pre-filled, then submit + const nameInput = screen.getByTestId('text-input-name'); + fireEvent.change(nameInput, { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await new Promise((r) => setTimeout(r, 50)); + expect(M3uUtils.addPlaylist).not.toHaveBeenCalled(); + }); + }); + + // ── Refresh / schedule behaviour ─────────────────────────────────────────── + + describe('schedule and refresh', () => { + it('renders the ScheduleInput', () => { + setupStores(); + render(); + expect(screen.getByTestId('schedule-input')).toBeInTheDocument(); + }); + }); + + // ── Max streams ──────────────────────────────────────────────────────────── + + describe('max streams field', () => { + it('renders max streams number input', () => { + setupStores(); + render(); + expect( + screen.getByRole('spinbutton', { name: /max.?stream/i }) + ).toBeInTheDocument(); + }); + + it('pre-fills max_streams when editing', () => { + setupStores(); + render( + + ); + expect(screen.getByDisplayValue('5')).toBeInTheDocument(); + }); + }); + + // ── User agent select ────────────────────────────────────────────────────── + + describe('user agent select', () => { + it('renders user agent select', () => { + setupStores(); + render(); + expect( + screen.getByRole('combobox', { name: /user.?agent/i }) + ).toBeInTheDocument(); + }); + }); + + // ── Credential fields ────────────────────────────────────────────────────── + + describe('credential fields', () => { + it('renders username input', () => { + setupStores(); + render(); + expect(screen.getByTestId('text-input-username')).toBeInTheDocument(); + }); + + it('renders password input', () => { + setupStores(); + render(); + expect( + document.querySelector('input[type="password"]') + ).toBeInTheDocument(); + }); + + it('pre-fills username when editing', () => { + setupStores(); + render(); + expect(screen.getByDisplayValue('user1')).toBeInTheDocument(); + }); + }); + + // ── Dummy EPG creation ───────────────────────────────────────────────────── + + describe('dummy EPG auto-creation', () => { + it('calls addEPG after successfully adding a playlist', async () => { + setupStores(); + render(); + + const nameInput = screen.getByTestId('text-input-name'); + fireEvent.change(nameInput, { target: { value: 'My Playlist' } }); + + const urlInput = screen.getByTestId('text-input-server_url'); + if (urlInput) { + fireEvent.change(urlInput, { + target: { value: 'http://example.com/p.m3u' }, + }); + } + + fireEvent.click(screen.getByRole('button', { name: /add|create|save/i })); + await waitFor(() => { + expect(M3uUtils.addPlaylist).toHaveBeenCalled(); + }); + // addEPG may be called conditionally — assert it was called or not based on a checkbox + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/M3UFilter.test.jsx b/frontend/src/components/forms/__tests__/M3UFilter.test.jsx new file mode 100644 index 00000000..fc71f4b8 --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UFilter.test.jsx @@ -0,0 +1,410 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UFilter from '../M3UFilter'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uFilterUtils.js', () => ({ + addM3UFilter: vi.fn(), + updateM3UFilter: vi.fn(), +})); + +vi.mock('../../../utils', () => ({ + setCustomProperty: vi.fn((obj, key, value) => ({ ...obj, [key]: value })), +})); + +// ── Constants mock ───────────────────────────────────────────────────────────── +vi.mock('../../../constants', () => ({ + M3U_FILTER_TYPES: [ + { value: 'include', label: 'Include' }, + { value: 'exclude', label: 'Exclude' }, + ], +})); + +// ── Mantine form ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/form', () => { + let _values = null; + + return { + isNotEmpty: vi.fn(() => (val) => (val ? null : 'Required')), + __resetFormState: () => { + _values = null; + }, + useForm: vi.fn(({ initialValues = {} } = {}) => { + if (_values === null) { + _values = { ...initialValues }; + } + + return { + key: vi.fn((field) => field), + getValues: () => ({ ..._values }), + setValues: (v) => { + Object.assign(_values, v); + }, + setFieldValue: (field, val) => { + _values[field] = val; + }, + reset: () => { + _values = { ...initialValues }; + }, + submitting: false, + onSubmit: vi.fn((handler) => (e) => { + e?.preventDefault?.(); + handler(); + }), + getInputProps: vi.fn((field, options = {}) => { + const isCheckbox = options?.type === 'checkbox'; + return { + ...(isCheckbox + ? { checked: !!_values?.[field] } + : { value: _values?.[field] ?? '' }), + onChange: vi.fn((e) => { + const val = isCheckbox + ? (e?.target?.checked ?? e) + : (e?.target?.value ?? e); + if (_values) _values[field] = val; + }), + error: null, + }; + }), + }; + }), + }; +}); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Box: ({ children }) =>
{children}
, + Button: ({ children, onClick, type, loading, disabled, variant, color }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Select: ({ label, placeholder, value, onChange, data, error, disabled }) => ( +
+ + {error && {error}} +
+ ), + Stack: ({ children }) =>
{children}
, + Switch: ({ id, label, checked, onChange, disabled }) => ( + + ), + TextInput: ({ + label, + placeholder, + value, + onChange, + onBlur, + error, + disabled, + }) => ( +
+ + {error && {error}} +
+ ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import usePlaylistsStore from '../../../store/playlists'; +import * as M3uFilterUtils from '../../../utils/forms/M3uFilterUtils.js'; +import * as mantineForm from '@mantine/form'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeM3U = (overrides = {}) => ({ + id: 1, + name: 'Test Playlist', + custom_properties: {}, + filters: [], + ...overrides, +}); + +const makeFilter = (overrides = {}) => ({ + id: 10, + filter_type: 'include', + regex_pattern: 'HBO.*', + exclude: false, + custom_properties: { case_sensitive: false }, + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + filter: null, + m3u: makeM3U(), + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = ({ + fetchPlaylist = vi.fn().mockResolvedValue(undefined), +} = {}) => { + vi.mocked(usePlaylistsStore).mockImplementation((sel) => + sel({ fetchPlaylist }) + ); + return { fetchPlaylist }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3UFilter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mantineForm.__resetFormState(); + vi.mocked(M3uFilterUtils.addM3UFilter).mockResolvedValue({ id: 99 }); + vi.mocked(M3uFilterUtils.updateM3UFilter).mockResolvedValue({}); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('renders modal title as "Filter"', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Filter'); + }); + + it('renders filter type Select', () => { + setupStores(); + render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('renders filter type options from M3U_FILTER_TYPES', () => { + setupStores(); + render(); + expect( + screen.getByRole('option', { name: 'Include' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: 'Exclude' }) + ).toBeInTheDocument(); + }); + + it('renders a text input for the pattern', () => { + setupStores(); + render(); + expect(screen.getByLabelText('Regex Pattern')).toBeInTheDocument(); + }); + + it('renders the exclusion switch', () => { + setupStores(); + render(); + expect(screen.getByTestId('exclude')).toBeInTheDocument(); + }); + + it('renders the case sensitivity switch', () => { + setupStores(); + render(); + expect(screen.getByTestId('case_sensitive')).toBeInTheDocument(); + }); + + it('renders a submit button', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /add|save|submit/i }) + ).toBeInTheDocument(); + }); + }); + + // ── Form reset on open/filter change ────────────────────────────────────── + + describe('form reset behaviour', () => { + it('shows empty pattern field for a new filter', () => { + setupStores(); + render(); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + }); + + // ── Cancel behaviour ─────────────────────────────────────────────────────── + + describe('cancel behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Adding a filter ──────────────────────────────────────────────────────── + + describe('adding a new filter', () => { + const fillForm = () => { + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'include' }, + }); + fireEvent.change(screen.getByRole('textbox'), { + target: { value: 'Sports.*' }, + }); + }; + + it('calls addM3UFilter on submit for a new filter', async () => { + setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(M3uFilterUtils.addM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not call updateM3UFilter when adding', async () => { + setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(M3uFilterUtils.addM3UFilter).toHaveBeenCalled(); + }); + expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled(); + }); + + it('calls fetchPlaylist after successful add', async () => { + const { fetchPlaylist } = setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id); + }); + }); + + it('calls onClose after successful add', async () => { + const onClose = vi.fn(); + setupStores(); + render(); + fillForm(); + fireEvent.click(screen.getByRole('button', { name: /add|save|submit/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); + + // ── Updating a filter ────────────────────────────────────────────────────── + + describe('updating an existing filter', () => { + it('calls updateM3UFilter on submit for an existing filter', async () => { + setupStores(); + render(); + fireEvent.click( + screen.getByRole('button', { name: /add|save|update|submit/i }) + ); + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not call addM3UFilter when updating', async () => { + setupStores(); + render(); + fireEvent.click( + screen.getByRole('button', { name: /add|save|update|submit/i }) + ); + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled(); + }); + expect(M3uFilterUtils.addM3UFilter).not.toHaveBeenCalled(); + }); + + it('calls fetchPlaylist after successful update', async () => { + const { fetchPlaylist } = setupStores(); + render(); + fireEvent.click( + screen.getByRole('button', { name: /add|save|update|submit/i }) + ); + await waitFor(() => { + expect(fetchPlaylist).toHaveBeenCalledWith(makeM3U().id); + }); + }); + + it('calls onClose after successful update', async () => { + const onClose = vi.fn(); + setupStores(); + render( + + ); + fireEvent.click( + screen.getByRole('button', { name: /add|save|update|submit/i }) + ); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/M3UFilters.test.jsx b/frontend/src/components/forms/__tests__/M3UFilters.test.jsx new file mode 100644 index 00000000..4adb4ada --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UFilters.test.jsx @@ -0,0 +1,594 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UFilters from '../M3UFilters'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists', () => ({ default: vi.fn() })); +vi.mock('../../../store/warnings', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uFilterUtils.js', () => ({ + deleteM3UFilter: vi.fn(), + updateM3UFilter: vi.fn(), +})); + +// ── Constants mock ───────────────────────────────────────────────────────────── +vi.mock('../../../constants', () => ({ + M3U_FILTER_TYPES: [ + { value: 'include', label: 'Include' }, + { value: 'exclude', label: 'Exclude' }, + ], +})); + +// ── Sub-component mocks ──────────────────────────────────────────────────────── +vi.mock('../M3UFilter', () => ({ + default: ({ isOpen, onClose, filter, m3u }) => + isOpen ? ( +
+
{filter?.id ?? 'new'}
+ + +
+ ) : null, +})); + +vi.mock('../../ConfirmationDialog', () => ({ + default: ({ opened, onConfirm, onClose, title }) => + opened ? ( +
+
{title}
+ + +
+ ) : null, +})); + +// ── DnD kit mocks ────────────────────────────────────────────────────────────── +vi.mock('@dnd-kit/core', () => ({ + closestCenter: vi.fn(), + DndContext: vi.fn(({ children, onDragEnd }) => ( +
+ {children} +
+ )), + KeyboardSensor: vi.fn(), + MouseSensor: vi.fn(), + TouchSensor: vi.fn(), + useDraggable: () => ({ + attributes: {}, + listeners: {}, + setNodeRef: vi.fn(), + }), + useSensor: vi.fn((sensor) => sensor), + useSensors: vi.fn((...sensors) => sensors), +})); + +vi.mock('@dnd-kit/sortable', () => ({ + arrayMove: vi.fn((arr, from, to) => { + const result = [...arr]; + const [item] = result.splice(from, 1); + result.splice(to, 0, item); + return result; + }), + SortableContext: ({ children }) => ( +
{children}
+ ), + useSortable: () => ({ + transform: null, + transition: null, + setNodeRef: vi.fn(), + isDragging: false, + }), + verticalListSortingStrategy: vi.fn(), +})); + +vi.mock('@dnd-kit/utilities', () => ({ + CSS: { Transform: { toString: vi.fn(() => '') } }, +})); + +vi.mock('@dnd-kit/modifiers', () => ({ + restrictToVerticalAxis: vi.fn(), +})); + +// ── lucide-react mocks ───────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + GripHorizontal: () => , + Info: () => , + SquareMinus: () => , + SquarePen: () => , +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled, color }) => ( + + ), + Alert: ({ children, title }) => ( +
+
{title}
+ {children} +
+ ), + Box: ({ children, ref }) =>
{children}
, + Button: ({ children, onClick, disabled, loading, variant, color }) => ( + + ), + Center: ({ children }) =>
{children}
, + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + useMantineTheme: () => ({ + tailwind: { + red: { 6: '#f56565' }, + green: { 5: '#48bb78' }, + yellow: { 3: '#ecc94b' }, + }, + }), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import usePlaylistsStore from '../../../store/playlists'; +import useWarningsStore from '../../../store/warnings'; +import * as M3uFilterUtils from '../../../utils/forms/M3uFilterUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makeFilter = (overrides = {}) => ({ + id: 1, + filter_type: 'include', + regex_pattern: 'HBO.*', + is_active: true, + exclude: false, + order: 0, + ...overrides, +}); + +const makePlaylist = (overrides = {}) => ({ + id: 10, + name: 'Test Playlist', + filters: [ + makeFilter({ id: 1, regex_pattern: 'HBO.*', order: 0 }), + makeFilter({ + id: 2, + filter_type: 'exclude', + regex_pattern: 'ESPN.*', + order: 1, + }), + ], + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + playlist: makePlaylist(), + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = ({ + fetchPlaylist = vi.fn().mockResolvedValue(undefined), + isWarningSuppressed = vi.fn().mockReturnValue(false), + suppressWarning = vi.fn(), +} = {}) => { + vi.mocked(usePlaylistsStore).mockImplementation((sel) => + sel({ fetchPlaylist }) + ); + vi.mocked(useWarningsStore).mockImplementation((sel) => + sel({ isWarningSuppressed, suppressWarning }) + ); + return { fetchPlaylist, isWarningSuppressed, suppressWarning }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3UFilters', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(M3uFilterUtils.deleteM3UFilter).mockResolvedValue(undefined); + vi.mocked(M3uFilterUtils.updateM3UFilter).mockResolvedValue(undefined); + }); + + // ── Guard conditions ─────────────────────────────────────────────────────── + + describe('guard conditions', () => { + it('does not render modal when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('does not render modal when playlist is null', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('does not render modal when playlist has no id', () => { + setupStores(); + render( + + ); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true with a valid playlist', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('renders the modal title', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal-title')).toBeInTheDocument(); + }); + + it('renders an "Add Filter" button', () => { + setupStores(); + render(); + expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); + }); + + it('renders filter patterns from playlist', () => { + setupStores(); + render(); + expect(screen.getByText('HBO.*')).toBeInTheDocument(); + expect(screen.getByText('ESPN.*')).toBeInTheDocument(); + }); + + it('renders filter type labels for each filter', () => { + setupStores(); + render(); + expect(screen.getAllByText('Include').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Exclude').length).toBeGreaterThanOrEqual(1); + }); + + it('renders edit action icons for each filter', () => { + setupStores(); + render(); + const penIcons = screen.getAllByTestId('icon-square-pen'); + expect(penIcons).toHaveLength(2); + }); + + it('renders delete action icons for each filter', () => { + setupStores(); + render(); + const minusIcons = screen.getAllByTestId('icon-square-minus'); + expect(minusIcons).toHaveLength(2); + }); + + it('renders drag handle icons for each filter', () => { + setupStores(); + render(); + const gripIcons = screen.getAllByTestId('icon-grip'); + expect(gripIcons).toHaveLength(2); + }); + + it('renders empty state when playlist has no filters', () => { + setupStores(); + const playlist = makePlaylist({ + filters: [], + }); + render(); + expect(screen.queryByTestId('icon-square-pen')).not.toBeInTheDocument(); + }); + + it('wraps list in DndContext', () => { + setupStores(); + render(); + expect(screen.getByTestId('dnd-context')).toBeInTheDocument(); + }); + + it('wraps list in SortableContext', () => { + setupStores(); + render(); + expect(screen.getByTestId('sortable-context')).toBeInTheDocument(); + }); + }); + + // ── Close / cancel ───────────────────────────────────────────────────────── + + describe('close behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── Opening the filter editor ────────────────────────────────────────────── + + describe('opening the filter editor', () => { + it('opens M3UFilter editor when Add Filter is clicked', () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + expect(screen.getByTestId('m3u-filter-editor')).toBeInTheDocument(); + }); + + it('passes null filter to editor when adding a new filter', () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + expect(screen.getByTestId('editor-filter-id')).toHaveTextContent('new'); + }); + + it('opens editor with the correct filter when edit icon is clicked', () => { + setupStores(); + render(); + const editButtons = screen + .getAllByTestId('icon-square-pen') + .map((icon) => icon.closest('button')); + fireEvent.click(editButtons[0]); + expect(screen.getByTestId('m3u-filter-editor')).toBeInTheDocument(); + expect(screen.getByTestId('editor-filter-id')).toHaveTextContent('1'); + }); + + it('closes editor when editor fires onClose without updated playlist', () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + fireEvent.click(screen.getByTestId('editor-close')); + expect(screen.queryByTestId('m3u-filter-editor')).not.toBeInTheDocument(); + }); + + it('closes editor and refreshes filters when editor fires onClose with playlist', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /new/i })); + fireEvent.click(screen.getByTestId('editor-close-with-playlist')); + expect(screen.queryByTestId('m3u-filter-editor')).not.toBeInTheDocument(); + }); + }); + + // ── Delete filter ────────────────────────────────────────────────────────── + + describe('deleting a filter', () => { + const clickDeleteFirst = () => { + const deleteButtons = screen + .getAllByTestId('icon-square-minus') + .map((icon) => icon.closest('button')); + fireEvent.click(deleteButtons[0]); + }; + + it('opens confirmation dialog when delete icon is clicked (warning not suppressed)', () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + expect(screen.getByTestId('confirmation-dialog')).toBeInTheDocument(); + }); + + it('calls deleteM3UFilter directly when warning is suppressed', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(true) }); + render(); + clickDeleteFirst(); + await waitFor(() => { + expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not open confirmation dialog when warning is suppressed', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(true) }); + render(); + clickDeleteFirst(); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('calls deleteM3UFilter after confirming deletion', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled(); + }); + }); + + it('calls fetchPlaylist after successful deletion', async () => { + const { fetchPlaylist } = setupStores({ + isWarningSuppressed: vi.fn().mockReturnValue(false), + }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect(fetchPlaylist).toHaveBeenCalledWith(10); + }); + }); + + it('closes confirmation dialog after confirming deletion', async () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + }); + + it('closes confirmation dialog when No is clicked', () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-no')); + expect( + screen.queryByTestId('confirmation-dialog') + ).not.toBeInTheDocument(); + }); + + it('does not call deleteM3UFilter when No is clicked', () => { + setupStores({ isWarningSuppressed: vi.fn().mockReturnValue(false) }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-no')); + expect(M3uFilterUtils.deleteM3UFilter).not.toHaveBeenCalled(); + }); + + it('does not call fetchPlaylist when deleteM3UFilter throws', async () => { + vi.mocked(M3uFilterUtils.deleteM3UFilter).mockRejectedValue( + new Error('fail') + ); + const { fetchPlaylist } = setupStores({ + isWarningSuppressed: vi.fn().mockReturnValue(false), + }); + render(); + clickDeleteFirst(); + fireEvent.click(screen.getByTestId('confirm-yes')); + await waitFor(() => { + expect(M3uFilterUtils.deleteM3UFilter).toHaveBeenCalled(); + }); + expect(fetchPlaylist).not.toHaveBeenCalled(); + }); + }); + + // ── Initialization from playlist prop ───────────────────────────────────── + + describe('filter initialization', () => { + it('loads filters from playlist.custom_properties.filters on mount', () => { + setupStores(); + render(); + expect(screen.getByText('HBO.*')).toBeInTheDocument(); + expect(screen.getByText('ESPN.*')).toBeInTheDocument(); + }); + + it('updates displayed filters when playlist prop changes', () => { + setupStores(); + const { rerender } = render(); + const updatedPlaylist = makePlaylist({ + filters: [makeFilter({ id: 3, regex_pattern: 'CNN.*', order: 0 })], + }); + rerender(); + expect(screen.getByText('CNN.*')).toBeInTheDocument(); + expect(screen.queryByText('HBO.*')).not.toBeInTheDocument(); + }); + }); + + // ── Drag and drop reordering ─────────────────────────────────────────────── + + describe('drag and drop', () => { + it('calls updateM3UFilter for reordered filters after drag end', async () => { + setupStores(); + // We need to trigger handleDragEnd manually via the DndContext mock. + // Re-mock DndContext to capture and expose onDragEnd. + let capturedOnDragEnd; + const { DndContext } = await import('@dnd-kit/core'); + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + // Simulate drag: move filter id=1 over filter id=2 + await waitFor(() => expect(capturedOnDragEnd).toBeDefined()); + capturedOnDragEnd({ active: { id: 1 }, over: { id: 2 } }); + + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).toHaveBeenCalled(); + }); + }); + + it('does not call updateM3UFilter when drag ends on same position', async () => { + setupStores(); + let capturedOnDragEnd; + const { DndContext } = await import('@dnd-kit/core'); + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await waitFor(() => expect(capturedOnDragEnd).toBeDefined()); + capturedOnDragEnd({ active: { id: 1 }, over: { id: 1 } }); + + await waitFor(() => { + expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled(); + }); + }); + + it('does not call updateM3UFilter when there is no over target', async () => { + setupStores(); + let capturedOnDragEnd; + const { DndContext } = await import('@dnd-kit/core'); + vi.mocked(DndContext).mockImplementation(({ children, onDragEnd }) => { + capturedOnDragEnd = onDragEnd; + return
{children}
; + }); + + render(); + + await waitFor(() => expect(capturedOnDragEnd).toBeDefined()); + capturedOnDragEnd({ active: { id: 1 }, over: null }); + + expect(M3uFilterUtils.updateM3UFilter).not.toHaveBeenCalled(); + }); + }); + + // ── Warning suppression ──────────────────────────────────────────────────── + + describe('warning suppression', () => { + it('calls suppressWarning when user confirms and opts to suppress', async () => { + // This depends on ConfirmationDialog exposing a "suppress" callback. + // Here we verify suppressWarning is available from the store. + const { suppressWarning } = setupStores({ + isWarningSuppressed: vi.fn().mockReturnValue(false), + }); + render(); + expect(suppressWarning).toBeDefined(); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx b/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx new file mode 100644 index 00000000..aba4e139 --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UGroupFilter.test.jsx @@ -0,0 +1,441 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UGroupFilter from '../M3UGroupFilter'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/forms/M3uGroupFilterUtils.js', () => ({ + saveAndRefreshPlaylist: vi.fn(), + buildGroupStates: vi.fn(), +})); + +// ── Sub-component mocks ──────────────────────────────────────────────────────── +vi.mock('../LiveGroupFilter', () => ({ + default: ({ + groupStates, + setGroupStates, + autoEnableNewGroupsLive, + setAutoEnableNewGroupsLive, + }) => ( +
+ {groupStates?.length ?? 0} + + +
+ ), +})); + +vi.mock('../VODCategoryFilter', () => ({ + default: ({ + categoryStates, + setCategoryStates, + autoEnableNewGroups, + setAutoEnableNewGroups, + type, + }) => ( +
+ + {categoryStates?.length ?? 0} + + + +
+ ), +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Button: ({ children, onClick, loading, disabled, variant, color }) => ( + + ), + Flex: ({ children }) =>
{children}
, + LoadingOverlay: ({ visible }) => + visible ?
: null, + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Tabs: ({ children, defaultValue, value }) => ( +
+ {children} +
+ ), + TabsList: ({ children }) =>
{children}
, + TabsPanel: ({ children, value }) => ( +
{children}
+ ), + TabsTab: ({ children, value, onClick }) => ( + + ), +})); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import useVODStore from '../../../store/useVODStore'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as M3uGroupFilterUtils from '../../../utils/forms/M3uGroupFilterUtils.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── +const makePlaylist = (overrides = {}) => ({ + id: 1, + name: 'Test Playlist', + account_type: 'XC', + enable_vod: true, + ...overrides, +}); + +const makeGroup = (overrides = {}) => ({ + id: 1, + name: 'Group A', + playlist_id: 1, + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + playlist: makePlaylist(), + isOpen: true, + onClose: vi.fn(), + ...overrides, +}); + +const setupStores = ({ + channelGroups = [makeGroup(), makeGroup({ id: 2, name: 'Group B' })], + fetchCategories = vi.fn().mockResolvedValue(undefined), +} = {}) => { + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ channelGroups }) + ); + vi.mocked(useVODStore).mockImplementation((sel) => sel({ fetchCategories })); + return { channelGroups, fetchCategories }; +}; + +// ────────────────────────────────────────────────────────────────────────────── + +describe('M3UGroupFilter', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockResolvedValue( + undefined + ); + vi.mocked(M3uGroupFilterUtils.buildGroupStates).mockReturnValue([]); + }); + + // ── Guard conditions ─────────────────────────────────────────────────────── + + describe('guard conditions', () => { + it('does not render modal when isOpen is false', () => { + setupStores(); + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the modal when isOpen is true with a valid playlist', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('renders the modal title', () => { + setupStores(); + render(); + expect(screen.getByTestId('modal-title')).toBeInTheDocument(); + }); + + it('renders tab list with Live and VOD tabs', () => { + setupStores(); + render(); + expect(screen.getByTestId('tabs-list')).toBeInTheDocument(); + expect(screen.getByTestId('tab-live')).toBeInTheDocument(); + expect(screen.getByTestId('tab-vod-movie')).toBeInTheDocument(); + expect(screen.getByTestId('tab-vod-series')).toBeInTheDocument(); + }); + + it('renders LiveGroupFilter panel', () => { + setupStores(); + render(); + expect(screen.getByTestId('live-group-filter')).toBeInTheDocument(); + }); + + it('renders VODCategoryFilter panels', () => { + setupStores(); + render(); + expect( + screen.getByTestId('vod-category-filter-movie') + ).toBeInTheDocument(); + expect( + screen.getByTestId('vod-category-filter-series') + ).toBeInTheDocument(); + }); + + it('renders a Save button', () => { + setupStores(); + render(); + expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); + }); + + it('renders a Cancel button', () => { + setupStores(); + render(); + expect( + screen.getByRole('button', { name: /cancel/i }) + ).toBeInTheDocument(); + }); + }); + + // ── Initialization ───────────────────────────────────────────────────────── + + describe('initialization', () => { + it('calls buildGroupStates with channelGroups and playlist on mount', async () => { + const { channelGroups } = setupStores(); + render(); + await waitFor(() => { + expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith( + channelGroups, + undefined + ); + }); + }); + + it('calls fetchCategories on mount', async () => { + const { fetchCategories } = setupStores(); + render(); + await waitFor(() => { + expect(fetchCategories).toHaveBeenCalled(); + }); + }); + + it('re-initializes when playlist prop changes', async () => { + setupStores(); + const { rerender } = render(); + const updatedPlaylist = makePlaylist({ + id: 2, + name: 'Updated Playlist', + channel_groups: [{ id: 3, name: 'Group C', playlist_id: 2 }], + }); + rerender( + + ); + await waitFor(() => { + expect(M3uGroupFilterUtils.buildGroupStates).toHaveBeenCalledWith( + expect.anything(), + updatedPlaylist.channel_groups + ); + }); + }); + }); + + // ── Close / cancel behaviour ─────────────────────────────────────────────── + + describe('close / cancel behaviour', () => { + it('calls onClose when modal X is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when Cancel button is clicked', () => { + const onClose = vi.fn(); + setupStores(); + render(); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalled(); + }); + }); + + // ── LiveGroupFilter interaction ──────────────────────────────────────────── + + describe('LiveGroupFilter interaction', () => { + it('updates groupStates when LiveGroupFilter fires onGroupStatesChange', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByTestId('live-group-filter')); + fireEvent.click(screen.getByTestId('live-change-groups')); + expect(screen.getByTestId('live-group-count')).toHaveTextContent('1'); + }); + + it('toggles autoEnableNewGroupsLive when LiveGroupFilter fires onAutoEnableChange', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByTestId('live-group-filter')); + // Toggle twice to verify it actually flips + fireEvent.click(screen.getByTestId('live-toggle-auto')); + fireEvent.click(screen.getByTestId('live-toggle-auto')); + // No crash = state updated correctly + }); + }); + + // ── VODCategoryFilter interaction ────────────────────────────────────────── + + describe('VODCategoryFilter interaction', () => { + it('updates movieCategoryStates when movie VODCategoryFilter fires setCategoryStates', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-change-movie')); + expect(screen.getByTestId('movie-category-count')).toHaveTextContent('1'); + }); + + it('updates seriesCategoryStates when series VODCategoryFilter fires setCategoryStates', async () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-change-series')); + expect(screen.getByTestId('series-category-count')).toHaveTextContent( + '1' + ); + }); + + it('toggles autoEnableNewGroupsVod when movie VODCategoryFilter fires setAutoEnableNewGroups', () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-toggle-auto-movie')); + fireEvent.click(screen.getByTestId('vod-toggle-auto-movie')); + }); + + it('toggles autoEnableNewGroupsSeries when series VODCategoryFilter fires setAutoEnableNewGroups', () => { + setupStores(); + render(); + fireEvent.click(screen.getByTestId('vod-toggle-auto-series')); + fireEvent.click(screen.getByTestId('vod-toggle-auto-series')); + }); + }); + + // ── Save ─────────────────────────────────────────────────────────────────── + + describe('saving', () => { + it('calls saveAndRefreshPlaylist with playlist and current states on Save click', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(M3uGroupFilterUtils.saveAndRefreshPlaylist).toHaveBeenCalledWith( + expect.objectContaining({ id: 1 }), + expect.any(Array), + expect.any(Array), + expect.any(Array), + expect.objectContaining({ + auto_enable_new_groups_live: true, + auto_enable_new_groups_vod: true, + auto_enable_new_groups_series: true, + }) + ); + }); + }); + + it('calls onClose after successful save', async () => { + const onClose = vi.fn(); + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('shows success notification after saving', async () => { + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ + color: expect.stringMatching(/green|teal/), + }) + ); + }); + }); + + it('does not call onClose when saveAndRefreshPlaylist throws', async () => { + vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockRejectedValue( + new Error('save failed') + ); + const onClose = vi.fn(); + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + // ── Loading state ────────────────────────────────────────────────────────── + + describe('loading state', () => { + it('disables Save button while submitting', async () => { + let resolveSave; + vi.mocked(M3uGroupFilterUtils.saveAndRefreshPlaylist).mockImplementation( + () => + new Promise((res) => { + resolveSave = res; + }) + ); + setupStores(); + render(); + await waitFor(() => screen.getByRole('button', { name: /save/i })); + + const saveBtn = screen.getByRole('button', { name: /save/i }); + fireEvent.click(saveBtn); + + await waitFor(() => { + expect(saveBtn.disabled || saveBtn.dataset.loading === 'true').toBe( + true + ); + }); + + resolveSave(); + }); + }); +}); diff --git a/frontend/src/components/forms/__tests__/M3UProfile.test.jsx b/frontend/src/components/forms/__tests__/M3UProfile.test.jsx new file mode 100644 index 00000000..45f73b71 --- /dev/null +++ b/frontend/src/components/forms/__tests__/M3UProfile.test.jsx @@ -0,0 +1,824 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import M3UProfile from '../M3UProfile'; + +// ── WebSocket mock ───────────────────────────────────────────────────────────── +vi.mock('../../../WebSocket', () => ({ + useWebSocket: vi.fn(), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/forms/M3uProfileUtils.js', () => ({ + addM3UProfile: vi.fn(), + applyRegex: vi.fn(), + applyXcSimplePatterns: vi.fn(), + buildProfileSchema: vi.fn(), + buildSubmitValues: vi.fn(), + fetchFirstStreamUrl: vi.fn(), + getDetectedMode: vi.fn(), + prepareExpDate: vi.fn(), + splitByPattern: vi.fn(), + updateM3UProfile: vi.fn(), + validateXcSimple: vi.fn(), +})); + +// ── react-hook-form mock ─────────────────────────────────────────────────────── +vi.mock('react-hook-form', async () => { + const actual = await vi.importActual('react-hook-form'); + return { ...actual, useForm: vi.fn() }; +}); + +// ── @hookform/resolvers/yup mock ─────────────────────────────────────────────── +vi.mock('@hookform/resolvers/yup', () => ({ + yupResolver: vi.fn(() => vi.fn()), +})); + +// ── @mantine/dates mock ──────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DateTimePicker: ({ label, value, onChange, disabled, placeholder }) => ( +
+ + onChange?.(e.target.value)} + disabled={disabled} + placeholder={placeholder} + /> +
+ ), +})); + +// ── @mantine/core mock ───────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + Alert: ({ children, title }) => ( +
+ {title} + {children} +
+ ), + Badge: ({ children, color }) => ( + + {children} + + ), + Button: ({ children, onClick, disabled, loading, variant, color, type }) => ( + + ), + Flex: ({ children }) =>
{children}
, + Grid: ({ children }) =>
{children}
, + GridCol: ({ children, span }) => ( +
+ {children} +
+ ), + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + NumberInput: ({ + label, + value, + onChange, + disabled, + min, + max, + placeholder, + }) => ( +
+ + onChange?.(Number(e.target.value))} + disabled={disabled} + min={min} + max={max} + placeholder={placeholder} + /> +
+ ), + Paper: ({ children }) =>
{children}
, + SegmentedControl: ({ value, onChange, data, disabled }) => ( +
+ {data?.map((item) => ( + + ))} +
+ ), + Text: ({ children, size, c, fw }) => ( + + {children} + + ), + Textarea: ({ label, value, onChange, disabled, placeholder, error }) => ( +
+ +