Skip to content

Commit 53a3fe6

Browse files
authored
feat: add probe filtering by adoption status for admin users (#133)
1 parent 83e6904 commit 53a3fe6

File tree

8 files changed

+297
-144
lines changed

8 files changed

+297
-144
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<template>
2+
<div class="flex flex-col gap-2">
3+
<span class="flex items-center font-bold">Adoption status:</span>
4+
<Select
5+
v-model="usedFilter.adoption"
6+
:options="ADOPTION_OPTIONS"
7+
class="min-w-48"
8+
@change="onChange"
9+
>
10+
<template #value="{value}">
11+
{{ value[0].toUpperCase() + value.slice(1) }}
12+
</template>
13+
<template #option="{option}">
14+
{{ option[0].toUpperCase() + option.slice(1) }}
15+
</template>
16+
</Select>
17+
</div>
18+
</template>
19+
20+
<script setup lang="ts">
21+
import { useProbeFilters, ADOPTION_OPTIONS, type Filter } from '~/composables/useProbeFilters';
22+
23+
const filter = defineModel('filter', { required: false, type: Object as PropType<Filter> });
24+
25+
const { filter: appliedFilter, onParamChange } = useProbeFilters();
26+
const usedFilter = computed(() => filter.value ?? appliedFilter.value);
27+
28+
// only apply changes if the default (shared) filter is used
29+
const onChange = filter.value ? () => {} : onParamChange;
30+
</script>

components/FilterSettings.vue renamed to components/probe/ProbeFilters/MobileProbeListFilters.vue

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
<span class="flex items-center font-bold">Filter:</span>
4242
<IconField class="h-9 !w-full">
4343
<InputIcon class="pi pi-search"/>
44-
<InputText v-model="draftFilter.search" class="m-0 size-full" placeholder="Filter by name, location, or tags"/>
44+
<InputText v-model="searchInput" class="m-0 size-full" placeholder="Filter by name, location, or tags" @value-change="onFilterChangeDebounced"/>
4545
</IconField>
4646
</InputGroup>
4747

@@ -85,33 +85,33 @@
8585
</div>
8686
</div>
8787

88+
<AdminFilterSettings v-if="auth.isAdmin" v-model:filter="draftFilter"/>
89+
8890
<div class="mt-4 flex justify-end gap-2">
8991
<Button label="Cancel" severity="secondary" text @click="emit('cancel')"/>
90-
<Button label="Apply" @click="emit('apply', draftFilter)"/>
92+
<Button label="Apply" @click="onBatchChange(draftFilter); emit('apply')"/>
9193
</div>
9294
</div>
9395
</template>
9496

9597
<script setup lang="ts">
96-
import {
97-
type StatusCode,
98-
type Filter,
99-
SORTABLE_FIELDS,
100-
STATUS_MAP,
101-
} from '~/composables/useProbeFilters';
102-
103-
const { filter, statusCounts } = defineProps({
104-
filter: {
105-
required: true,
106-
type: Object as PropType<Filter>,
107-
},
108-
statusCounts: {
109-
required: true,
110-
type: Object as PropType<Record<StatusCode, number>>,
111-
},
112-
});
98+
import { aggregate } from '@directus/sdk';
99+
import debounce from 'lodash/debounce';
100+
import AdminFilterSettings from '~/components/probe/ProbeFilters/AdminFilterSettings.vue';
101+
import { useErrorToast } from '~/composables/useErrorToast';
102+
import { type StatusCode, SORTABLE_FIELDS, STATUS_MAP, useProbeFilters } from '~/composables/useProbeFilters';
103+
import { useAuth } from '~/store/auth';
104+
105+
const auth = useAuth();
106+
const { $directus } = useNuxtApp();
107+
108+
const { filter, getDirectusFilter, onBatchChange } = useProbeFilters();
109+
110+
const draftFilter = ref({ ...filter.value });
111+
const draftFilterDeps = computed(() => ({ ...draftFilter.value }));
113112
114-
const draftFilter = ref({ ...filter });
113+
const searchInput = ref(draftFilter.value.search);
114+
const onFilterChangeDebounced = debounce(() => draftFilter.value.search = searchInput.value, 300);
115115
116116
const STATUS_CODES = Object.keys(STATUS_MAP) as StatusCode[];
117117
@@ -121,8 +121,36 @@
121121
tags: 'Tag count',
122122
};
123123
124+
const { data: statusCounts, error: statusCountError } = await useLazyAsyncData(
125+
() => $directus.request<[{ count: number; status: Status; isOutdated: boolean }]>(aggregate('gp_probes', {
126+
query: {
127+
filter: getDirectusFilter(draftFilter, [ 'status' ]),
128+
groupBy: [ 'status', 'isOutdated' ],
129+
},
130+
aggregate: { count: '*' },
131+
})),
132+
{
133+
watch: [ draftFilterDeps ],
134+
default: () => Object.fromEntries(STATUS_CODES.map(status => [ status, 0 ])),
135+
transform: (data) => {
136+
const counts = Object.fromEntries(STATUS_CODES.map(status => [ status, 0 ]));
137+
138+
STATUS_CODES.forEach((code) => {
139+
counts[code] = data.reduce((sum, status) => {
140+
return STATUS_MAP[code].options.includes(status.status) && (status.isOutdated || !STATUS_MAP[code].outdatedOnly)
141+
? sum + status.count
142+
: sum;
143+
}, 0);
144+
});
145+
146+
return counts;
147+
},
148+
},
149+
);
150+
151+
useErrorToast(statusCountError);
152+
124153
const emit = defineEmits<{
125-
(e: 'cancel'): void;
126-
(e: 'apply', payload: Filter): void;
154+
(e: 'cancel' | 'apply'): void;
127155
}>();
128156
</script>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<template>
2+
<div class="ml-auto flex h-9 items-stretch gap-x-2 self-end font-normal">
3+
<span class="flex items-center font-bold">Status:</span>
4+
<Select
5+
v-model="filter.status"
6+
:options="STATUS_CODES"
7+
:pt="{ listContainer: { class: '!max-h-64' } }"
8+
option-label="code"
9+
class="min-w-64"
10+
@change="onParamChange"
11+
>
12+
<template #option="{option}: {option: StatusCode}">
13+
<span class="flex h-full items-center gap-2">
14+
<span
15+
:class="{
16+
'font-bold text-bluegray-900 dark:text-white': option === filter.status,
17+
'text-bluegray-400': option !== filter.status
18+
}">
19+
{{ STATUS_MAP[option].name }}
20+
</span>
21+
<Tag
22+
class="-my-0.5"
23+
:class="{
24+
'bg-primary text-white dark:bg-white dark:text-bluegray-900 ': option === filter.status,
25+
'border border-surface-300 bg-surface-0 text-bluegray-900 dark:border-dark-600 dark:bg-dark-800 dark:text-surface-0': option !== filter.status
26+
}">
27+
{{ statusCounts[option] }}
28+
</Tag>
29+
</span>
30+
</template>
31+
32+
<template #value="{value}: {value: StatusCode}">
33+
<span class="flex h-full items-center gap-2">
34+
<span class="text-bluegray-400">{{ STATUS_MAP[value].name }}</span>
35+
<Tag class="-my-1 border ">{{ statusCounts[value] }}</Tag>
36+
</span>
37+
</template>
38+
</Select>
39+
<InputGroup class="!w-auto">
40+
<IconField>
41+
<InputIcon class="pi pi-search"/>
42+
<InputText v-model="searchInput" class="m-0 h-full min-w-[280px]" placeholder="Filter by name, location, or tags" @input="onFilterChangeDebounced"/>
43+
</IconField>
44+
</InputGroup>
45+
46+
<div v-if="auth.isAdmin" class="flex size-9 items-stretch justify-between rounded-md border border-surface-300 text-bluegray-700 focus-within:border-primary hover:border-surface-400 dark:border-dark-600 dark:bg-dark-900 dark:text-dark-0 dark:hover:border-dark-400">
47+
<Button
48+
class="relative hover:bg-white focus:ring-primary dark:hover:bg-dark-900 dark:focus:ring-primary"
49+
severity="secondary"
50+
size="small"
51+
text
52+
@click="adminOptsRef.toggle($event)">
53+
<i class="pi pi-sliders-h"/>
54+
<i v-if="!isDefault('adoption')" class="pi pi-circle-fill absolute right-2 top-2 text-[0.4rem] text-primary"/>
55+
</Button>
56+
57+
<Popover ref="adminOptsRef" class="w-fit gap-4 p-4 [&>*]:border-none" role="dialog">
58+
<AdminFilterSettings/>
59+
</Popover>
60+
</div>
61+
</div>
62+
</template>
63+
64+
<script setup lang="ts">
65+
import { aggregate } from '@directus/sdk';
66+
import debounce from 'lodash/debounce';
67+
import AdminFilterSettings from '~/components/probe/ProbeFilters/AdminFilterSettings.vue';
68+
import { useErrorToast } from '~/composables/useErrorToast';
69+
import { STATUS_MAP, type StatusCode, useProbeFilters } from '~/composables/useProbeFilters';
70+
import { useAuth } from '~/store/auth';
71+
72+
const auth = useAuth();
73+
const { $directus } = useNuxtApp();
74+
75+
const active = ref(true);
76+
const { filter, onParamChange, onFilterChange, getDirectusFilter, isDefault } = useProbeFilters({ active });
77+
const searchInput = ref(filter.value.search);
78+
const onFilterChangeDebounced = debounce(() => onFilterChange(searchInput.value), 500);
79+
const filterDeps = computed(() => ({ ...filter.value }));
80+
81+
const adminOptsRef = ref();
82+
const STATUS_CODES = Object.keys(STATUS_MAP) as StatusCode[];
83+
84+
const { data: statusCounts, error: statusCountError } = await useLazyAsyncData(
85+
() => $directus.request<[{ count: number; status: Status; isOutdated: boolean }]>(aggregate('gp_probes', {
86+
query: {
87+
filter: getDirectusFilter(filter, [ 'status' ]),
88+
groupBy: [ 'status', 'isOutdated' ],
89+
},
90+
aggregate: { count: '*' },
91+
})),
92+
{
93+
watch: [ filterDeps ],
94+
default: () => Object.fromEntries(STATUS_CODES.map(status => [ status, 0 ])),
95+
transform: (data) => {
96+
const counts = Object.fromEntries(STATUS_CODES.map(status => [ status, 0 ]));
97+
98+
STATUS_CODES.forEach((code) => {
99+
counts[code] = data.reduce((sum, status) => {
100+
return STATUS_MAP[code].options.includes(status.status) && (status.isOutdated || !STATUS_MAP[code].outdatedOnly)
101+
? sum + status.count
102+
: sum;
103+
}, 0);
104+
});
105+
106+
return counts;
107+
},
108+
},
109+
);
110+
111+
useErrorToast(statusCountError);
112+
113+
onBeforeUnmount(() => {
114+
onFilterChangeDebounced.cancel();
115+
});
116+
117+
onBeforeRouteLeave(() => {
118+
active.value = false;
119+
});
120+
</script>

composables/useProbeFilters.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { ref } from 'vue';
33
import { useRoute } from 'vue-router';
44
import { useUserFilter } from '~/composables/useUserFilter';
55
import { ONLINE_STATUSES, OFFLINE_STATUSES } from '~/constants/probes';
6+
import { useAuth } from '~/store/auth';
67

78
export type StatusCode = 'all' | 'online' | 'ping-test-failed' | 'offline' | 'online-outdated';
9+
export type AdoptionOption = 'all' | 'adopted' | 'non-adopted';
810

911
type StatusOption = {
1012
name: string;
@@ -17,9 +19,10 @@ export interface Filter {
1719
status: StatusCode;
1820
by: string;
1921
desc: boolean;
22+
adoption: AdoptionOption;
2023
}
2124

22-
const DEFAULT_FILTER: Filter = { search: '', status: 'all', by: 'name', desc: false } as const;
25+
const DEFAULT_FILTER: Filter = { search: '', status: 'all', by: 'name', desc: false, adoption: 'all' } as const;
2326

2427
export const SORTABLE_FIELDS: string[] = [ 'name', 'location', 'tags' ] as const;
2528

@@ -31,12 +34,15 @@ export const STATUS_MAP: Record<StatusCode, StatusOption> = {
3134
'offline': { name: 'Offline', options: OFFLINE_STATUSES },
3235
} as const;
3336

37+
export const ADOPTION_OPTIONS: string[] = [ 'all', 'adopted', 'non-adopted' ] as const;
38+
3439
interface ProbeFiltersOptions {
3540
active?: MaybeRefOrGetter<boolean>;
3641
}
3742

3843
export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {}) => {
3944
const route = useRoute();
45+
const auth = useAuth();
4046
const { getUserFilter } = useUserFilter();
4147

4248
const filter = ref<Filter>({ ...DEFAULT_FILTER });
@@ -46,8 +52,8 @@ export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {
4652
const { sortField = '', sortOrder = 1 } = event;
4753

4854
if (!sortOrder || typeof sortField !== 'string' || !SORTABLE_FIELDS.includes(sortField)) {
49-
filter.value.by = 'name';
50-
filter.value.desc = false;
55+
filter.value.by = DEFAULT_FILTER.by;
56+
filter.value.desc = DEFAULT_FILTER.desc;
5157
} else {
5258
filter.value.by = sortField;
5359
filter.value.desc = sortOrder === -1;
@@ -68,9 +74,10 @@ export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {
6874

6975
const constructQuery = () => ({
7076
...filter.value.search && { filter: filter.value.search },
71-
...filter.value.by !== 'name' && { by: filter.value.by },
77+
...!isDefault('by') && { by: filter.value.by },
7278
...filter.value.desc && { desc: 'true' },
73-
...filter.value.status !== 'all' && { status: filter.value.status },
79+
...!isDefault('status') && { status: filter.value.status },
80+
...auth.isAdmin && !isDefault('adoption') && { adoption: filter.value.adoption },
7481
});
7582

7683
const onParamChange = () => {
@@ -100,19 +107,33 @@ export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {
100107
}
101108
};
102109

103-
const getCurrentFilter = (includeStatus: boolean = false) => ({
104-
...getUserFilter('userId'),
105-
...filter.value.search && { searchIndex: { _icontains: filter.value.search } },
106-
...includeStatus && filter.value.status !== 'all' && { status: { _in: STATUS_MAP[filter.value.status].options } },
107-
...includeStatus && filter.value.status === 'online-outdated' && { isOutdated: { _eq: true } },
108-
});
110+
const getCurrentFilter = (ignoredFields: Array<keyof Filter> = []) => getDirectusFilter(filter, ignoredFields);
111+
112+
const getDirectusFilter = (filter: MaybeRefOrGetter<Filter>, ignoredFields: Array<keyof Filter> = []) => {
113+
const filterValue = toValue(filter);
114+
115+
return {
116+
...getUserFilter('userId'),
117+
...filterValue.search && { searchIndex: { _icontains: filterValue.search } },
118+
...!ignoredFields.includes('status') && !isDefault('status', filter) && { status: { _in: STATUS_MAP[filterValue.status].options } },
119+
...!ignoredFields.includes('status') && filterValue.status === 'online-outdated' && { isOutdated: { _eq: true } },
120+
...!ignoredFields.includes('adoption') && auth.isAdmin && !isDefault('adoption', filter) && {
121+
userId: filterValue.adoption === 'adopted' ? { _neq: null } : { _eq: null },
122+
},
123+
};
124+
};
125+
126+
const isDefault = (field: keyof Filter, filterObj: MaybeRefOrGetter<Filter> = filter) => {
127+
return toValue(filterObj)[field] === DEFAULT_FILTER[field];
128+
};
109129

110130
watch([
111131
() => route.query.filter,
112132
() => route.query.by,
113133
() => route.query.desc,
114134
() => route.query.status,
115-
], async ([ search, by, desc, status ]) => {
135+
() => route.query.adoption,
136+
], async ([ search, by, desc, status, adoption ]) => {
116137
if (!toValue(active)) {
117138
return;
118139
}
@@ -140,6 +161,12 @@ export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {
140161
} else {
141162
filter.value.status = DEFAULT_FILTER.status;
142163
}
164+
165+
if (typeof adoption === 'string' && ADOPTION_OPTIONS.includes(adoption) && auth.isAdmin) {
166+
filter.value.adoption = adoption as AdoptionOption;
167+
} else {
168+
filter.value.adoption = DEFAULT_FILTER.adoption;
169+
}
143170
}, { immediate: true });
144171

145172
return {
@@ -149,10 +176,13 @@ export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {
149176
// handlers
150177
onSortChange,
151178
onFilterChange,
152-
onStatusChange: () => onParamChange(),
179+
onParamChange,
153180
onBatchChange,
154181
// builders
155182
getSortSettings,
156183
getCurrentFilter,
184+
getDirectusFilter,
185+
// helpers
186+
isDefault,
157187
};
158188
};

0 commit comments

Comments
 (0)