Skip to content

Commit 8a16339

Browse files
authored
Merge pull request #1 from devforth/feature/AdminForth/1262/rework-i-line-filters-plugin
Feature/admin forth/1262/rework i line filters plugin
2 parents 2842d1b + df9d837 commit 8a16339

6 files changed

Lines changed: 251 additions & 306 deletions

File tree

custom/FiltersArea.vue

Lines changed: 23 additions & 290 deletions
Original file line numberDiff line numberDiff line change
@@ -1,305 +1,38 @@
11
<template>
2-
<div v-if="columnsWithFilter && columnsWithFilter.length > 0" class="flex flex-col w-full p-4 mb-4 rounded-lg border border-gray-100 dark:border-gray-700 shadow-sm dark:shadow-lg text-gray-900 dark:text-white">
3-
<div class ="flex justify-end items-center">
4-
<p
5-
class="hover:underline cursor-pointer text-blue-700 dark:text-blue-500 text-end"
6-
@click="isExpanded = !isExpanded"
7-
>
8-
{{ isExpanded ? 'Hide filters' : 'Show filters' }}
9-
</p>
10-
</div>
11-
<div v-if="isExpanded" class="md:grid md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6 gap-2 w-full">
12-
<div class="flex flex-col" v-for="c in columnsWithFilter" :key="c">
13-
<div class="min-w-48">
14-
<p class="dark:text-gray-400 font-medium text-sm">{{ c.label }}</p>
15-
<Select
16-
:teleportToBody="true"
17-
v-if="c.foreignResource"
18-
:multiple="c.filterOptions.multiselect"
19-
class="w-full"
20-
:options="columnOptions[c.name] || []"
21-
:searchDisabled="!c.foreignResource.searchableFields"
22-
@scroll-near-end="loadMoreOptions(c.name)"
23-
@search="(searchTerm) => {
24-
if (c.foreignResource.searchableFields && onSearchInput[c.name]) {
25-
onSearchInput[c.name](searchTerm);
26-
}
27-
}"
28-
@update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions.multiselect ? 'in' : 'eq', value: c.filterOptions.multiselect ? ($event.length ? $event : undefined) : $event || undefined })"
29-
:modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value || (c.filterOptions.multiselect ? [] : '')"
30-
:teleportToTop="true"
31-
>
32-
<template #extra-item v-if="columnLoadingState[c.name]?.loading">
33-
<div class="text-center text-gray-400 dark:text-gray-300 py-2 flex items-center justify-center gap-2">
34-
<Spinner class="w-4 h-4" />
35-
{{ $t('Loading...') }}
36-
</div>
37-
</template>
38-
</Select>
39-
<Select
40-
:teleportToBody="true"
41-
:multiple="c.filterOptions.multiselect"
42-
class="w-full"
43-
v-else-if="c.type === 'boolean'"
44-
:options="[
45-
{ label: $t('Yes'), value: true },
46-
{ label: $t('No'), value: false },
47-
// if field is not required, undefined might be there, and user might want to filter by it
48-
...(c.required ? [] : [ { label: $t('Unset'), value: undefined } ])
49-
]"
50-
@update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions.multiselect ? 'in' : 'eq', value: c.filterOptions.multiselect ? ($event.length ? $event : undefined) : $event })"
51-
:modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value !== undefined
52-
? filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value
53-
: (c.filterOptions.multiselect ? [] : '')"
54-
:teleportToTop="true"
55-
/>
56-
57-
<Select
58-
:teleportToBody="true"
59-
:multiple="c.filterOptions.multiselect"
60-
class="w-full"
61-
v-else-if="c.enum"
62-
:options="c.enum"
63-
@update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions.multiselect ? 'in' : 'eq', value: c.filterOptions.multiselect ? ($event.length ? $event : undefined) : $event || undefined })"
64-
:modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value || (c.filterOptions.multiselect ? [] : '')"
65-
:teleportToTop="true"
66-
/>
67-
68-
<Input
69-
v-else-if="['string', 'text', 'json', 'richtext', 'unknown'].includes(c.type)"
70-
type="text"
71-
full-width
72-
:placeholder="$t('Search')"
73-
@update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions?.substringSearch ? 'ilike' : 'eq', value: $event || undefined })"
74-
:modelValue="getFilterItem({ column: c, operator: c.filterOptions?.substringSearch ? 'ilike' : 'eq' })"
75-
/>
76-
77-
<CustomDateRangePicker
78-
v-else-if="['datetime', 'date', 'time'].includes(c.type)"
79-
:column="c"
80-
:valueStart="filtersStore.filters.find(f => f.field === c.name && f.operator === 'gte')?.value || undefined"
81-
@update:valueStart="onFilterInput[c.name]({ column: c, operator: 'gte', value: $event || undefined })"
82-
:valueEnd="filtersStore.filters.find(f => f.field === c.name && f.operator === 'lte')?.value || undefined"
83-
@update:valueEnd="onFilterInput[c.name]({ column: c, operator: 'lte', value: $event || undefined })"
84-
/>
85-
86-
<CustomRangePicker
87-
v-else-if="['integer', 'decimal', 'float'].includes(c.type) && c.allowMinMaxQuery"
88-
:valueStart="getFilterItem({ column: c, operator: 'gte' })"
89-
@update:valueStart="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? $event : undefined })"
90-
:valueEnd="getFilterItem({ column: c, operator: 'lte' })"
91-
@update:valueEnd="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? $event : undefined })"
92-
/>
93-
94-
<div v-else-if="['integer', 'decimal', 'float'].includes(c.type)" class="flex gap-2">
95-
<Input
96-
type="number"
97-
full-width
98-
aria-describedby="helper-text-explanation"
99-
:placeholder="$t('From')"
100-
@update:modelValue="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? $event : undefined })"
101-
:modelValue="getFilterItem({ column: c, operator: 'gte' })"
102-
/>
103-
<Input
104-
type="number"
105-
full-width
106-
aria-describedby="helper-text-explanation"
107-
:placeholder="$t('To')"
108-
@update:modelValue="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? $event : undefined })"
109-
:modelValue="getFilterItem({ column: c, operator: 'lte' })"
110-
/>
111-
</div>
112-
</div>
113-
</div>
114-
<template v-if="freeCells > 0" v-for="n in freeCells-1" :key="'free-cell-' + n">
115-
<div></div>
116-
</template>
117-
<template v-if="freeCells === 0" v-for="n in cols-1" :key="'free-cell2-' + n">
118-
<div></div>
119-
</template>
120-
<div class="flex items-end justify-end">
121-
<Button
122-
class="mt-6 max-w-24"
123-
@click="filtersStore.filters = [...filtersStore.filters.filter(f => filtersStore.shouldFilterBeHidden(f.field))]"
124-
:disabled="filtersStore.filters.length === 0"
125-
>
126-
Clear all
127-
</Button>
2+
<div class="flex gap-1">
3+
<div v-for="filter in meta.options" :key="filter.name" >
4+
<UniversalSearchInput
5+
v-if="filter.hasSearchInput"
6+
:meta="{
7+
placeholder: `Search ${filter.name}`,
8+
filterField: filter.name
9+
}"
10+
/>
11+
12+
<div v-else>
13+
<QickFiltersSelect
14+
:filter="filter"
15+
/>
12816
</div>
12917
</div>
13018
</div>
19+
13120
</template>
13221

13322
<script lang="ts" setup>
134-
import { onMounted, onUnmounted, computed, ref, reactive, watch } from 'vue';
135-
import { useFiltersStore } from '@/stores/filters';
136-
import { callAdminForthApi, loadMoreForeignOptions, searchForeignOptions, createSearchInputHandlers } from '@/utils';
137-
import { useRouter } from 'vue-router';
138-
import debounce from 'debounce';
139-
import { Select, Input, Button } from '@/afcl';
140-
import CustomRangePicker from "@/components/CustomRangePicker.vue";
141-
import CustomDateRangePicker from '@/components/CustomDateRangePicker.vue';
142-
import { useRoute } from 'vue-router';
143-
144-
const router = useRouter();
145-
const route = useRoute();
146-
const columnLoadingState = reactive({});
147-
const columnOffsets = reactive({});
148-
const columnEmptyResultsCount = reactive({});
149-
const filtersStore = useFiltersStore();
150-
const columnsMinMax = ref({});
151-
const isExpanded = ref(true);
152-
const freeCells = ref(0);
153-
const currentBreakpoint = ref("base");
154-
const cols = ref(1);
23+
import UniversalSearchInput from './UniversalSearchInput.vue';
24+
import QickFiltersSelect from './QickFiltersSelect.vue';
25+
import type { Filter } from './types';
15526
15627
const props = defineProps<{
157-
meta: any,
28+
meta: {
29+
pluginInstanceId: string,
30+
resourceId: string,
31+
options: Filter[]
32+
},
15833
resource: any,
15934
adminUser: any
16035
}>();
16136
162-
const columnOptions = ref({});
163-
const columnsWithFilter = computed(
164-
() => sortFilters(props.resource.columns?.filter(column => column.showIn.filter && props.meta.options.columns.some(c => c.column === column.name)), props.meta.options.columns) || []
165-
);
166-
167-
onMounted(async () => {
168-
// columnsMinMax.value = await callAdminForthApi({
169-
// path: '/get_min_max_for_columns',
170-
// method: 'POST',
171-
// body: {
172-
// resourceId: route.params.resourceId
173-
// }
174-
// });
175-
currentBreakpoint.value = getBreakpoint(window.innerWidth);
176-
calculateGridCols();
177-
window.addEventListener('resize', () => {
178-
const newBreakpoint = getBreakpoint(window.innerWidth)
179-
if (newBreakpoint !== currentBreakpoint.value) {
180-
currentBreakpoint.value = newBreakpoint;
181-
calculateGridCols();
182-
}
183-
})
184-
});
185-
186-
onUnmounted(() => {
187-
window.removeEventListener('resize', getBreakpoint)
188-
})
189-
190-
function getFilterItem({ column, operator }) {
191-
const filterValue = filtersStore.filters.find(f => f.field === column.name && f.operator === operator)?.value;
192-
return filterValue !== undefined ? filterValue : '';
193-
}
194-
195-
async function loadMoreOptions(columnName, searchTerm = '') {
196-
return loadMoreForeignOptions({
197-
columnName,
198-
searchTerm,
199-
columns: props.resource.columns,
200-
resourceId: Array.isArray(router.currentRoute.value.params.resourceId)
201-
? router.currentRoute.value.params.resourceId[0]
202-
: router.currentRoute.value.params.resourceId,
203-
columnOptions,
204-
columnLoadingState,
205-
columnOffsets,
206-
columnEmptyResultsCount
207-
});
208-
}
209-
210-
async function searchOptions(columnName, searchTerm) {
211-
return searchForeignOptions({
212-
columnName,
213-
searchTerm,
214-
columns: props.resource.columns,
215-
resourceId: Array.isArray(router.currentRoute.value.params.resourceId)
216-
? router.currentRoute.value.params.resourceId[0]
217-
: router.currentRoute.value.params.resourceId,
218-
columnOptions,
219-
columnLoadingState,
220-
columnOffsets,
221-
columnEmptyResultsCount
222-
});
223-
}
224-
225-
const onSearchInput = computed(() => {
226-
return createSearchInputHandlers(
227-
props.resource.columns,
228-
searchOptions,
229-
(column) => column.filterOptions?.debounceTimeMs || 300
230-
);
231-
});
232-
233-
const onFilterInput = computed(() => {
234-
if (!props.resource.columns) return {};
235-
236-
return props.resource.columns.reduce((acc, c) => {
237-
return {
238-
...acc,
239-
[c.name]: debounce(({ column, operator, value }) => {
240-
setFilterItem({ column, operator, value });
241-
}, c.filterOptions?.debounceTimeMs || 10),
242-
};
243-
}, {});
244-
});
245-
246-
function setFilterItem({ column, operator, value }) {
247-
248-
const index = filtersStore.filters.findIndex(f => f.field === column.name && f.operator === operator);
249-
if (value === undefined || value === '' || value === null) {
250-
if (index !== -1) {
251-
filtersStore.filters.splice(index, 1);
252-
}
253-
} else {
254-
if (index === -1) {
255-
filtersStore.setFilter({ field: column.name, value, operator });
256-
} else {
257-
filtersStore.setFilters([...filtersStore.filters.slice(0, index), { field: column.name, value, operator }, ...filtersStore.filters.slice(index + 1)])
258-
}
259-
}
260-
}
261-
262-
263-
function getBreakpoint(width) {
264-
if (width >= 1536) return '2xl'
265-
if (width >= 1280) return 'xl'
266-
if (width >= 1024) return 'lg'
267-
if (width >= 768) return 'md'
268-
if (width >= 640) return 'sm'
269-
return 'base'
270-
}
271-
272-
function calculateGridCols() {
273-
const size = currentBreakpoint.value;
274-
switch (size) {
275-
case '2xl':
276-
cols.value = 6;
277-
break;
278-
case 'xl':
279-
cols.value = 3;
280-
break;
281-
case 'lg':
282-
cols.value = 2;
283-
break;
284-
case 'md':
285-
cols.value = 1;
286-
break;
287-
default:
288-
cols.value = 1;
289-
}
290-
const rows = Math.ceil(columnsWithFilter.value.length / cols.value);
291-
freeCells.value = (cols.value * rows) - columnsWithFilter.value.length;
292-
}
293-
294-
function sortFilters(array, fieldsObject) {
295-
let desiredOrder = [];
296-
fieldsObject.forEach(element => {
297-
desiredOrder.push(element.column);
298-
});
299-
const sortedObjects = array.sort(
300-
(a, b) => desiredOrder.indexOf(a.name) - desiredOrder.indexOf(b.name)
301-
);
302-
return sortedObjects;
303-
}
30437
30538
</script>

custom/QickFiltersSelect.vue

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<template>
2+
<Select
3+
class="w-full"
4+
:options="selectOptions"
5+
v-model="selected"
6+
classesForInput="py-[4px] !text-sm bg-white rounded"
7+
teleportToBody
8+
></Select>
9+
</template>
10+
11+
12+
13+
<script lang="ts" setup>
14+
import { ref, onMounted, watch } from 'vue';
15+
import { Select } from '@/afcl'
16+
import type { Filter } from './types';
17+
import { useAdminforth } from '@/adminforth';
18+
import { AdminForthFilterOperators } from '@/types/Common';
19+
20+
const { list } = useAdminforth();
21+
22+
const props = defineProps<{
23+
filter: Filter
24+
}>();
25+
26+
const selected = ref(null);
27+
const selectOptions = ref<{ label: string, value: any }[]>([]);
28+
29+
onMounted(() => {
30+
selectOptions.value = props.filter.enum.map(option => ({
31+
label: option.label,
32+
value: option.label
33+
}));
34+
});
35+
36+
37+
watch(selected, (newValue) => {
38+
console.log('Selected value changed:', newValue);
39+
list?.updateFilter?.({
40+
field: `_qf_${props.filter.name}`,
41+
operator: AdminForthFilterOperators.EQ,
42+
value: newValue,
43+
});
44+
})
45+
46+
</script>

0 commit comments

Comments
 (0)