Skip to content

Commit f817c71

Browse files
PavelKopeckyMartinKolarik
authored andcommitted
feat: add credits page filters (#154)
1 parent 545e641 commit f817c71

File tree

10 files changed

+408
-41
lines changed

10 files changed

+408
-41
lines changed

app/components/AsyncCell.vue

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
<template>
2-
<Skeleton v-if="loading" class="flex max-w-40 self-center"/>
2+
<Skeleton v-if="loading" class="flex self-center" :class="sizeClass"/>
33
<slot v-else/>
44
</template>
55

66
<script setup lang="ts">
7-
defineProps({
7+
const props = defineProps({
88
loading: {
99
type: Boolean,
1010
required: true,
1111
},
12+
size: {
13+
type: String,
14+
default: 'normal',
15+
},
1216
});
17+
18+
const sizeClass = computed(() => props.size === 'small' ? 'max-w-20' : 'max-w-40');
1319
</script>
File renamed without changes.
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<template>
2+
<div class="flex items-center justify-end gap-4">
3+
Type:
4+
<TreeSelect
5+
v-model="selectedValues"
6+
:options="nodes"
7+
selection-mode="checkbox"
8+
class="w-32 font-normal md:w-56"
9+
:expanded-keys="expandedKeys"
10+
:pt="{
11+
pcTree: {
12+
root: 'p-0',
13+
nodeToggleButton: 'hidden',
14+
node: 'focus:outline-none',
15+
}
16+
}"
17+
@change="onChangeDebounced"
18+
@hide="onHide"
19+
>
20+
<template #value="slotProps">
21+
{{ renderTreeSelectValue(slotProps.value, nodes) }}
22+
</template>
23+
<template #option="slotProps">
24+
<span :class="{'font-medium': slotProps.node.data.field === 'type'}">{{slotProps.node.label}}</span>
25+
</template>
26+
</TreeSelect>
27+
</div>
28+
</template>
29+
30+
<script setup>
31+
import debounce from 'lodash/debounce';
32+
import isEqual from 'lodash/isEqual';
33+
import { FIELD_LABELS, TYPE_REASONS, useCreditsFilters } from '~/composables/useCreditsFilters';
34+
import { buildNodesByKey, renderTreeSelectValue } from '~/utils/tree-select.ts';
35+
36+
const { filter, onParamChange, key: creditsFilterKey } = useCreditsFilters();
37+
38+
// TREE SELECT STRUCTURE DEFINITIONS
39+
40+
// e.g., 0-0 -> { field: 'reason', value: 'adopted-probes' }
41+
const keyValueMap = Object.entries(TYPE_REASONS).reduce((map, [ key, reasons ], index) => {
42+
map[index] = { field: 'type', value: key };
43+
44+
reasons.forEach((reason, reasonIndex) => {
45+
map[`${index}-${reasonIndex}`] = { field: 'reason', value: reason };
46+
});
47+
48+
return map;
49+
}, {});
50+
51+
const getNodeLabelForKey = (key) => {
52+
const { field, value } = keyValueMap[key];
53+
return FIELD_LABELS[field][value];
54+
};
55+
56+
const nodes = ref(buildNodesByKey(keyValueMap, getNodeLabelForKey));
57+
58+
// expand all nodes that have children
59+
const expandedKeys = computed(() => nodes.value.reduce((expanded, node) => {
60+
if (node.children?.length) {
61+
expanded[node.key] = true;
62+
}
63+
64+
return expanded;
65+
}, {}));
66+
67+
const selectedValues = ref({});
68+
const selectedCount = computed(() => Object.values(selectedValues.value).filter(value => value.checked).length);
69+
70+
// FILTER APPLICATION
71+
72+
const applyFilter = () => {
73+
// apply selected values from the filter
74+
const buildSelection = (node, filter, selectedValues) => {
75+
if (node.children?.length) {
76+
node.children.forEach((child) => {
77+
buildSelection(child, filter, selectedValues);
78+
});
79+
80+
const checkedChildren = node.children.filter(child => selectedValues[child.key].checked);
81+
82+
selectedValues[node.key] = {
83+
checked: checkedChildren.length === node.children.length,
84+
partialChecked: checkedChildren.length !== node.children.length && checkedChildren.length > 0,
85+
};
86+
} else {
87+
selectedValues[node.key] = {
88+
checked: filter[node.data.field].includes(node.data.value),
89+
};
90+
}
91+
92+
return selectedValues;
93+
};
94+
95+
selectedValues.value = nodes.value.reduce((selected, node) => buildSelection(node, filter.value, selected), {});
96+
};
97+
98+
watch(creditsFilterKey, applyFilter, { immediate: true });
99+
100+
// HANDLERS
101+
102+
const onChange = () => {
103+
if (selectedCount.value === 0) {
104+
return;
105+
}
106+
107+
const suggestedFilter = Object.entries(selectedValues.value).reduce((selected, [ key, { checked, partialChecked }]) => {
108+
if (checked || partialChecked) {
109+
const { field, value } = keyValueMap[key];
110+
selected[field].push(value);
111+
}
112+
113+
return selected;
114+
}, {
115+
type: [],
116+
reason: [],
117+
});
118+
119+
// do not reset page (via onParamChange) if there is no filter change
120+
if (!isEqual(suggestedFilter, filter.value)) {
121+
filter.value = suggestedFilter;
122+
onParamChange();
123+
}
124+
};
125+
126+
const onChangeDebounced = debounce(onChange, 350);
127+
128+
const onHide = () => {
129+
onChangeDebounced.flush();
130+
applyFilter();
131+
};
132+
133+
onBeforeUnmount(() => {
134+
onChangeDebounced.cancel();
135+
});
136+
</script>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import cloneDeep from 'lodash/cloneDeep';
2+
import isEqual from 'lodash/isEqual';
3+
import { ref } from 'vue';
4+
import { useRoute } from 'vue-router';
5+
6+
type CreditsChangeType = 'additions' | 'deductions';
7+
type CreditsChangeReason = 'adopted-probes' | 'sponsorship';
8+
9+
type Filter = {
10+
type: CreditsChangeType[];
11+
reason: CreditsChangeReason[];
12+
};
13+
14+
const PERMITTED_VALUES = {
15+
type: [ 'additions', 'deductions' ],
16+
reason: [ 'adopted-probes', 'sponsorship' ],
17+
};
18+
19+
const DEFAULT_FILTER = cloneDeep(PERMITTED_VALUES) as Filter;
20+
21+
export const FIELD_LABELS = {
22+
type: {
23+
additions: 'Additions',
24+
deductions: 'Deductions',
25+
},
26+
reason: {
27+
'adopted-probes': 'Adopted probes',
28+
'sponsorship': 'Sponsorship',
29+
},
30+
};
31+
32+
export const TYPE_REASONS = {
33+
additions: [ 'adopted-probes', 'sponsorship' ],
34+
deductions: [],
35+
};
36+
37+
export const useCreditsFilters = () => {
38+
const route = useRoute();
39+
const active = ref(true);
40+
const filter = ref<Filter>(cloneDeep(DEFAULT_FILTER));
41+
const key = computed(() => JSON.stringify(filter.value));
42+
const anyFilterApplied = computed(() => (Object.keys(DEFAULT_FILTER) as Array<keyof Filter>).some(key => !isDefault(key)));
43+
44+
const constructQuery = () => ({
45+
...!isDefault('type') && filter.value.type.length && { type: filter.value.type },
46+
...!isDefault('reason') && filter.value.reason.length && { reason: filter.value.reason },
47+
});
48+
49+
const onParamChange = () => {
50+
navigateTo({
51+
query: constructQuery(),
52+
});
53+
};
54+
55+
const isDefault = (field: keyof Filter, filterObj: MaybeRefOrGetter<Filter> = filter) => {
56+
return isEqual(toValue(filterObj)[field], DEFAULT_FILTER[field]);
57+
};
58+
59+
const getCurrentFilter = () => {
60+
const { type, reason } = filter.value;
61+
const allReasons = new Set(PERMITTED_VALUES.reason);
62+
const filterReasons = new Set(reason);
63+
64+
return {
65+
type,
66+
reason: isEqual(filterReasons, allReasons) ? [ ...reason, 'other' ] : reason,
67+
};
68+
};
69+
70+
watch([ () => route.query.type, () => route.query.reason ], async ([ type, reason ]) => {
71+
if (!toValue(active)) {
72+
return;
73+
}
74+
75+
const reasonArray = Array.isArray(reason) ? reason : [ reason ];
76+
const typeArray = Array.isArray(type) ? type : [ type ];
77+
78+
if (type && typeArray.every(type => PERMITTED_VALUES.type.includes(type!))) {
79+
filter.value.type = typeArray as CreditsChangeType[];
80+
} else {
81+
filter.value.type = DEFAULT_FILTER.type;
82+
}
83+
84+
if (reason && filter.value.type.includes('additions') && reasonArray.every(reason => PERMITTED_VALUES.reason.includes(reason!))) {
85+
filter.value.reason = reasonArray as CreditsChangeReason[];
86+
} else {
87+
filter.value.reason = filter.value.type.includes('additions') ? DEFAULT_FILTER.reason : [];
88+
}
89+
}, { immediate: true });
90+
91+
onBeforeRouteLeave(() => {
92+
active.value = false;
93+
});
94+
95+
return {
96+
// state
97+
anyFilterApplied,
98+
filter,
99+
key,
100+
// handlers
101+
onParamChange,
102+
// builders
103+
constructQuery,
104+
getCurrentFilter,
105+
// helpers
106+
isDefault,
107+
};
108+
};

0 commit comments

Comments
 (0)