diff --git a/CHANGELOG.md b/CHANGELOG.md index a9cd96f4..d41c6150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Progress, bulk completion, and single-channel results use `send_websocket_update` instead of `async_to_sync(channel_layer.group_send)`, so notifications work reliably under gevent-patched uWSGI and Celery workers. - Single-channel and selected-channel auto-match always run, even when the channel already has EPG assigned; match-all (no channel IDs) still only processes channels without EPG. - Rematching to the same EPG no longer re-saves the channel or queues program-parse tasks; only assignments that actually change are written and refreshed. +- **Easier EPG search when editing a channel.** The filter in the EPG picker now works more like a normal search box. You can type several words at once (e.g. `sky uk`) and it finds channels where every word appears somewhere in the name or TVG-ID, the order doesn't matter, and a word in the name can pair with one in the ID. Accents are ignored too, so `decale` matches `Décalé` and the other way around. — Thanks [@FiveBoroughs](https://github.com/FiveBoroughs) ### Performance diff --git a/frontend/src/components/forms/Channel.jsx b/frontend/src/components/forms/Channel.jsx index 8f39e8f3..260bd4ad 100644 --- a/frontend/src/components/forms/Channel.jsx +++ b/frontend/src/components/forms/Channel.jsx @@ -541,13 +541,21 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => { return <>; } + // Case- and accent-insensitive. + const foldText = (text) => + text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, ''); + + // Every term must match, against name and id combined. + const tvgFilterTerms = foldText(tvgFilter).split(/\s+/).filter(Boolean); const filteredTvgs = tvgs .filter((tvg) => tvg.epg_source == selectedEPG) - .filter( - (tvg) => - tvg.name.toLowerCase().includes(tvgFilter.toLowerCase()) || - tvg.tvg_id.toLowerCase().includes(tvgFilter.toLowerCase()) - ); + .filter((tvg) => { + const haystack = foldText(`${tvg.name} ${tvg.tvg_id}`); + return tvgFilterTerms.every((term) => haystack.includes(term)); + }); const filteredLogos = logoOptions.filter((logo) => logo.name.toLowerCase().includes(logoFilter.toLowerCase()) @@ -1128,6 +1136,7 @@ const ChannelForm = ({ channel: channelProp = null, isOpen, onClose }) => { {/* Filter Input */} setTvgFilter(event.currentTarget.value) diff --git a/frontend/src/components/forms/__tests__/Channel.test.jsx b/frontend/src/components/forms/__tests__/Channel.test.jsx index efdd01e2..d822c9e1 100644 --- a/frontend/src/components/forms/__tests__/Channel.test.jsx +++ b/frontend/src/components/forms/__tests__/Channel.test.jsx @@ -1225,4 +1225,153 @@ describe('ChannelForm', () => { expect(screen.queryByText(/Clear All Overrides/)).not.toBeInTheDocument(); }); }); + + // ── EPG filter search ────────────────────────────────────────────────────── + describe('EPG filter search', () => { + const skyText = 'Sky Sports Ultra HD (SkySportUltraHD.1.uk)'; + const btText = 'BT Sport 1 (BTSport1.uk)'; + + const epgTvgs = [ + { + id: 'tvg-sky', + name: 'Sky Sports Ultra HD', + tvg_id: 'SkySportUltraHD.1.uk', + epg_source: 10, + icon_url: '', + }, + { + id: 'tvg-bt', + name: 'BT Sport 1', + tvg_id: 'BTSport1.uk', + epg_source: 10, + icon_url: '', + }, + // Accent in the name, but the id does NOT mirror it ("decale"): an ascii + // query only matches if the haystack is accent-folded. + { + id: 'tvg-canal', + name: 'Canal Décalé', + tvg_id: 'CPLUS42.fr', + epg_source: 10, + icon_url: '', + }, + // Plain ascii everywhere: an accented query only matches if the query + // itself is accent-folded. + { + id: 'tvg-decaleplus', + name: 'Decale Plus', + tvg_id: 'DECP.fr', + epg_source: 10, + icon_url: '', + }, + ]; + const canalText = 'Canal Décalé (CPLUS42.fr)'; + const decaleText = 'Decale Plus (DECP.fr)'; + + // Render the form, point the store at our two same-source tvgs, and pick the + // EPG source so the dropdown list is populated. + const renderAndSelectSource = () => { + setupMocks(); + // Stable references — recreating these per selector call would retrigger + // the epgs-dependent effect and loop forever. + const epgs = makeEpgs(); + const tvgsById = {}; + vi.mocked(useEPGsStore).mockImplementation((sel) => + sel({ epgs, tvgs: epgTvgs, tvgsById }) + ); + render(); + fireEvent.change(screen.getByTestId('select-Source'), { + target: { value: '10' }, + }); + }; + + const typeFilter = (value) => + fireEvent.change(screen.getByTestId('text-input-tvg-filter'), { + target: { value }, + }); + + it('matches when space-separated terms each appear across name and id', () => { + renderAndSelectSource(); + typeFilter('Sky uk'); + expect(screen.getByText(skyText)).toBeInTheDocument(); + expect(screen.queryByText(btText)).not.toBeInTheDocument(); + }); + + it('matches regardless of term order', () => { + renderAndSelectSource(); + typeFilter('uk sky'); + expect(screen.getByText(skyText)).toBeInTheDocument(); + expect(screen.queryByText(btText)).not.toBeInTheDocument(); + }); + + it('still matches a single substring term', () => { + renderAndSelectSource(); + typeFilter('sky'); + expect(screen.getByText(skyText)).toBeInTheDocument(); + expect(screen.queryByText(btText)).not.toBeInTheDocument(); + }); + + it('shows every row for the source when the filter is empty', () => { + renderAndSelectSource(); + expect(screen.getByText(skyText)).toBeInTheDocument(); + expect(screen.getByText(btText)).toBeInTheDocument(); + }); + + it('shows no rows when a term matches nothing', () => { + renderAndSelectSource(); + typeFilter('sky zzz'); + expect(screen.queryByText(skyText)).not.toBeInTheDocument(); + expect(screen.queryByText(btText)).not.toBeInTheDocument(); + }); + + it('ignores case, including all-caps', () => { + renderAndSelectSource(); + typeFilter('SKY UK'); + expect(screen.getByText(skyText)).toBeInTheDocument(); + expect(screen.queryByText(btText)).not.toBeInTheDocument(); + }); + + it('matches when one term hits the name and another hits the id', () => { + renderAndSelectSource(); + typeFilter('Sports uk'); + expect(screen.getByText(skyText)).toBeInTheDocument(); + expect(screen.queryByText(btText)).not.toBeInTheDocument(); + }); + + it('tolerates leading, trailing and repeated whitespace', () => { + renderAndSelectSource(); + typeFilter(' sky uk '); + expect(screen.getByText(skyText)).toBeInTheDocument(); + expect(screen.queryByText(btText)).not.toBeInTheDocument(); + }); + + it('matches an id fragment that is not in the display name', () => { + renderAndSelectSource(); + typeFilter('skysport'); + expect(screen.getByText(skyText)).toBeInTheDocument(); + expect(screen.queryByText(btText)).not.toBeInTheDocument(); + }); + + it('matches a name word combined with a channel number', () => { + renderAndSelectSource(); + typeFilter('bt 1'); + expect(screen.getByText(btText)).toBeInTheDocument(); + expect(screen.queryByText(skyText)).not.toBeInTheDocument(); + }); + + it('folds accents in the data so a plain ascii query matches', () => { + renderAndSelectSource(); + typeFilter('canal decale'); + expect(screen.getByText(canalText)).toBeInTheDocument(); + expect(screen.queryByText(decaleText)).not.toBeInTheDocument(); + expect(screen.queryByText(skyText)).not.toBeInTheDocument(); + }); + + it('folds accents in the query so it matches plain ascii data', () => { + renderAndSelectSource(); + typeFilter('décalé'); + expect(screen.getByText(decaleText)).toBeInTheDocument(); + expect(screen.queryByText(skyText)).not.toBeInTheDocument(); + }); + }); });