Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions app/components/AsyncCell.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
<template>
<Skeleton v-if="loading" class="flex max-w-40 self-center"/>
<Skeleton v-if="loading" class="flex self-center" :class="sizeClass"/>
<slot v-else/>
</template>

<script setup lang="ts">
defineProps({
const props = defineProps({
loading: {
type: Boolean,
required: true,
},
size: {
type: String,
default: 'normal',
},
});

const sizeClass = computed(() => props.size === 'small' ? 'max-w-20' : 'max-w-40');
</script>
136 changes: 136 additions & 0 deletions app/components/credits/CreditsFilters.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<template>
<div class="flex items-center justify-end gap-4">
Type:
<TreeSelect
v-model="selectedValues"
:options="nodes"
selection-mode="checkbox"
class="w-32 font-normal md:w-56"
:expanded-keys="expandedKeys"
:pt="{
pcTree: {
root: 'p-0',
nodeToggleButton: 'hidden',
node: 'focus:outline-none',
}
}"
@change="onChangeDebounced"
@hide="onHide"
>
<template #value="slotProps">
{{ renderTreeSelectValue(slotProps.value, nodes) }}
</template>
<template #option="slotProps">
<span :class="{'font-medium': slotProps.node.data.field === 'type'}">{{slotProps.node.label}}</span>
</template>
</TreeSelect>
</div>
</template>

<script setup>
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import { FIELD_LABELS, TYPE_REASONS, useCreditsFilters } from '~/composables/useCreditsFilters';
import { buildNodesByKey, renderTreeSelectValue } from '~/utils/tree-select.ts';

const { filter, onParamChange, key: creditsFilterKey } = useCreditsFilters();

// TREE SELECT STRUCTURE DEFINITIONS

// e.g., 0-0 -> { field: 'reason', value: 'adopted-probes' }
const keyValueMap = Object.entries(TYPE_REASONS).reduce((map, [ key, reasons ], index) => {
map[index] = { field: 'type', value: key };

reasons.forEach((reason, reasonIndex) => {
map[`${index}-${reasonIndex}`] = { field: 'reason', value: reason };
});

return map;
}, {});

const getNodeLabelForKey = (key) => {
const { field, value } = keyValueMap[key];
return FIELD_LABELS[field][value];
};

const nodes = ref(buildNodesByKey(keyValueMap, getNodeLabelForKey));

// expand all nodes that have children
const expandedKeys = computed(() => nodes.value.reduce((expanded, node) => {
if (node.children?.length) {
expanded[node.key] = true;
}

return expanded;
}, {}));

const selectedValues = ref({});
const selectedCount = computed(() => Object.values(selectedValues.value).filter(value => value.checked).length);

// FILTER APPLICATION

const applyFilter = () => {
// apply selected values from the filter
const buildSelection = (node, filter, selectedValues) => {
if (node.children?.length) {
node.children.forEach((child) => {
buildSelection(child, filter, selectedValues);
});

const checkedChildren = node.children.filter(child => selectedValues[child.key].checked);

selectedValues[node.key] = {
checked: checkedChildren.length === node.children.length,
partialChecked: checkedChildren.length !== node.children.length && checkedChildren.length > 0,
};
} else {
selectedValues[node.key] = {
checked: filter[node.data.field].includes(node.data.value),
};
}

return selectedValues;
};

selectedValues.value = nodes.value.reduce((selected, node) => buildSelection(node, filter.value, selected), {});
};

watch(creditsFilterKey, applyFilter, { immediate: true });

// HANDLERS

const onChange = () => {
if (selectedCount.value === 0) {
return;
}

const suggestedFilter = Object.entries(selectedValues.value).reduce((selected, [ key, { checked, partialChecked }]) => {
if (checked || partialChecked) {
const { field, value } = keyValueMap[key];
selected[field].push(value);
}

return selected;
}, {
type: [],
reason: [],
});

// do not reset page (via onParamChange) if there is no filter change
if (!isEqual(suggestedFilter, filter.value)) {
filter.value = suggestedFilter;
onParamChange();
}
};

const onChangeDebounced = debounce(onChange, 350);

const onHide = () => {
onChangeDebounced.flush();
applyFilter();
};

onBeforeUnmount(() => {
onChangeDebounced.cancel();
});
</script>
51 changes: 35 additions & 16 deletions app/components/probe/ProbeFilters/AdminFilterSettings.vue
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
<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 class="flex flex-col gap-4">
<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}">
{{upperFirst(value)}}
</template>
<template #option="{option}">
{{upperFirst(option)}}
</template>
</Select>
</div>
<div class="flex flex-col gap-2">
<span class="flex items-center font-bold">Probe type:</span>
<Select
v-model="usedFilter.probeType"
:options="PROBE_TYPE_OPTIONS"
class="min-w-48"
@change="onChange"
>
<template #value="{value}">
{{upperFirst(value)}}
</template>
<template #option="{option}">
{{upperFirst(option)}}
</template>
</Select>
</div>
</div>
</template>

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

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

Expand Down
4 changes: 2 additions & 2 deletions app/components/probe/ProbeFilters/ProbeListFilters.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
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"/>
<i v-if="anyAdminFilterApplied" 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">
Expand All @@ -73,7 +73,7 @@
const { $directus } = useNuxtApp();

const active = ref(true);
const { filter, onParamChange, onFilterChange, getDirectusFilter, isDefault } = useProbeFilters({ active });
const { filter, onParamChange, onFilterChange, getDirectusFilter, anyAdminFilterApplied } = useProbeFilters({ active });
const searchInput = ref(filter.value.search);
const onFilterChangeDebounced = debounce(() => onFilterChange(searchInput.value), 500);
const filterDeps = computed(() => ({ ...filter.value }));
Expand Down
108 changes: 108 additions & 0 deletions app/composables/useCreditsFilters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import cloneDeep from 'lodash/cloneDeep';
import isEqual from 'lodash/isEqual';
import { ref } from 'vue';
import { useRoute } from 'vue-router';

type CreditsChangeType = 'additions' | 'deductions';
type CreditsChangeReason = 'adopted-probes' | 'sponsorship';

type Filter = {
type: CreditsChangeType[];
reason: CreditsChangeReason[];
};

const PERMITTED_VALUES = {
type: [ 'additions', 'deductions' ],
reason: [ 'adopted-probes', 'sponsorship' ],
};

const DEFAULT_FILTER = cloneDeep(PERMITTED_VALUES) as Filter;

export const FIELD_LABELS = {
type: {
additions: 'Additions',
deductions: 'Deductions',
},
reason: {
'adopted-probes': 'Adopted probes',
'sponsorship': 'Sponsorship',
},
};

export const TYPE_REASONS = {
additions: [ 'adopted-probes', 'sponsorship' ],
deductions: [],
};

export const useCreditsFilters = () => {
const route = useRoute();
const active = ref(true);
const filter = ref<Filter>(cloneDeep(DEFAULT_FILTER));
const key = computed(() => JSON.stringify(filter.value));
const anyFilterApplied = computed(() => (Object.keys(DEFAULT_FILTER) as Array<keyof Filter>).some(key => !isDefault(key)));

const constructQuery = () => ({
...!isDefault('type') && filter.value.type.length && { type: filter.value.type },
...!isDefault('reason') && filter.value.reason.length && { reason: filter.value.reason },
});

const onParamChange = () => {
navigateTo({
query: constructQuery(),
});
};

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

const getCurrentFilter = () => {
const { type, reason } = filter.value;
const allReasons = new Set(PERMITTED_VALUES.reason);
const filterReasons = new Set(reason);

return {
type,
reason: isEqual(filterReasons, allReasons) ? [ ...reason, 'other' ] : reason,
};
};

watch([ () => route.query.type, () => route.query.reason ], async ([ type, reason ]) => {
if (!toValue(active)) {
return;
}

const reasonArray = Array.isArray(reason) ? reason : [ reason ];
const typeArray = Array.isArray(type) ? type : [ type ];

if (type && typeArray.every(type => PERMITTED_VALUES.type.includes(type!))) {
filter.value.type = typeArray as CreditsChangeType[];
} else {
filter.value.type = DEFAULT_FILTER.type;
}

if (reason && filter.value.type.includes('additions') && reasonArray.every(reason => PERMITTED_VALUES.reason.includes(reason!))) {
filter.value.reason = reasonArray as CreditsChangeReason[];
} else {
filter.value.reason = filter.value.type.includes('additions') ? DEFAULT_FILTER.reason : [];
}
}, { immediate: true });

onBeforeRouteLeave(() => {
active.value = false;
});

return {
// state
anyFilterApplied,
filter,
key,
// handlers
onParamChange,
// builders
constructQuery,
getCurrentFilter,
// helpers
isDefault,
};
};
Loading
Loading