@adminforth/quick-filters 1.2.9 → 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/.woodpecker/release.yml +4 -4
- package/build.log +5 -2
- package/custom/FiltersArea.vue +23 -290
- package/custom/QickFiltersSelect.vue +46 -0
- package/custom/UniversalSearchInput.vue +92 -0
- package/custom/types.ts +6 -0
- package/dist/custom/FiltersArea.vue +23 -290
- package/dist/custom/QickFiltersSelect.vue +46 -0
- package/dist/custom/UniversalSearchInput.vue +92 -0
- package/dist/custom/types.ts +6 -0
- package/dist/index.js +70 -11
- package/index.ts +71 -13
- package/package.json +3 -2
- package/types.ts +12 -3
package/.woodpecker/release.yml
CHANGED
|
@@ -17,18 +17,18 @@ steps:
|
|
|
17
17
|
- infisical export --domain https://vault.devforth.io/api --format=dotenv-export --env="prod" > /woodpecker/deploy.vault.env
|
|
18
18
|
|
|
19
19
|
build:
|
|
20
|
-
image:
|
|
20
|
+
image: devforth/node20-pnpm:latest
|
|
21
21
|
when:
|
|
22
22
|
- event: push
|
|
23
23
|
commands:
|
|
24
24
|
- apt update && apt install -y rsync
|
|
25
25
|
- . /woodpecker/deploy.vault.env
|
|
26
|
-
-
|
|
26
|
+
- pnpm install
|
|
27
27
|
- /bin/bash ./.woodpecker/buildRelease.sh
|
|
28
28
|
- npm audit signatures
|
|
29
29
|
|
|
30
30
|
release:
|
|
31
|
-
image:
|
|
31
|
+
image: devforth/node20-pnpm:latest
|
|
32
32
|
when:
|
|
33
33
|
- event:
|
|
34
34
|
- push
|
|
@@ -36,7 +36,7 @@ steps:
|
|
|
36
36
|
- main
|
|
37
37
|
commands:
|
|
38
38
|
- . /woodpecker/deploy.vault.env
|
|
39
|
-
-
|
|
39
|
+
- pnpm exec semantic-release
|
|
40
40
|
|
|
41
41
|
slack-on-failure:
|
|
42
42
|
image: curlimages/curl
|
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
|
|
11
|
-
total size is
|
|
13
|
+
sent 5,172 bytes received 115 bytes 10,574.00 bytes/sec
|
|
14
|
+
total size is 4,745 speedup is 0.90
|
package/custom/FiltersArea.vue
CHANGED
|
@@ -1,305 +1,38 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
3
|
-
<div
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
<div
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
135
|
-
import
|
|
136
|
-
import {
|
|
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:
|
|
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>
|
package/custom/types.ts
ADDED
|
@@ -1,305 +1,38 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div
|
|
3
|
-
<div
|
|
4
|
-
<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
<div
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
135
|
-
import
|
|
136
|
-
import {
|
|
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:
|
|
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>
|
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 {
|
|
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.
|
|
31
|
-
resourceConfig.options.pageInjections.list.
|
|
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,
|
|
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.
|
|
25
|
-
resourceConfig.options.pageInjections.list.
|
|
38
|
+
if ( !resourceConfig.options.pageInjections.list.beforeActionButtons ) {
|
|
39
|
+
resourceConfig.options.pageInjections.list.beforeActionButtons = [];
|
|
26
40
|
}
|
|
27
|
-
(resourceConfig.options.pageInjections.list.
|
|
28
|
-
{
|
|
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
|
-
|
|
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": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"types": "dist/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"typescript": "^5.7.3"
|
|
22
22
|
},
|
|
23
23
|
"peerDependencies": {
|
|
24
|
-
"adminforth": "
|
|
24
|
+
"adminforth": "^2.24.0"
|
|
25
25
|
},
|
|
26
26
|
"release": {
|
|
27
27
|
"plugins": [
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
[
|
|
33
33
|
"semantic-release-slack-bot",
|
|
34
34
|
{
|
|
35
|
+
"packageName": "@adminforth/quick-filters",
|
|
35
36
|
"notifyOnSuccess": true,
|
|
36
37
|
"notifyOnFail": true,
|
|
37
38
|
"slackIcon": ":package:",
|
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
|
-
|
|
3
|
-
column: string;
|
|
4
|
-
}[];
|
|
13
|
+
filters: IFilter[]
|
|
5
14
|
}
|