Skip to content

Commit 2cbf53e

Browse files
authored
Artefact filters in url query parameters (#139)
* Make artefact filters preside in url query parameters * Refactor Filter to allow for lack of availableOptions * Some refactor * Small refactor * Don't add q parameter if there is no search * Improve q query parameter handling * Handle options only in query parameters * Bug fix * Rename availableFilters to detectedFilters
1 parent b17f82b commit 2cbf53e

12 files changed

+166
-130
lines changed

frontend/lib/models/filter.dart

+4-39
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import 'package:dartx/dartx.dart';
21
import 'package:freezed_annotation/freezed_annotation.dart';
32

43
part 'filter.freezed.dart';
@@ -10,46 +9,12 @@ class Filter<T> with _$Filter<T> {
109
const factory Filter({
1110
required String name,
1211
required String? Function(T) extractOption,
13-
required List<({String name, bool value})> options,
12+
@Default(<String>{}) Set<String> selectedOptions,
13+
@Default(<String>[]) List<String> detectedOptions,
1414
}) = _Filter<T>;
1515

16-
factory Filter.fromObjects({
17-
required String name,
18-
required String? Function(T) extractOption,
19-
required List<T> objects,
20-
}) {
21-
final names = <String>{};
22-
for (final object in objects) {
23-
final name = extractOption(object);
24-
if (name != null) names.add(name);
25-
}
26-
27-
final options = [
28-
for (final name in names.toList().sorted()) (name: name, value: false),
29-
];
30-
31-
return Filter(name: name, extractOption: extractOption, options: options);
32-
}
33-
3416
bool doesObjectPassFilter(T object) {
35-
final noOptionsSelected = options.none((option) => option.value);
36-
if (noOptionsSelected) return true;
37-
final selectedOptions = {
38-
for (final option in options)
39-
if (option.value) option.name,
40-
};
41-
return selectedOptions.contains(extractOption(object));
42-
}
43-
44-
Filter<T> copyWithOptionValue(String optionName, bool optionValue) {
45-
return copyWith(
46-
options: [
47-
for (final option in options)
48-
if (option.name == optionName)
49-
(name: optionName, value: optionValue)
50-
else
51-
option,
52-
],
53-
);
17+
return selectedOptions.isEmpty ||
18+
selectedOptions.contains(extractOption(object));
5419
}
5520
}

frontend/lib/models/filters.dart

+76-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import 'package:dartx/dartx.dart';
22
import 'package:freezed_annotation/freezed_annotation.dart';
33

4+
import 'artefact.dart';
45
import 'filter.dart';
6+
import 'test_execution.dart';
57

68
part 'filters.freezed.dart';
79

@@ -22,7 +24,15 @@ class Filters<T> with _$Filters<T> {
2224
filters: [
2325
for (final filter in filters)
2426
if (filter.name == filterName)
25-
filter.copyWithOptionValue(optionName, optionValue)
27+
if (optionValue)
28+
filter.copyWith(
29+
selectedOptions: filter.selectedOptions.union({optionName}),
30+
)
31+
else
32+
filter.copyWith(
33+
selectedOptions:
34+
filter.selectedOptions.difference({optionName}),
35+
)
2636
else
2737
filter,
2838
],
@@ -31,4 +41,69 @@ class Filters<T> with _$Filters<T> {
3141

3242
bool doesObjectPassFilters(T object) =>
3343
filters.all((filter) => filter.doesObjectPassFilter(object));
44+
45+
Filters<T> copyWithQueryParams(Map<String, List<String>> queryParams) {
46+
final newFilters = filters.map((filter) {
47+
final values = queryParams[filter.name]?.toSet();
48+
if (values == null || values.isEmpty) return filter;
49+
return filter.copyWith(
50+
selectedOptions: values.toSet(),
51+
detectedOptions: filter.detectedOptions.toSet().union(values).toList()
52+
..sort(),
53+
);
54+
});
55+
56+
return copyWith(filters: newFilters.toList());
57+
}
58+
59+
Map<String, List<String>> toQueryParams() {
60+
final queryParams = <String, List<String>>{};
61+
for (final filter in filters) {
62+
if (filter.selectedOptions.isNotEmpty) {
63+
queryParams[filter.name] = filter.selectedOptions.toList();
64+
}
65+
}
66+
return queryParams;
67+
}
68+
69+
Filters<T> copyWithOptionsExtracted(List<T> objects) {
70+
final newFilters = <Filter<T>>[];
71+
for (final filter in filters) {
72+
final options = <String>{};
73+
for (final object in objects) {
74+
final option = filter.extractOption(object);
75+
if (option != null) options.add(option);
76+
}
77+
newFilters
78+
.add(filter.copyWith(detectedOptions: options.toList()..sort()));
79+
}
80+
return copyWith(filters: newFilters);
81+
}
3482
}
83+
84+
final emptyArtefactFilters = Filters<Artefact>(
85+
filters: [
86+
Filter<Artefact>(
87+
name: 'Assignee',
88+
extractOption: (artefact) => artefact.assignee?.name,
89+
),
90+
Filter<Artefact>(
91+
name: 'Status',
92+
extractOption: (artefact) => artefact.status.name,
93+
),
94+
],
95+
);
96+
97+
final emptyTestExecutionFilters = Filters<TestExecution>(
98+
filters: [
99+
Filter<TestExecution>(
100+
name: 'Review status',
101+
extractOption: (te) =>
102+
te.reviewDecision.isEmpty ? 'Undecided' : 'Reviewed',
103+
),
104+
Filter<TestExecution>(
105+
name: 'Execution status',
106+
extractOption: (te) => te.status.name,
107+
),
108+
],
109+
);

frontend/lib/providers/artefact_filters.dart

+7-17
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,24 @@
11
import 'package:riverpod_annotation/riverpod_annotation.dart';
22

33
import '../models/artefact.dart';
4-
import '../models/family_name.dart';
5-
import '../models/filter.dart';
64
import '../models/filters.dart';
5+
import '../routing.dart';
76
import 'family_artefacts.dart';
87

98
part 'artefact_filters.g.dart';
109

1110
@riverpod
1211
class ArtefactFilters extends _$ArtefactFilters {
1312
@override
14-
Filters<Artefact> build(FamilyName family) {
13+
Filters<Artefact> build(Uri pageUri) {
14+
final family = AppRoutes.familyFromUri(pageUri);
15+
1516
final artefacts =
1617
ref.watch(familyArtefactsProvider(family)).requireValue.values.toList();
1718

18-
return Filters<Artefact>(
19-
filters: [
20-
Filter<Artefact>.fromObjects(
21-
name: 'Assignee',
22-
extractOption: (artefact) => artefact.assignee?.name,
23-
objects: artefacts,
24-
),
25-
Filter<Artefact>.fromObjects(
26-
name: 'Status',
27-
extractOption: (artefact) => artefact.status.name,
28-
objects: artefacts,
29-
),
30-
],
31-
);
19+
return emptyArtefactFilters
20+
.copyWithOptionsExtracted(artefacts)
21+
.copyWithQueryParams(pageUri.queryParametersAll);
3222
}
3323

3424
void handleFilterOptionChange(

frontend/lib/providers/filtered_family_artefacts.dart

+7-6
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,22 @@ import 'package:dartx/dartx.dart';
22
import 'package:riverpod_annotation/riverpod_annotation.dart';
33

44
import '../models/artefact.dart';
5-
import '../models/family_name.dart';
5+
import '../models/filters.dart';
6+
import '../routing.dart';
67
import 'family_artefacts.dart';
7-
import 'artefact_filters.dart';
8-
import 'search_value.dart';
98

109
part 'filtered_family_artefacts.g.dart';
1110

1211
@riverpod
1312
Map<int, Artefact> filteredFamilyArtefacts(
1413
FilteredFamilyArtefactsRef ref,
15-
FamilyName family,
14+
Uri pageUri,
1615
) {
16+
final family = AppRoutes.familyFromUri(pageUri);
1717
final artefacts = ref.watch(familyArtefactsProvider(family)).requireValue;
18-
final filters = ref.watch(artefactFiltersProvider(family));
19-
final searchValue = ref.watch(searchValueProvider);
18+
final filters =
19+
emptyArtefactFilters.copyWithQueryParams(pageUri.queryParametersAll);
20+
final searchValue = pageUri.queryParameters['q'] ?? '';
2021

2122
return artefacts.filterValues(
2223
(artefact) =>

frontend/lib/providers/test_execution_filters.dart

+1-16
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import 'package:riverpod_annotation/riverpod_annotation.dart';
22

3-
import '../models/filter.dart';
43
import '../models/filters.dart';
54
import '../models/test_execution.dart';
65
import 'artefact_builds.dart';
@@ -17,21 +16,7 @@ class TestExecutionFilters extends _$TestExecutionFilters {
1716
for (final testExecution in build.testExecutions) testExecution,
1817
];
1918

20-
return Filters<TestExecution>(
21-
filters: [
22-
Filter<TestExecution>.fromObjects(
23-
name: 'Review status',
24-
extractOption: (te) =>
25-
te.reviewDecision.isEmpty ? 'Undecided' : 'Reviewed',
26-
objects: testExecutions,
27-
),
28-
Filter<TestExecution>.fromObjects(
29-
name: 'Execution status',
30-
extractOption: (te) => te.status.name,
31-
objects: testExecutions,
32-
),
33-
],
34-
);
19+
return emptyTestExecutionFilters.copyWithOptionsExtracted(testExecutions);
3520
}
3621

3722
void handleFilterOptionChange(

frontend/lib/routing.dart

+8-4
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,18 @@ class AppRoutes {
5555
static const debs = '/debs';
5656

5757
static FamilyName familyFromContext(BuildContext context) {
58-
final route = GoRouterState.of(context).fullPath!;
58+
return familyFromUri(GoRouterState.of(context).uri);
59+
}
60+
61+
static FamilyName familyFromUri(Uri uri) {
62+
final path = uri.path;
5963

60-
if (route.startsWith(snaps)) {
64+
if (path.startsWith(snaps)) {
6165
return FamilyName.snap;
62-
} else if (route.startsWith(debs)) {
66+
} else if (path.startsWith(debs)) {
6367
return FamilyName.deb;
6468
} else {
65-
throw Exception('Unknown route: $route');
69+
throw Exception('Unknown route: $path');
6670
}
6771
}
6872

frontend/lib/ui/dashboard/artefact_side_filters.dart

+22-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_riverpod/flutter_riverpod.dart';
3+
import 'package:go_router/go_router.dart';
34

45
import '../../providers/artefact_filters.dart';
5-
import '../../routing.dart';
6+
import '../../providers/search_value.dart';
67
import '../side_filters.dart';
78
import 'artefact_search_bar.dart';
89

@@ -11,8 +12,8 @@ class ArtefactSideFilters extends ConsumerWidget {
1112

1213
@override
1314
Widget build(BuildContext context, WidgetRef ref) {
14-
final family = AppRoutes.familyFromContext(context);
15-
final filters = ref.watch(artefactFiltersProvider(family));
15+
final pageUri = GoRouterState.of(context).uri;
16+
final filters = ref.watch(artefactFiltersProvider(pageUri));
1617

1718
return SizedBox(
1819
width: SideFilters.width,
@@ -23,9 +24,26 @@ class ArtefactSideFilters extends ConsumerWidget {
2324
SideFilters(
2425
filters: filters,
2526
onOptionChanged: ref
26-
.read(artefactFiltersProvider(family).notifier)
27+
.read(artefactFiltersProvider(pageUri).notifier)
2728
.handleFilterOptionChange,
2829
),
30+
const SizedBox(height: SideFilters.spacingBetweenFilters),
31+
SizedBox(
32+
width: double.infinity,
33+
child: ElevatedButton(
34+
onPressed: () {
35+
final searchValue = ref.read(searchValueProvider).trim();
36+
final queryParams = {
37+
if (searchValue.isNotEmpty) 'q': searchValue,
38+
...ref.read(artefactFiltersProvider(pageUri)).toQueryParams(),
39+
};
40+
context.go(
41+
pageUri.replace(queryParameters: queryParams).toString(),
42+
);
43+
},
44+
child: const Text('Apply'),
45+
),
46+
),
2947
],
3048
),
3149
);

frontend/lib/ui/dashboard/stage_column.dart

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import 'package:dartx/dartx.dart';
22
import 'package:flutter/material.dart';
33
import 'package:flutter_riverpod/flutter_riverpod.dart';
4+
import 'package:go_router/go_router.dart';
45

56
import '../../models/stage_name.dart';
67
import '../../providers/filtered_family_artefacts.dart';
7-
import '../../routing.dart';
88
import '../spacing.dart';
99
import 'artefact_card.dart';
1010

@@ -15,10 +15,10 @@ class StageColumn extends ConsumerWidget {
1515

1616
@override
1717
Widget build(BuildContext context, WidgetRef ref) {
18-
final family = AppRoutes.familyFromContext(context);
18+
final pageUri = GoRouterState.of(context).uri;
1919
final artefacts = [
2020
for (final artefact
21-
in ref.watch(filteredFamilyArtefactsProvider(family)).values)
21+
in ref.watch(filteredFamilyArtefactsProvider(pageUri)).values)
2222
if (artefact.stage == stage) artefact,
2323
];
2424

frontend/lib/ui/side_filters.dart

+4-4
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,18 @@ class _SideFilter extends StatelessWidget {
5555
style: Theme.of(context).textTheme.headlineSmall,
5656
),
5757
children: [
58-
for (final option in filter.options)
58+
for (final option in filter.detectedOptions)
5959
Row(
6060
children: [
6161
YaruCheckbox(
62-
value: option.value,
62+
value: filter.selectedOptions.contains(option),
6363
onChanged: (newValue) {
6464
if (newValue != null) {
65-
onOptionChanged(filter.name, option.name, newValue);
65+
onOptionChanged(filter.name, option, newValue);
6666
}
6767
},
6868
),
69-
Text(option.name),
69+
Text(option),
7070
],
7171
),
7272
],

0 commit comments

Comments
 (0)