From f0267508ffef64b180276ad384332bd1055d54e0 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Thu, 15 Jan 2026 16:26:06 -0600 Subject: [PATCH] Enhancement: Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647) --- CHANGELOG.md | 1 + apps/channels/api_views.py | 69 ++++++++- frontend/src/api.js | 12 ++ .../src/components/tables/StreamsTable.jsx | 141 ++++++++++++++++-- frontend/src/index.css | 20 ++- 5 files changed, 227 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5282ff54..c834174d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Table header pin toggle: Pin/unpin table headers to keep them visible while scrolling. Toggle available in channel table menu and UI Settings page. Setting persists across sessions and applies to all tables. (Closes #663) +- Cascading filters for streams table: Improved filter usability with hierarchical M3U and Group dropdowns. M3U acts as the parent filter showing only active/enabled accounts, while Group options dynamically update to display only groups available in the selected M3U(s). Only enabled M3U's are displayed. (Closes #647) ### Changed diff --git a/apps/channels/api_views.py b/apps/channels/api_views.py index fcf50f49..8ea5db8a 100644 --- a/apps/channels/api_views.py +++ b/apps/channels/api_views.py @@ -96,7 +96,7 @@ class StreamFilter(django_filters.FilterSet): channel_group_name = OrInFilter( field_name="channel_group__name", lookup_expr="icontains" ) - m3u_account = django_filters.NumberFilter(field_name="m3u_account__id") + m3u_account = django_filters.BaseInFilter(field_name="m3u_account__id") m3u_account_name = django_filters.CharFilter( field_name="m3u_account__name", lookup_expr="icontains" ) @@ -194,6 +194,73 @@ class StreamViewSet(viewsets.ModelViewSet): # Return the response with the list of unique group names return Response(list(group_names)) + @action(detail=False, methods=["get"], url_path="filter-options") + def get_filter_options(self, request, *args, **kwargs): + """ + Get available filter options based on current filter state. + Uses a hierarchical approach: M3U is the parent filter, Group filters based on M3U. + """ + # For group options: we need to bypass the channel_group custom queryset filtering + # Store original request params + original_params = request.query_params + + # Create modified params without channel_group for getting group options + params_without_group = request.GET.copy() + params_without_group.pop('channel_group', None) + params_without_group.pop('channel_group_name', None) + + # Temporarily modify request to exclude channel_group + request._request.GET = params_without_group + base_queryset_for_groups = self.get_queryset() + + # Apply filterset (which will apply M3U filters) + group_filterset = self.filterset_class( + params_without_group, + queryset=base_queryset_for_groups + ) + group_queryset = group_filterset.qs + + group_names = ( + group_queryset.exclude(channel_group__isnull=True) + .order_by("channel_group__name") + .values_list("channel_group__name", flat=True) + .distinct() + ) + + # For M3U options: show ALL M3Us (don't filter by anything except name search) + params_for_m3u = request.GET.copy() + params_for_m3u.pop('m3u_account', None) + params_for_m3u.pop('channel_group', None) + params_for_m3u.pop('channel_group_name', None) + + # Temporarily modify request to exclude filters for M3U options + request._request.GET = params_for_m3u + base_queryset_for_m3u = self.get_queryset() + + m3u_filterset = self.filterset_class( + params_for_m3u, + queryset=base_queryset_for_m3u + ) + m3u_queryset = m3u_filterset.qs + + m3u_accounts = ( + m3u_queryset.exclude(m3u_account__isnull=True) + .order_by("m3u_account__name") + .values("m3u_account__id", "m3u_account__name") + .distinct() + ) + + # Restore original params + request._request.GET = original_params + + return Response({ + "groups": list(group_names), + "m3u_accounts": [ + {"id": m3u["m3u_account__id"], "name": m3u["m3u_account__name"]} + for m3u in m3u_accounts + ] + }) + @swagger_auto_schema( method="post", operation_description="Retrieve streams by a list of IDs using POST to avoid URL length limitations", diff --git a/frontend/src/api.js b/frontend/src/api.js index c33ff1ee..ab22848c 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -727,6 +727,18 @@ export default class API { } } + static async getStreamFilterOptions(params) { + try { + const response = await request( + `${host}/api/channels/streams/filter-options/?${params.toString()}` + ); + + return response; + } catch (e) { + errorNotification('Failed to retrieve filter options', e); + } + } + static async addStream(values) { try { const response = await request(`${host}/api/channels/streams/`, { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 72d50d49..02dae3d5 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -186,6 +186,7 @@ const StreamsTable = ({ onReady }) => { const [stream, setStream] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [groupOptions, setGroupOptions] = useState([]); + const [m3uOptions, setM3uOptions] = useState([]); const [initialDataCount, setInitialDataCount] = useState(null); const [data, setData] = useState([]); // Holds fetched data @@ -371,14 +372,14 @@ const StreamsTable = ({ onReady }) => { const handleGroupChange = (value) => { setFilters((prev) => ({ ...prev, - channel_group: value ? value : '', + channel_group: value && value.length > 0 ? value.join(',') : '', })); }; const handleM3UChange = (value) => { setFilters((prev) => ({ ...prev, - m3u_account: value ? value : '', + m3u_account: value && value.length > 0 ? value.join(',') : '', })); }; @@ -419,16 +420,24 @@ const StreamsTable = ({ onReady }) => { }); try { - const [result, ids, groups] = await Promise.all([ + const [result, ids, filterOptions] = await Promise.all([ API.queryStreams(params), API.getAllStreamIds(params), - API.getStreamGroups(), + API.getStreamFilterOptions(params), ]); setAllRowIds(ids); setData(result.results); setPageCount(Math.ceil(result.count / pagination.pageSize)); - setGroupOptions(groups); + + // Set filtered options based on current filters + setGroupOptions(filterOptions.groups); + setM3uOptions( + filterOptions.m3u_accounts.map((m3u) => ({ + label: m3u.name, + value: `${m3u.id}`, + })) + ); // Calculate the starting and ending item indexes const startItem = pagination.pageIndex * pagination.pageSize + 1; // +1 to start from 1, not 0 @@ -844,7 +853,10 @@ const StreamsTable = ({ onReady }) => { ); - case 'group': + case 'group': { + const selectedGroups = filters.channel_group + ? filters.channel_group.split(',').filter(Boolean) + : []; return ( { nothingFoundMessage="No options" onClick={handleSelectClick} onChange={handleGroupChange} + value={selectedGroups} data={groupOptions} variant="unstyled" className="table-input-header custom-multiselect" clearable + valueComponent={({ value }) => { + const index = selectedGroups.indexOf(value); + if (index === 0) { + return ( + + + {value} + + {selectedGroups.length > 1 && ( + + +{selectedGroups.length - 1} + + )} + + ); + } + return null; + }} style={{ flex: 1, minWidth: 0 }} rightSectionPointerEvents="auto" rightSection={React.createElement(sortingIcon, { @@ -871,11 +916,15 @@ const StreamsTable = ({ onReady }) => { /> ); + } - case 'm3u': + case 'm3u': { + const selectedM3Us = filters.m3u_account + ? filters.m3u_account.split(',').filter(Boolean) + : []; return ( -