Skip to content
30 changes: 30 additions & 0 deletions components/probe/ProbeFilters/AdminFilterSettings.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-2">
<span class="flex items-center font-bold">Adoption status:</span>
<Select
v-model="usedFilter.adoption"
:options="ADOPTION_OPTIONS"
class="min-w-48"
@change="onChange"
>
<template #value="{value}">
{{ value[0].toUpperCase() + value.slice(1) }}
</template>
<template #option="{option}">
{{ option[0].toUpperCase() + option.slice(1) }}
</template>
</Select>
</div>
</template>

<script setup lang="ts">
import { useProbeFilters, ADOPTION_OPTIONS, type Filter } from '~/composables/useProbeFilters';

const filter = defineModel('filter', { required: false, type: Object as PropType<Filter> });

const { filter: appliedFilter, onParamChange } = useProbeFilters();
const usedFilter = computed(() => filter.value ?? appliedFilter.value);

// only apply changes if the default (shared) filter is used
const onChange = filter.value ? () => {} : onParamChange;
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<span class="flex items-center font-bold">Filter:</span>
<IconField class="h-9 !w-full">
<InputIcon class="pi pi-search"/>
<InputText v-model="draftFilter.search" class="m-0 size-full" placeholder="Filter by name, location, or tags"/>
<InputText v-model="searchInput" class="m-0 size-full" placeholder="Filter by name, location, or tags" @value-change="onFilterChangeDebounced"/>
</IconField>
</InputGroup>

Expand Down Expand Up @@ -85,33 +85,33 @@
</div>
</div>

<AdminFilterSettings v-if="auth.isAdmin" v-model:filter="draftFilter"/>

<div class="mt-4 flex justify-end gap-2">
<Button label="Cancel" severity="secondary" text @click="emit('cancel')"/>
<Button label="Apply" @click="emit('apply', draftFilter)"/>
<Button label="Apply" @click="onBatchChange(draftFilter); emit('apply')"/>
</div>
</div>
</template>

<script setup lang="ts">
import {
type StatusCode,
type Filter,
SORTABLE_FIELDS,
STATUS_MAP,
} from '~/composables/useProbeFilters';

const { filter, statusCounts } = defineProps({
filter: {
required: true,
type: Object as PropType<Filter>,
},
statusCounts: {
required: true,
type: Object as PropType<Record<StatusCode, number>>,
},
});
import { aggregate } from '@directus/sdk';
import debounce from 'lodash/debounce';
import AdminFilterSettings from '~/components/probe/ProbeFilters/AdminFilterSettings.vue';
import { useErrorToast } from '~/composables/useErrorToast';
import { type StatusCode, SORTABLE_FIELDS, STATUS_MAP, useProbeFilters } from '~/composables/useProbeFilters';
import { useAuth } from '~/store/auth';

const auth = useAuth();
const { $directus } = useNuxtApp();

const { filter, getDirectusFilter, onBatchChange } = useProbeFilters();

const draftFilter = ref({ ...filter.value });
const draftFilterDeps = computed(() => ({ ...draftFilter.value }));

const draftFilter = ref({ ...filter });
const searchInput = ref(draftFilter.value.search);
const onFilterChangeDebounced = debounce(() => draftFilter.value.search = searchInput.value, 300);

const STATUS_CODES = Object.keys(STATUS_MAP) as StatusCode[];

Expand All @@ -121,8 +121,36 @@
tags: 'Tag count',
};

const { data: statusCounts, error: statusCountError } = await useLazyAsyncData(
() => $directus.request<[{ count: number; status: Status; isOutdated: boolean }]>(aggregate('gp_probes', {
query: {
filter: getDirectusFilter(draftFilter, [ 'status' ]),
groupBy: [ 'status', 'isOutdated' ],
},
aggregate: { count: '*' },
})),
{
watch: [ draftFilterDeps ],
default: () => Object.fromEntries(STATUS_CODES.map(status => [ status, 0 ])),
transform: (data) => {
const counts = Object.fromEntries(STATUS_CODES.map(status => [ status, 0 ]));

STATUS_CODES.forEach((code) => {
counts[code] = data.reduce((sum, status) => {
return STATUS_MAP[code].options.includes(status.status) && (status.isOutdated || !STATUS_MAP[code].outdatedOnly)
? sum + status.count
: sum;
}, 0);
});

return counts;
},
},
);

useErrorToast(statusCountError);

const emit = defineEmits<{
(e: 'cancel'): void;
(e: 'apply', payload: Filter): void;
(e: 'cancel' | 'apply'): void;
}>();
</script>
120 changes: 120 additions & 0 deletions components/probe/ProbeFilters/ProbeListFilters.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<template>
<div class="ml-auto flex h-9 items-stretch gap-x-2 self-end font-normal">
<span class="flex items-center font-bold">Status:</span>
<Select
v-model="filter.status"
:options="STATUS_CODES"
:pt="{ listContainer: { class: '!max-h-64' } }"
option-label="code"
class="min-w-64"
@change="onParamChange"
>
<template #option="{option}: {option: StatusCode}">
<span class="flex h-full items-center gap-2">
<span
:class="{
'font-bold text-bluegray-900 dark:text-white': option === filter.status,
'text-bluegray-400': option !== filter.status
}">
{{ STATUS_MAP[option].name }}
</span>
<Tag
class="-my-0.5"
:class="{
'bg-primary text-white dark:bg-white dark:text-bluegray-900 ': option === filter.status,
'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
}">
{{ statusCounts[option] }}
</Tag>
</span>
</template>

<template #value="{value}: {value: StatusCode}">
<span class="flex h-full items-center gap-2">
<span class="text-bluegray-400">{{ STATUS_MAP[value].name }}</span>
<Tag class="-my-1 border ">{{ statusCounts[value] }}</Tag>
</span>
</template>
</Select>
<InputGroup class="!w-auto">
<IconField>
<InputIcon class="pi pi-search"/>
<InputText v-model="searchInput" class="m-0 h-full min-w-[280px]" placeholder="Filter by name, location, or tags" @input="onFilterChangeDebounced"/>
</IconField>
</InputGroup>

<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">
<Button
class="relative hover:bg-white focus:ring-primary dark:hover:bg-dark-900 dark:focus:ring-primary"
severity="secondary"
size="small"
text
@click="adminOptsRef.toggle($event)">
<i class="pi pi-sliders-h"/>
<i v-if="!isDefault('adoption')" class="pi pi-circle-fill absolute right-2 top-2 text-[0.4rem] text-primary"/>
</Button>

<Popover ref="adminOptsRef" class="w-fit gap-4 p-4 [&>*]:border-none" role="dialog">
<AdminFilterSettings/>
</Popover>
</div>
</div>
</template>

<script setup lang="ts">
import { aggregate } from '@directus/sdk';
import debounce from 'lodash/debounce';
import AdminFilterSettings from '~/components/probe/ProbeFilters/AdminFilterSettings.vue';
import { useErrorToast } from '~/composables/useErrorToast';
import { STATUS_MAP, type StatusCode, useProbeFilters } from '~/composables/useProbeFilters';
import { useAuth } from '~/store/auth';

const auth = useAuth();
const { $directus } = useNuxtApp();

const active = ref(true);
const { filter, onParamChange, onFilterChange, getDirectusFilter, isDefault } = useProbeFilters({ active });
const searchInput = ref(filter.value.search);
const onFilterChangeDebounced = debounce(() => onFilterChange(searchInput.value), 500);
const filterDeps = computed(() => ({ ...filter.value }));

const adminOptsRef = ref();
const STATUS_CODES = Object.keys(STATUS_MAP) as StatusCode[];

const { data: statusCounts, error: statusCountError } = await useLazyAsyncData(
() => $directus.request<[{ count: number; status: Status; isOutdated: boolean }]>(aggregate('gp_probes', {
query: {
filter: getDirectusFilter(filter, [ 'status' ]),
groupBy: [ 'status', 'isOutdated' ],
},
aggregate: { count: '*' },
})),
{
watch: [ filterDeps ],
default: () => Object.fromEntries(STATUS_CODES.map(status => [ status, 0 ])),
transform: (data) => {
const counts = Object.fromEntries(STATUS_CODES.map(status => [ status, 0 ]));

STATUS_CODES.forEach((code) => {
counts[code] = data.reduce((sum, status) => {
return STATUS_MAP[code].options.includes(status.status) && (status.isOutdated || !STATUS_MAP[code].outdatedOnly)
? sum + status.count
: sum;
}, 0);
});

return counts;
},
},
);

useErrorToast(statusCountError);

onBeforeUnmount(() => {
onFilterChangeDebounced.cancel();
});

onBeforeRouteLeave(() => {
active.value = false;
});
</script>
56 changes: 43 additions & 13 deletions composables/useProbeFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { ref } from 'vue';
import { useRoute } from 'vue-router';
import { useUserFilter } from '~/composables/useUserFilter';
import { ONLINE_STATUSES, OFFLINE_STATUSES } from '~/constants/probes';
import { useAuth } from '~/store/auth';

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

type StatusOption = {
name: string;
Expand All @@ -17,9 +19,10 @@ export interface Filter {
status: StatusCode;
by: string;
desc: boolean;
adoption: AdoptionOption;
}

const DEFAULT_FILTER: Filter = { search: '', status: 'all', by: 'name', desc: false } as const;
const DEFAULT_FILTER: Filter = { search: '', status: 'all', by: 'name', desc: false, adoption: 'all' } as const;

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

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

export const ADOPTION_OPTIONS: string[] = [ 'all', 'adopted', 'non-adopted' ] as const;

interface ProbeFiltersOptions {
active?: MaybeRefOrGetter<boolean>;
}

export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {}) => {
const route = useRoute();
const auth = useAuth();
const { getUserFilter } = useUserFilter();

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

if (!sortOrder || typeof sortField !== 'string' || !SORTABLE_FIELDS.includes(sortField)) {
filter.value.by = 'name';
filter.value.desc = false;
filter.value.by = DEFAULT_FILTER.by;
filter.value.desc = DEFAULT_FILTER.desc;
} else {
filter.value.by = sortField;
filter.value.desc = sortOrder === -1;
Expand All @@ -68,9 +74,10 @@ export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {

const constructQuery = () => ({
...filter.value.search && { filter: filter.value.search },
...filter.value.by !== 'name' && { by: filter.value.by },
...!isDefault('by') && { by: filter.value.by },
...filter.value.desc && { desc: 'true' },
...filter.value.status !== 'all' && { status: filter.value.status },
...!isDefault('status') && { status: filter.value.status },
...auth.isAdmin && !isDefault('adoption') && { adoption: filter.value.adoption },
});

const onParamChange = () => {
Expand Down Expand Up @@ -100,19 +107,33 @@ export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {
}
};

const getCurrentFilter = (includeStatus: boolean = false) => ({
...getUserFilter('userId'),
...filter.value.search && { searchIndex: { _icontains: filter.value.search } },
...includeStatus && filter.value.status !== 'all' && { status: { _in: STATUS_MAP[filter.value.status].options } },
...includeStatus && filter.value.status === 'online-outdated' && { isOutdated: { _eq: true } },
});
const getCurrentFilter = (ignoredFields: Array<keyof Filter> = []) => getDirectusFilter(filter, ignoredFields);

const getDirectusFilter = (filter: MaybeRefOrGetter<Filter>, ignoredFields: Array<keyof Filter> = []) => {
const filterValue = toValue(filter);

return {
...getUserFilter('userId'),
...filterValue.search && { searchIndex: { _icontains: filterValue.search } },
...!ignoredFields.includes('status') && !isDefault('status', filter) && { status: { _in: STATUS_MAP[filterValue.status].options } },
...!ignoredFields.includes('status') && filterValue.status === 'online-outdated' && { isOutdated: { _eq: true } },
...!ignoredFields.includes('adoption') && auth.isAdmin && !isDefault('adoption', filter) && {
userId: filterValue.adoption === 'adopted' ? { _neq: null } : { _eq: null },
},
};
};

const isDefault = (field: keyof Filter, filterObj: MaybeRefOrGetter<Filter> = filter) => {
return toValue(filterObj)[field] === DEFAULT_FILTER[field];
};

watch([
() => route.query.filter,
() => route.query.by,
() => route.query.desc,
() => route.query.status,
], async ([ search, by, desc, status ]) => {
() => route.query.adoption,
], async ([ search, by, desc, status, adoption ]) => {
if (!toValue(active)) {
return;
}
Expand Down Expand Up @@ -140,6 +161,12 @@ export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {
} else {
filter.value.status = DEFAULT_FILTER.status;
}

if (typeof adoption === 'string' && ADOPTION_OPTIONS.includes(adoption) && auth.isAdmin) {
filter.value.adoption = adoption as AdoptionOption;
} else {
filter.value.adoption = DEFAULT_FILTER.adoption;
}
}, { immediate: true });

return {
Expand All @@ -149,10 +176,13 @@ export const useProbeFilters = ({ active = () => true }: ProbeFiltersOptions = {
// handlers
onSortChange,
onFilterChange,
onStatusChange: () => onParamChange(),
onParamChange,
onBatchChange,
// builders
getSortSettings,
getCurrentFilter,
getDirectusFilter,
// helpers
isDefault,
};
};
Loading