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)
Some checks are pending
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run

This commit is contained in:
SergeantPanda 2026-01-15 16:26:06 -06:00
parent 0f29cc6e66
commit f0267508ff
5 changed files with 227 additions and 16 deletions

View file

@ -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

View file

@ -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",

View file

@ -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/`, {

View file

@ -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 }) => {
</Flex>
);
case 'group':
case 'group': {
const selectedGroups = filters.channel_group
? filters.channel_group.split(',').filter(Boolean)
: [];
return (
<Flex align="center" style={{ width: '100%', flex: 1 }}>
<MultiSelect
@ -854,10 +866,43 @@ const StreamsTable = ({ onReady }) => {
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 (
<Flex gap={4} align="center">
<Text
size="xs"
style={{
padding: '2px 6px',
backgroundColor: 'var(--mantine-color-dark-4)',
borderRadius: '4px',
}}
>
{value}
</Text>
{selectedGroups.length > 1 && (
<Text
size="xs"
style={{
padding: '2px 6px',
backgroundColor: 'var(--mantine-color-dark-4)',
borderRadius: '4px',
}}
>
+{selectedGroups.length - 1}
</Text>
)}
</Flex>
);
}
return null;
}}
style={{ flex: 1, minWidth: 0 }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
@ -871,11 +916,15 @@ const StreamsTable = ({ onReady }) => {
/>
</Flex>
);
}
case 'm3u':
case 'm3u': {
const selectedM3Us = filters.m3u_account
? filters.m3u_account.split(',').filter(Boolean)
: [];
return (
<Flex align="center" style={{ width: '100%', flex: 1 }}>
<Select
<MultiSelect
placeholder="M3U"
searchable
clearable
@ -883,12 +932,45 @@ const StreamsTable = ({ onReady }) => {
nothingFoundMessage="No options"
onClick={handleSelectClick}
onChange={handleM3UChange}
data={playlists.map((playlist) => ({
label: playlist.name,
value: `${playlist.id}`,
}))}
value={selectedM3Us}
data={m3uOptions}
variant="unstyled"
className="table-input-header"
className="table-input-header custom-multiselect"
valueComponent={({ value }) => {
const index = selectedM3Us.indexOf(value);
if (index === 0) {
const label =
m3uOptions.find((opt) => opt.value === value)?.label ||
value;
return (
<Flex gap={4} align="center">
<Text
size="xs"
style={{
padding: '2px 6px',
backgroundColor: 'var(--mantine-color-dark-4)',
borderRadius: '4px',
}}
>
{label}
</Text>
{selectedM3Us.length > 1 && (
<Text
size="xs"
style={{
padding: '2px 6px',
backgroundColor: 'var(--mantine-color-dark-4)',
borderRadius: '4px',
}}
>
+{selectedM3Us.length - 1}
</Text>
)}
</Flex>
);
}
return null;
}}
style={{ flex: 1, minWidth: 0 }}
rightSectionPointerEvents="auto"
rightSection={React.createElement(sortingIcon, {
@ -902,6 +984,7 @@ const StreamsTable = ({ onReady }) => {
/>
</Flex>
);
}
}
};
@ -972,6 +1055,38 @@ const StreamsTable = ({ onReady }) => {
fetchData();
}, [fetchData]);
// Clear dependent filters if selected values are no longer in filtered options
useEffect(() => {
// Clear group filter if the selected groups are no longer available
if (filters.channel_group) {
const selectedGroups = filters.channel_group.split(',').filter(Boolean);
const stillValid = selectedGroups.filter((group) =>
groupOptions.includes(group)
);
if (stillValid.length !== selectedGroups.length) {
setFilters((prev) => ({
...prev,
channel_group: stillValid.join(','),
}));
}
}
// Clear M3U filter if the selected M3Us are no longer available
if (filters.m3u_account) {
const selectedIds = filters.m3u_account.split(',').filter(Boolean);
const availableIds = m3uOptions.map((opt) => opt.value);
const stillValid = selectedIds.filter((id) => availableIds.includes(id));
if (stillValid.length !== selectedIds.length) {
setFilters((prev) => ({
...prev,
m3u_account: stillValid.join(','),
}));
}
}
}, [groupOptions, m3uOptions, filters.channel_group, filters.m3u_account]);
return (
<>
<Flex

View file

@ -118,7 +118,23 @@ table.mrt-table tr.mantine-Table-tr.mantine-Table-tr-detail-panel td.mantine-Tab
.custom-multiselect .mantine-MultiSelect-input {
min-height: 30px;
/* Set a minimum height */
max-height: 30px;
/* Set max height */
overflow: hidden;
flex-wrap: nowrap;
}
.custom-multiselect .mantine-MultiSelect-pillsList {
flex-wrap: nowrap;
overflow: hidden;
}
.custom-multiselect .mantine-MultiSelect-pill {
max-width: 100px;
flex-shrink: 0;
}
.custom-multiselect .mantine-MultiSelect-pill span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}