@adminforth/quick-filters 1.2.10 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build.log CHANGED
@@ -5,7 +5,10 @@
5
5
  sending incremental file list
6
6
  custom/
7
7
  custom/FiltersArea.vue
8
+ custom/QickFiltersSelect.vue
9
+ custom/UniversalSearchInput.vue
8
10
  custom/tsconfig.json
11
+ custom/types.ts
9
12
 
10
- sent 12,928 bytes received 58 bytes 25,972.00 bytes/sec
11
- total size is 12,713 speedup is 0.98
13
+ sent 5,172 bytes received 115 bytes 10,574.00 bytes/sec
14
+ total size is 4,745 speedup is 0.90
@@ -1,305 +1,38 @@
1
1
  <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
+ />
128
16
  </div>
129
17
  </div>
130
18
  </div>
19
+
131
20
  </template>
132
21
 
133
22
  <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';
155
26
 
156
27
  const props = defineProps<{
157
- meta: any,
28
+ meta: {
29
+ pluginInstanceId: string,
30
+ resourceId: string,
31
+ options: Filter[]
32
+ },
158
33
  resource: any,
159
34
  adminUser: any
160
35
  }>();
161
36
 
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
- }
304
37
 
305
38
  </script>
@@ -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,0 +1,92 @@
1
+ <template>
2
+ <div class="af-universal-search flex items-center gap-1">
3
+ <slot name="prefix" />
4
+ <div class="relative w-64">
5
+ <input
6
+ v-model="localValue"
7
+ type="text"
8
+ :placeholder="props.meta?.placeholder ?? ''"
9
+ class="w-full border rounded text-sm dark:bg-gray-800 border-gray-300 dark:border-gray-600 dark:text-white px-2 py-1 pr-6"
10
+ @keyup.enter="applyImmediate"
11
+ >
12
+ <p
13
+ v-if="localValue"
14
+ @click="clear"
15
+ class="absolute right-2 top-1/2 -translate-y-1/2 hover:cursor-pointer hover:text-gray-600 dark:text-white dark:hover:text-gray-400 "
16
+ >
17
+
18
+ </p>
19
+ </div>
20
+ <slot name="suffix" />
21
+ </div>
22
+ </template>
23
+ <script setup lang="ts">
24
+ import { ref, watch, onMounted } from 'vue';
25
+ import adminforth from '@/adminforth';
26
+ import { AdminForthFilterOperators } from '@/types/Common';
27
+ import { useRoute } from 'vue-router';
28
+
29
+ const route = useRoute();
30
+
31
+ const props = defineProps<{
32
+ meta?: {
33
+ placeholder?: string,
34
+ debounceMs?: number,
35
+ filterField: string,
36
+ };
37
+ }>();
38
+ const localValue = ref('');
39
+ let t: any = null;
40
+ let blockFilterUpdate = false;
41
+
42
+ onMounted(() => {
43
+ const filters = Object.keys(route.query).filter(k => k.startsWith('filter__')).map(k => {
44
+ const [_, field, operator] = k.split('__');
45
+ return {
46
+ field,
47
+ operator,
48
+ value: JSON.parse(decodeURIComponent(route.query[k] as string))
49
+ }
50
+ });
51
+ const isUniversalSearchFilterApplied = filters.find(f => f.field === `_universal_search_${props.meta?.filterField}`);
52
+ if (isUniversalSearchFilterApplied) {
53
+ localValue.value = isUniversalSearchFilterApplied.value;
54
+ blockFilterUpdate = true;
55
+ };
56
+ });
57
+
58
+ function send(term?: string) {
59
+ adminforth?.list?.updateFilter?.({
60
+ field: `_universal_search_${props.meta?.filterField}`,
61
+ operator: AdminForthFilterOperators.EQ,
62
+ value: term || '',
63
+ });
64
+ }
65
+
66
+ function apply() {
67
+ const term = localValue.value.trim();
68
+ send(term || '');
69
+ }
70
+
71
+ function applyImmediate() {
72
+ if (t) clearTimeout(t);
73
+ t = null;
74
+ apply();
75
+ }
76
+
77
+ watch(localValue, () => {
78
+ if (blockFilterUpdate) {
79
+ blockFilterUpdate = false;
80
+ return;
81
+ }
82
+ const delay = props.meta?.debounceMs ?? 500;
83
+ if (t) clearTimeout(t);
84
+ t = setTimeout(apply, delay);
85
+ });
86
+
87
+ function clear() {
88
+ blockFilterUpdate = true;
89
+ localValue.value = '';
90
+ applyImmediate();
91
+ }
92
+ </script>
@@ -0,0 +1,6 @@
1
+ export interface Filter {
2
+ name: string,
3
+ icon?: string,
4
+ enum?: { label: string, icon?: string }[],
5
+ hasSearchInput?: boolean
6
+ }
@@ -1,305 +1,38 @@
1
1
  <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
+ />
128
16
  </div>
129
17
  </div>
130
18
  </div>
19
+
131
20
  </template>
132
21
 
133
22
  <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';
155
26
 
156
27
  const props = defineProps<{
157
- meta: any,
28
+ meta: {
29
+ pluginInstanceId: string,
30
+ resourceId: string,
31
+ options: Filter[]
32
+ },
158
33
  resource: any,
159
34
  adminUser: any
160
35
  }>();
161
36
 
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
- }
304
37
 
305
38
  </script>
@@ -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,0 +1,92 @@
1
+ <template>
2
+ <div class="af-universal-search flex items-center gap-1">
3
+ <slot name="prefix" />
4
+ <div class="relative w-64">
5
+ <input
6
+ v-model="localValue"
7
+ type="text"
8
+ :placeholder="props.meta?.placeholder ?? ''"
9
+ class="w-full border rounded text-sm dark:bg-gray-800 border-gray-300 dark:border-gray-600 dark:text-white px-2 py-1 pr-6"
10
+ @keyup.enter="applyImmediate"
11
+ >
12
+ <p
13
+ v-if="localValue"
14
+ @click="clear"
15
+ class="absolute right-2 top-1/2 -translate-y-1/2 hover:cursor-pointer hover:text-gray-600 dark:text-white dark:hover:text-gray-400 "
16
+ >
17
+
18
+ </p>
19
+ </div>
20
+ <slot name="suffix" />
21
+ </div>
22
+ </template>
23
+ <script setup lang="ts">
24
+ import { ref, watch, onMounted } from 'vue';
25
+ import adminforth from '@/adminforth';
26
+ import { AdminForthFilterOperators } from '@/types/Common';
27
+ import { useRoute } from 'vue-router';
28
+
29
+ const route = useRoute();
30
+
31
+ const props = defineProps<{
32
+ meta?: {
33
+ placeholder?: string,
34
+ debounceMs?: number,
35
+ filterField: string,
36
+ };
37
+ }>();
38
+ const localValue = ref('');
39
+ let t: any = null;
40
+ let blockFilterUpdate = false;
41
+
42
+ onMounted(() => {
43
+ const filters = Object.keys(route.query).filter(k => k.startsWith('filter__')).map(k => {
44
+ const [_, field, operator] = k.split('__');
45
+ return {
46
+ field,
47
+ operator,
48
+ value: JSON.parse(decodeURIComponent(route.query[k] as string))
49
+ }
50
+ });
51
+ const isUniversalSearchFilterApplied = filters.find(f => f.field === `_universal_search_${props.meta?.filterField}`);
52
+ if (isUniversalSearchFilterApplied) {
53
+ localValue.value = isUniversalSearchFilterApplied.value;
54
+ blockFilterUpdate = true;
55
+ };
56
+ });
57
+
58
+ function send(term?: string) {
59
+ adminforth?.list?.updateFilter?.({
60
+ field: `_universal_search_${props.meta?.filterField}`,
61
+ operator: AdminForthFilterOperators.EQ,
62
+ value: term || '',
63
+ });
64
+ }
65
+
66
+ function apply() {
67
+ const term = localValue.value.trim();
68
+ send(term || '');
69
+ }
70
+
71
+ function applyImmediate() {
72
+ if (t) clearTimeout(t);
73
+ t = null;
74
+ apply();
75
+ }
76
+
77
+ watch(localValue, () => {
78
+ if (blockFilterUpdate) {
79
+ blockFilterUpdate = false;
80
+ return;
81
+ }
82
+ const delay = props.meta?.debounceMs ?? 500;
83
+ if (t) clearTimeout(t);
84
+ t = setTimeout(apply, delay);
85
+ });
86
+
87
+ function clear() {
88
+ blockFilterUpdate = true;
89
+ localValue.value = '';
90
+ applyImmediate();
91
+ }
92
+ </script>
@@ -0,0 +1,6 @@
1
+ export interface Filter {
2
+ name: string,
3
+ icon?: string,
4
+ enum?: { label: string, icon?: string }[],
5
+ hasSearchInput?: boolean
6
+ }
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  });
9
9
  };
10
10
  import { AdminForthPlugin } from "adminforth";
11
- import { suggestIfTypo } from "adminforth";
11
+ import { AdminForthFilterOperators } from "adminforth";
12
12
  export default class extends AdminForthPlugin {
13
13
  constructor(options) {
14
14
  super(options, import.meta.url);
@@ -20,6 +20,20 @@ export default class extends AdminForthPlugin {
20
20
  });
21
21
  return __awaiter(this, void 0, void 0, function* () {
22
22
  _super.modifyResourceConfig.call(this, adminforth, resourceConfig);
23
+ const frontendOptions = this.options.filters.map(f => {
24
+ if (!f.enum && !f.searchInput) {
25
+ throw new Error(`Filter ${f.name} should have either enum or searchInput property`);
26
+ }
27
+ if (f.enum && f.searchInput) {
28
+ throw new Error(`Filter ${f.name} should not have both enum and searchInput properties, choose one of them`);
29
+ }
30
+ return {
31
+ name: f.name,
32
+ icon: f.icon,
33
+ enum: f.enum ? f.enum.map(e => ({ label: e.label, icon: e.icon })) : undefined,
34
+ hasSearchInput: !!f.searchInput,
35
+ };
36
+ });
23
37
  // simply modify resourceConfig or adminforth.config. You can get access to plugin options via this.options;
24
38
  if (!resourceConfig.options.pageInjections) {
25
39
  resourceConfig.options.pageInjections = {};
@@ -27,20 +41,65 @@ export default class extends AdminForthPlugin {
27
41
  if (!resourceConfig.options.pageInjections.list) {
28
42
  resourceConfig.options.pageInjections.list = {};
29
43
  }
30
- if (!resourceConfig.options.pageInjections.list.afterBreadcrumbs) {
31
- resourceConfig.options.pageInjections.list.afterBreadcrumbs = [];
44
+ if (!resourceConfig.options.pageInjections.list.beforeActionButtons) {
45
+ resourceConfig.options.pageInjections.list.beforeActionButtons = [];
46
+ }
47
+ resourceConfig.options.pageInjections.list.beforeActionButtons.push({
48
+ file: this.componentPath('FiltersArea.vue'),
49
+ meta: {
50
+ pluginInstanceId: this.pluginInstanceId,
51
+ resourceId: this.resourceConfig.resourceId,
52
+ options: frontendOptions
53
+ }
54
+ });
55
+ const normalizeFilterValue = (filters) => {
56
+ var _a, _b;
57
+ const filtersToReturn = [];
58
+ for (const filter of filters) {
59
+ if (filter.field.startsWith('_universal_search_')) {
60
+ const searchTerm = filter.value;
61
+ if (!searchTerm)
62
+ continue;
63
+ const searchFieldName = filter.field.replace('_universal_search_', '');
64
+ const filterFromSearch = ((_b = (_a = this.options.filters.find(f => f.name === searchFieldName)) === null || _a === void 0 ? void 0 : _a.searchInput) === null || _b === void 0 ? void 0 : _b.call(_a, searchTerm)) || { field: searchFieldName, operator: AdminForthFilterOperators.EQ, value: searchTerm };
65
+ filtersToReturn.push(filterFromSearch);
66
+ }
67
+ else if (filter.field.startsWith('_qf_')) {
68
+ const quickFilter = this.options.filters.find(f => `_qf_${f.name}` === filter.field);
69
+ if (quickFilter === null || quickFilter === void 0 ? void 0 : quickFilter.enum) {
70
+ const enumOption = quickFilter.enum.find(e => e.label === filter.value);
71
+ if (enumOption) {
72
+ const filterFromEnum = enumOption.filters();
73
+ filtersToReturn.push(filterFromEnum);
74
+ }
75
+ }
76
+ }
77
+ else {
78
+ filtersToReturn.push(filter);
79
+ }
80
+ }
81
+ return filtersToReturn;
82
+ };
83
+ const transformer = (_a) => __awaiter(this, [_a], void 0, function* ({ query }) {
84
+ const normalizedFilters = normalizeFilterValue(query.filters);
85
+ query.filters = normalizedFilters;
86
+ console.log('Transformed filters', query.filters);
87
+ return { ok: true, error: '' };
88
+ });
89
+ const originalBefore = this.resourceConfig.hooks.list.beforeDatasourceRequest;
90
+ if (!originalBefore) {
91
+ this.resourceConfig.hooks.list.beforeDatasourceRequest = [transformer];
92
+ }
93
+ else if (Array.isArray(originalBefore)) {
94
+ originalBefore.push(transformer);
95
+ this.resourceConfig.hooks.list.beforeDatasourceRequest = originalBefore;
96
+ }
97
+ else {
98
+ this.resourceConfig.hooks.list.beforeDatasourceRequest = [originalBefore, transformer];
32
99
  }
33
- resourceConfig.options.pageInjections.list.afterBreadcrumbs.push({ file: this.componentPath('FiltersArea.vue'), meta: { pluginInstanceId: this.pluginInstanceId, resourceId: this.resourceConfig.resourceId, options: this.options } });
34
100
  });
35
101
  }
36
102
  validateConfigAfterDiscover(adminforth, resourceConfig) {
37
- for (const colOpt of this.options.columns) {
38
- const column = resourceConfig.columns.find(c => c.name === colOpt.column);
39
- if (!column) {
40
- const similar = suggestIfTypo(resourceConfig.columns.map((column) => column.name), colOpt.column);
41
- throw new Error(`QuickFilters plugin: column '${colOpt.column}' not found in resource '${resourceConfig.resourceId}'. Did you mean '${similar}'?`);
42
- }
43
- }
44
103
  }
45
104
  instanceUniqueRepresentation(pluginOptions) {
46
105
  // optional method to return unique string representation of plugin instance.
package/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { AdminForthPlugin } from "adminforth";
2
- import type { IAdminForth, IHttpServer, AdminForthResourcePages, AdminForthResourceColumn, AdminForthDataTypes, AdminForthResource, AdminForthComponentDeclaration } from "adminforth";
2
+ import type { IAdminForth, AdminForthResource, AdminForthComponentDeclaration } from "adminforth";
3
+ import { AdminForthFilterOperators } from "adminforth";
3
4
  import type { PluginOptions } from './types.js';
4
- import { suggestIfTypo } from "adminforth";
5
5
 
6
6
  export default class extends AdminForthPlugin {
7
7
  options: PluginOptions;
@@ -14,6 +14,20 @@ export default class extends AdminForthPlugin {
14
14
  async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
15
15
  super.modifyResourceConfig(adminforth, resourceConfig);
16
16
 
17
+ const frontendOptions = this.options.filters.map(f => {
18
+ if (!f.enum && !f.searchInput) {
19
+ throw new Error(`Filter ${f.name} should have either enum or searchInput property`);
20
+ }
21
+ if (f.enum && f.searchInput) {
22
+ throw new Error(`Filter ${f.name} should not have both enum and searchInput properties, choose one of them`);
23
+ }
24
+ return {
25
+ name: f.name,
26
+ icon: f.icon,
27
+ enum: f.enum ? f.enum.map(e => ({ label: e.label, icon: e.icon })) : undefined,
28
+ hasSearchInput: !!f.searchInput,
29
+ };
30
+ });
17
31
  // simply modify resourceConfig or adminforth.config. You can get access to plugin options via this.options;
18
32
  if ( !resourceConfig.options.pageInjections ) {
19
33
  resourceConfig.options.pageInjections = {};
@@ -21,22 +35,66 @@ export default class extends AdminForthPlugin {
21
35
  if ( !resourceConfig.options.pageInjections.list ) {
22
36
  resourceConfig.options.pageInjections.list = {};
23
37
  }
24
- if ( !resourceConfig.options.pageInjections.list.afterBreadcrumbs ) {
25
- resourceConfig.options.pageInjections.list.afterBreadcrumbs = [];
38
+ if ( !resourceConfig.options.pageInjections.list.beforeActionButtons ) {
39
+ resourceConfig.options.pageInjections.list.beforeActionButtons = [];
26
40
  }
27
- (resourceConfig.options.pageInjections.list.afterBreadcrumbs as AdminForthComponentDeclaration[]).push(
28
- { file: this.componentPath('FiltersArea.vue'), meta: { pluginInstanceId: this.pluginInstanceId, resourceId: this.resourceConfig.resourceId, options: this.options } }
41
+ (resourceConfig.options.pageInjections.list.beforeActionButtons as AdminForthComponentDeclaration[]).push(
42
+ {
43
+ file: this.componentPath('FiltersArea.vue'),
44
+ meta: {
45
+ pluginInstanceId: this.pluginInstanceId,
46
+ resourceId: this.resourceConfig.resourceId,
47
+ options: frontendOptions
48
+ }
49
+ }
29
50
  );
51
+
52
+ const normalizeFilterValue = (filters: any[]) => {
53
+ const filtersToReturn = [];
54
+ for (const filter of filters) {
55
+ if (filter.field.startsWith('_universal_search_')) {
56
+ const searchTerm = filter.value as string;
57
+ if (!searchTerm) continue;
58
+ const searchFieldName = filter.field.replace('_universal_search_', '');
59
+ const filterFromSearch = this.options.filters.find(f => f.name === searchFieldName)?.searchInput?.(searchTerm) || { field: searchFieldName, operator: AdminForthFilterOperators.EQ, value: searchTerm };
60
+ filtersToReturn.push(filterFromSearch);
61
+ } else if (filter.field.startsWith('_qf_')) {
62
+ const quickFilter = this.options.filters.find(f => `_qf_${f.name}` === filter.field);
63
+ if (quickFilter?.enum) {
64
+ const enumOption = quickFilter.enum.find(e => e.label === filter.value);
65
+ if (enumOption) {
66
+ const filterFromEnum = enumOption.filters();
67
+ filtersToReturn.push(filterFromEnum);
68
+ }
69
+ }
70
+ } else {
71
+ filtersToReturn.push(filter);
72
+ }
73
+ }
74
+ return filtersToReturn;
75
+ }
76
+
77
+ const transformer = async ({ query }: { query: any }) => {
78
+ const normalizedFilters = normalizeFilterValue(query.filters);
79
+ query.filters = normalizedFilters;
80
+ console.log('Transformed filters', query.filters);
81
+ return { ok: true, error: '' };
82
+ };
83
+
84
+ const originalBefore = this.resourceConfig.hooks.list.beforeDatasourceRequest;
85
+
86
+ if (!originalBefore) {
87
+ this.resourceConfig.hooks.list.beforeDatasourceRequest = [transformer];
88
+ } else if (Array.isArray(originalBefore)) {
89
+ originalBefore.push(transformer);
90
+ this.resourceConfig.hooks.list.beforeDatasourceRequest = originalBefore;
91
+ } else {
92
+ this.resourceConfig.hooks.list.beforeDatasourceRequest = [originalBefore, transformer];
93
+ }
30
94
  }
31
95
 
32
96
  validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
33
- for ( const colOpt of this.options.columns ) {
34
- const column = resourceConfig.columns.find(c => c.name === colOpt.column);
35
- if ( !column ) {
36
- const similar = suggestIfTypo(resourceConfig.columns.map((column: any) => column.name), colOpt.column);
37
- throw new Error(`QuickFilters plugin: column '${colOpt.column}' not found in resource '${resourceConfig.resourceId}'. Did you mean '${similar}'?`);
38
- }
39
- }
97
+
40
98
  }
41
99
 
42
100
  instanceUniqueRepresentation(pluginOptions: any) : string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/quick-filters",
3
- "version": "1.2.10",
3
+ "version": "2.0.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
package/types.ts CHANGED
@@ -1,5 +1,14 @@
1
+ import { Filters, type IAdminForthSingleFilter } from "adminforth"
2
+ interface IFilter {
3
+ name: string;
4
+ icon?: string;
5
+ enum?: {
6
+ label: string;
7
+ icon?: string;
8
+ filters: () => IAdminForthSingleFilter | Promise<IAdminForthSingleFilter>;
9
+ }[];
10
+ searchInput?: (searchVal: string) => IAdminForthSingleFilter | Promise<IAdminForthSingleFilter>;
11
+ }
1
12
  export interface PluginOptions {
2
- columns: {
3
- column: string;
4
- }[];
13
+ filters: IFilter[]
5
14
  }