mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
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
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:
parent
0f29cc6e66
commit
f0267508ff
5 changed files with 227 additions and 16 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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/`, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue