adminforth 2.27.0-next.3 → 2.27.0-next.30

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.
Files changed (57) hide show
  1. package/commands/callTsProxy.js +10 -5
  2. package/commands/proxy.ts +18 -10
  3. package/dist/commands/proxy.js +14 -10
  4. package/dist/commands/proxy.js.map +1 -1
  5. package/dist/modules/configValidator.d.ts.map +1 -1
  6. package/dist/modules/configValidator.js +16 -9
  7. package/dist/modules/configValidator.js.map +1 -1
  8. package/dist/modules/restApi.d.ts.map +1 -1
  9. package/dist/modules/restApi.js +77 -4
  10. package/dist/modules/restApi.js.map +1 -1
  11. package/dist/modules/styles.js +1 -1
  12. package/dist/servers/express.d.ts.map +1 -1
  13. package/dist/servers/express.js +4 -0
  14. package/dist/servers/express.js.map +1 -1
  15. package/dist/spa/package-lock.json +41 -0
  16. package/dist/spa/package.json +3 -0
  17. package/dist/spa/pnpm-lock.yaml +38 -0
  18. package/dist/spa/src/App.vue +77 -76
  19. package/dist/spa/src/afcl/Button.vue +2 -3
  20. package/dist/spa/src/afcl/Dialog.vue +1 -1
  21. package/dist/spa/src/afcl/Input.vue +1 -1
  22. package/dist/spa/src/afcl/Select.vue +8 -2
  23. package/dist/spa/src/afcl/Spinner.vue +1 -1
  24. package/dist/spa/src/components/CallActionWrapper.vue +1 -1
  25. package/dist/spa/src/components/ColumnValueInput.vue +16 -3
  26. package/dist/spa/src/components/ColumnValueInputWrapper.vue +25 -2
  27. package/dist/spa/src/components/CustomRangePicker.vue +16 -14
  28. package/dist/spa/src/components/Filters.vue +95 -63
  29. package/dist/spa/src/components/GroupsTable.vue +9 -6
  30. package/dist/spa/src/components/MenuLink.vue +2 -2
  31. package/dist/spa/src/components/ResourceForm.vue +100 -6
  32. package/dist/spa/src/components/ResourceListTable.vue +13 -7
  33. package/dist/spa/src/components/ShowTable.vue +1 -1
  34. package/dist/spa/src/components/Sidebar.vue +2 -2
  35. package/dist/spa/src/components/ThreeDotsMenu.vue +19 -9
  36. package/dist/spa/src/spa_types/core.ts +32 -0
  37. package/dist/spa/src/stores/filters.ts +16 -12
  38. package/dist/spa/src/types/Back.ts +20 -4
  39. package/dist/spa/src/types/Common.ts +24 -5
  40. package/dist/spa/src/types/adapters/StorageAdapter.ts +12 -0
  41. package/dist/spa/src/utils/createEditUtils.ts +65 -0
  42. package/dist/spa/src/utils/index.ts +2 -1
  43. package/dist/spa/src/utils/utils.ts +44 -9
  44. package/dist/spa/src/utils.ts +2 -1
  45. package/dist/spa/src/views/CreateView.vue +22 -49
  46. package/dist/spa/src/views/EditView.vue +20 -38
  47. package/dist/spa/src/views/ListView.vue +22 -32
  48. package/dist/spa/src/views/ShowView.vue +67 -12
  49. package/dist/types/Back.d.ts +20 -15
  50. package/dist/types/Back.d.ts.map +1 -1
  51. package/dist/types/Back.js.map +1 -1
  52. package/dist/types/Common.d.ts +31 -5
  53. package/dist/types/Common.d.ts.map +1 -1
  54. package/dist/types/Common.js.map +1 -1
  55. package/dist/types/adapters/StorageAdapter.d.ts +11 -0
  56. package/dist/types/adapters/StorageAdapter.d.ts.map +1 -1
  57. package/package.json +5 -3
@@ -19,7 +19,7 @@
19
19
 
20
20
  <div class="py-4 ">
21
21
  <ul class="space-y-3 font-medium text-sm">
22
- <li v-for="c in columnsWithFilter" :key="c">
22
+ <li v-for="c in columnsWithFilter" :key="c.name">
23
23
  <div class="flex flex-col">
24
24
  <div class="flex justify-between items-center">
25
25
  <p class="dark:text-gray-400 my-1">{{ c.label }}</p>
@@ -38,15 +38,15 @@
38
38
  </div>
39
39
  <component
40
40
  v-if="c.components?.filter"
41
- :is="getCustomComponent(c.components.filter)"
42
- :meta="c?.components?.list?.meta"
41
+ :is="getCustomComponent(formatComponent(c.components.filter))"
42
+ :meta="formatComponent(c.components.filter)?.meta"
43
43
  :column="c"
44
44
  class="w-full"
45
- @update:modelValue="(filtersArray) => {
45
+ @update:modelValue="(filtersArray:FilterParams[] ) => {
46
46
  filtersStore.filters = filtersStore.filters.filter(f => f.field !== c.name);
47
47
 
48
48
  for (const f of filtersArray) {
49
- filtersStore.filters.push({ field: c.name, ...f });
49
+ filtersStore.filters.push({ ...f, field: c.name });
50
50
  }
51
51
  console.log('filtersStore.filters', filtersStore.filters);
52
52
  emits('update:filters', [...filtersStore.filters]);
@@ -55,18 +55,18 @@
55
55
  />
56
56
  <Select
57
57
  v-else-if="c.foreignResource"
58
- :multiple="c.filterOptions.multiselect"
58
+ :multiple="c?.filterOptions?.multiselect"
59
59
  class="w-full"
60
60
  :options="columnOptions[c.name] || []"
61
61
  :searchDisabled="!c.foreignResource.searchableFields"
62
62
  @scroll-near-end="loadMoreOptions(c.name)"
63
63
  @search="(searchTerm) => {
64
- if (c.foreignResource.searchableFields && onSearchInput[c.name]) {
64
+ if (c.foreignResource?.searchableFields && onSearchInput[c.name]) {
65
65
  onSearchInput[c.name](searchTerm);
66
66
  }
67
67
  }"
68
- @update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions.multiselect ? 'in' : 'eq', value: c.filterOptions.multiselect ? ($event.length ? $event : undefined) : $event || undefined })"
69
- :modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value || (c.filterOptions.multiselect ? [] : '')"
68
+ @update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions?.multiselect ? AFFO.IN : AFFO.EQ, value: c.filterOptions?.multiselect ? ($event.length ? $event : undefined) : $event || undefined })"
69
+ :modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions?.multiselect ? AFFO.IN : AFFO.EQ))?.value || (c.filterOptions?.multiselect ? [] : '')"
70
70
  >
71
71
  <template #extra-item v-if="columnLoadingState[c.name]?.loading">
72
72
  <div class="text-center text-gray-400 dark:text-gray-300 py-2 flex items-center justify-center gap-2">
@@ -76,7 +76,7 @@
76
76
  </template>
77
77
  </Select>
78
78
  <Select
79
- :multiple="c.filterOptions.multiselect"
79
+ :multiple="c.filterOptions?.multiselect"
80
80
  class="w-full"
81
81
  v-else-if="c.type === 'boolean'"
82
82
  :options="[
@@ -85,63 +85,63 @@
85
85
  // if field is not required, undefined might be there, and user might want to filter by it
86
86
  ...(c.required ? [] : [ { label: $t('Unset'), value: undefined } ])
87
87
  ]"
88
- @update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions.multiselect ? 'in' : 'eq', value: c.filterOptions.multiselect ? ($event.length ? $event : undefined) : $event })"
89
- :modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value !== undefined
90
- ? filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value
91
- : (c.filterOptions.multiselect ? [] : '')"
88
+ @update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions?.multiselect ? AFFO.IN : AFFO.EQ, value: c.filterOptions?.multiselect ? ($event.length ? $event : undefined) : $event })"
89
+ :modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions?.multiselect ? AFFO.IN : AFFO.EQ))?.value !== undefined
90
+ ? filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions?.multiselect ? AFFO.IN : AFFO.EQ))?.value
91
+ : (c.filterOptions?.multiselect ? [] : '')"
92
92
  />
93
93
 
94
94
  <Select
95
- :multiple="c.filterOptions.multiselect"
95
+ :multiple="c.filterOptions?.multiselect"
96
96
  class="w-full"
97
97
  v-else-if="c.enum"
98
98
  :options="c.enum"
99
- @update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions.multiselect ? 'in' : 'eq', value: c.filterOptions.multiselect ? ($event.length ? $event : undefined) : $event || undefined })"
100
- :modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions.multiselect ? 'in' : 'eq'))?.value || (c.filterOptions.multiselect ? [] : '')"
99
+ @update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions?.multiselect ? AFFO.IN : AFFO.EQ, value: c.filterOptions?.multiselect ? ($event.length ? $event : undefined) : $event || undefined })"
100
+ :modelValue="filtersStore.filters.find(f => f.field === c.name && f.operator === (c.filterOptions?.multiselect ? AFFO.IN : AFFO.EQ))?.value || (c.filterOptions?.multiselect ? [] : '')"
101
101
  />
102
102
 
103
103
  <Input
104
- v-else-if="['string', 'text', 'json', 'richtext', 'unknown'].includes(c.type)"
104
+ v-else-if="['string', 'text', 'json', 'richtext', 'unknown'].includes(c.type ?? '')"
105
105
  type="text"
106
106
  full-width
107
107
  :placeholder="$t('Search')"
108
- @update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions?.substringSearch ? 'ilike' : 'eq', value: $event || undefined })"
109
- :modelValue="getFilterItem({ column: c, operator: c.filterOptions?.substringSearch ? 'ilike' : 'eq' })"
108
+ @update:modelValue="onFilterInput[c.name]({ column: c, operator: c.filterOptions?.substringSearch ? AFFO.ILIKE : AFFO.EQ, value: $event || undefined })"
109
+ :modelValue="(getFilterItem({ column: c, operator: c.filterOptions?.substringSearch ? AFFO.ILIKE : AFFO.EQ }) as string | number)"
110
110
  />
111
111
 
112
112
  <CustomDateRangePicker
113
- v-else-if="['datetime', 'date', 'time'].includes(c.type)"
113
+ v-else-if="['datetime', 'date', 'time'].includes(c.type ?? '')"
114
114
  :column="c"
115
- :valueStart="filtersStore.filters.find(f => f.field === c.name && f.operator === 'gte')?.value || undefined"
116
- @update:valueStart="onFilterInput[c.name]({ column: c, operator: 'gte', value: $event || undefined })"
117
- :valueEnd="filtersStore.filters.find(f => f.field === c.name && f.operator === 'lte')?.value || undefined"
118
- @update:valueEnd="onFilterInput[c.name]({ column: c, operator: 'lte', value: $event || undefined })"
115
+ :valueStart="filtersStore.filters.find(f => f.field === c.name && f.operator === AFFO.GTE)?.value || undefined"
116
+ @update:valueStart="onFilterInput[c.name]({ column: c, operator: AFFO.GTE, value: $event || undefined })"
117
+ :valueEnd="filtersStore.filters.find(f => f.field === c.name && f.operator === AFFO.LTE)?.value || undefined"
118
+ @update:valueEnd="onFilterInput[c.name]({ column: c, operator: AFFO.LTE, value: $event || undefined })"
119
119
  />
120
120
 
121
121
  <CustomRangePicker
122
- v-else-if="['integer', 'decimal', 'float'].includes(c.type) && c.allowMinMaxQuery"
122
+ v-else-if="['integer', 'decimal', 'float'].includes(c.type ?? '') && c.allowMinMaxQuery"
123
123
  :min="getFilterMinValue(c.name)"
124
124
  :max="getFilterMaxValue(c.name)"
125
- :valueStart="getFilterItem({ column: c, operator: 'gte' })"
126
- @update:valueStart="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? (c.type === 'decimal' ? String($event) : $event) : undefined })"
127
- :valueEnd="getFilterItem({ column: c, operator: 'lte' })"
128
- @update:valueEnd="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? (c.type === 'decimal' ? String($event) : $event) : undefined })"
125
+ :valueStart="(getFilterItem({ column: c, operator: AFFO.GTE }) as number)"
126
+ @update:valueStart="(val) => rangeChangeHandler((val !== '' && val !== null) ? (c.type === 'decimal' ? String(val) : val) : undefined, c, AFFO.GTE)"
127
+ :valueEnd="(getFilterItem({ column: c, operator: AFFO.LTE }) as number)"
128
+ @update:valueEnd="(val) => rangeChangeHandler((val !== '' && val !== null) ? (c.type === 'decimal' ? String(val) : val) : undefined, c, AFFO.LTE)"
129
129
  />
130
130
 
131
- <div v-else-if="['integer', 'decimal', 'float'].includes(c.type)" class="flex gap-2">
131
+ <div v-else-if="['integer', 'decimal', 'float'].includes(c.type ?? '')" class="flex gap-2">
132
132
  <Input
133
133
  type="number"
134
134
  aria-describedby="helper-text-explanation"
135
135
  :placeholder="$t('From')"
136
- @update:modelValue="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? (c.type === 'decimal' ? String($event) : $event) : undefined })"
137
- :modelValue="getFilterItem({ column: c, operator: 'gte' })"
136
+ @update:modelValue="onFilterInput[c.name]({ column: c, operator: AFFO.GTE, value: ($event !== '' && $event !== null) ? (c.type === 'decimal' ? String($event) : $event) : undefined })"
137
+ :modelValue="(getFilterItem({ column: c, operator: AFFO.GTE }) as number)"
138
138
  />
139
139
  <Input
140
140
  type="number"
141
141
  aria-describedby="helper-text-explanation"
142
142
  :placeholder="$t('To')"
143
- @update:modelValue="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? (c.type === 'decimal' ? String($event) : $event) : undefined })"
144
- :modelValue="getFilterItem({ column: c, operator: 'lte' })"
143
+ @update:modelValue="onFilterInput[c.name]({ column: c, operator: AFFO.LTE, value: ($event !== '' && $event !== null) ? (c.type === 'decimal' ? String($event) : $event) : undefined })"
144
+ :modelValue="(getFilterItem({ column: c, operator: AFFO.LTE }) as number)"
145
145
  />
146
146
  </div>
147
147
  </div>
@@ -164,14 +164,13 @@
164
164
  </div>
165
165
  </template>
166
166
 
167
- <script setup>
167
+ <script setup lang="ts">
168
168
  import { watch, computed, ref, reactive } from 'vue';
169
169
  import { useI18n } from 'vue-i18n';
170
170
  import CustomDateRangePicker from '@/components/CustomDateRangePicker.vue';
171
- import { callAdminForthApi, loadMoreForeignOptions, searchForeignOptions, createSearchInputHandlers } from '@/utils';
171
+ import { loadMoreForeignOptions, searchForeignOptions, createSearchInputHandlers, formatComponent } from '@/utils';
172
172
  import { useRouter } from 'vue-router';
173
173
  import CustomRangePicker from "@/components/CustomRangePicker.vue";
174
- import { useFiltersStore } from '@/stores/filters';
175
174
  import { getCustomComponent } from '@/utils';
176
175
  import Input from '@/afcl/Input.vue';
177
176
  import Select from '@/afcl/Select.vue';
@@ -179,27 +178,39 @@ import Spinner from '@/afcl/Spinner.vue';
179
178
  import debounce from 'debounce';
180
179
  import { Tooltip } from '@/afcl';
181
180
  import { IconCloseOutline } from '@iconify-prerendered/vue-flowbite';
181
+ import type { AdminforthFilterStore, AdminforthFilterStoreUnwrapped } from '@/spa_types/core';
182
+ import type { AdminForthResourceColumnCommon, FilterParams, ColumnMinMaxValue } from '@/types/Common';
183
+ import { AdminForthFilterOperators } from '@/types/Common';
184
+
182
185
 
183
- const filtersStore = useFiltersStore();
184
186
  const { t } = useI18n();
185
187
 
188
+ const AFFO = AdminForthFilterOperators;
186
189
 
187
190
  // props: columns
188
191
  // add support for v-model:filers
189
- const props = defineProps(['columns', 'filters', 'show', 'columnsMinMax']);
192
+ // const props = defineProps(['columns', 'filters', 'show', 'columnsMinMax']);
193
+ const props = defineProps<{
194
+ columns: AdminForthResourceColumnCommon[],
195
+ filters?: AdminforthFilterStore['filters'],
196
+ show: Boolean,
197
+ columnsMinMax: ColumnMinMaxValue,
198
+ filtersStore: AdminforthFilterStoreUnwrapped
199
+ }>();
200
+
190
201
  const emits = defineEmits(['update:filters', 'hide']);
191
202
 
192
203
  const router = useRouter();
193
204
 
194
205
 
195
206
  const columnsWithFilter = computed(
196
- () => props.columns?.filter(column => column.showIn.filter) || []
207
+ () => props.columns?.filter(column => column.showIn?.filter) || []
197
208
  );
198
209
 
199
- const columnOptions = ref({});
200
- const columnLoadingState = reactive({});
201
- const columnOffsets = reactive({});
202
- const columnEmptyResultsCount = reactive({});
210
+ const columnOptions = ref<{[key: string]: Record<string, any>[]}>({});
211
+ const columnLoadingState = reactive<Record<string, { loading: boolean; hasMore: boolean }>>({});
212
+ const columnOffsets = reactive<Record<string, number>>({});
213
+ const columnEmptyResultsCount = reactive<Record<string, number>>({});
203
214
 
204
215
  watch(() => props.columns, async (newColumns) => {
205
216
  if (!newColumns) return;
@@ -219,12 +230,12 @@ watch(() => props.columns, async (newColumns) => {
219
230
  }, { immediate: true });
220
231
 
221
232
  // Function to load more options for a specific column
222
- async function loadMoreOptions(columnName, searchTerm = '') {
233
+ async function loadMoreOptions(columnName: string, searchTerm = '') {
223
234
  return loadMoreForeignOptions({
224
235
  columnName,
225
236
  searchTerm,
226
237
  columns: props.columns,
227
- resourceId: router.currentRoute.value.params.resourceId,
238
+ resourceId: router.currentRoute.value.params.resourceId as string,
228
239
  columnOptions,
229
240
  columnLoadingState,
230
241
  columnOffsets,
@@ -232,12 +243,12 @@ async function loadMoreOptions(columnName, searchTerm = '') {
232
243
  });
233
244
  }
234
245
 
235
- async function searchOptions(columnName, searchTerm) {
246
+ async function searchOptions(columnName: string, searchTerm: string) {
236
247
  return searchForeignOptions({
237
248
  columnName,
238
249
  searchTerm,
239
250
  columns: props.columns,
240
- resourceId: router.currentRoute.value.params.resourceId,
251
+ resourceId: router.currentRoute.value.params.resourceId as string,
241
252
  columnOptions,
242
253
  columnLoadingState,
243
254
  columnOffsets,
@@ -263,9 +274,9 @@ watch(() => props.show, (show) => {
263
274
  // }
264
275
 
265
276
  const onFilterInput = computed(() => {
266
- if (!props.columns) return {};
277
+ if (!props.columns) return {} as Record<string, any>;
267
278
 
268
- return props.columns.reduce((acc, c) => {
279
+ return props.columns.reduce<Record<string, ReturnType<typeof debounce>>>((acc, c) => {
269
280
  return {
270
281
  ...acc,
271
282
  [c.name]: debounce(({ column, operator, value }) => {
@@ -275,6 +286,27 @@ const onFilterInput = computed(() => {
275
286
  }, {});
276
287
  });
277
288
 
289
+ // rangeState is used for cutom range picker, because if we change two values very quickly
290
+ // in filters writes only the last one, because of debounce
291
+ const rangeState = reactive<Record<string, { gte: number | null; lte: number | null }>>({});
292
+
293
+ const updateRange = (column: AdminForthResourceColumnCommon) => {
294
+ debounce(() => {
295
+ const { gte, lte } = rangeState[column.name];
296
+
297
+ setFilterItem({ column, operator: AFFO.GTE, value: gte });
298
+ setFilterItem({ column, operator: AFFO.LTE, value: lte });
299
+ }, column?.filterOptions?.debounceTimeMs || 10)();
300
+ }
301
+
302
+ function rangeChangeHandler(value: number | null, column: AdminForthResourceColumnCommon, operator: 'gte' | 'lte') {
303
+ if (!rangeState[column.name]) {
304
+ rangeState[column.name] = { gte: null, lte: null };
305
+ }
306
+ rangeState[column.name][operator] = value;
307
+ updateRange(column);
308
+ }
309
+
278
310
  const onSearchInput = computed(() => {
279
311
  return createSearchInputHandlers(
280
312
  props.columns,
@@ -283,40 +315,40 @@ const onSearchInput = computed(() => {
283
315
  );
284
316
  });
285
317
 
286
- function setFilterItem({ column, operator, value }) {
318
+ function setFilterItem({ column, operator, value }: { column: AdminForthResourceColumnCommon; operator: AdminForthFilterOperators; value: any }) {
287
319
 
288
- const index = filtersStore.filters.findIndex(f => f.field === column.name && f.operator === operator);
320
+ const index = props.filtersStore.filters.findIndex(f => f.field === column.name && f.operator === operator);
289
321
  if (value === undefined || value === '' || value === null) {
290
322
  if (index !== -1) {
291
- filtersStore.filters.splice(index, 1);
323
+ props.filtersStore.filters.splice(index, 1);
292
324
  }
293
325
  } else {
294
326
  if (index === -1) {
295
- filtersStore.setFilter({ field: column.name, value, operator });
327
+ props.filtersStore.setFilter({ field: column.name, value, operator });
296
328
  } else {
297
- filtersStore.setFilters([...filtersStore.filters.slice(0, index), { field: column.name, value, operator }, ...filtersStore.filters.slice(index + 1)])
329
+ props.filtersStore.setFilters([...props.filtersStore.filters.slice(0, index), { field: column.name, value, operator }, ...props.filtersStore.filters.slice(index + 1)])
298
330
  }
299
331
  }
300
- emits('update:filters', [...filtersStore.filters]);
332
+ emits('update:filters', [...props.filtersStore.filters]);
301
333
  }
302
334
 
303
- function getFilterItem({ column, operator }) {
304
- const filterValue = filtersStore.filters.find(f => f.field === column.name && f.operator === operator)?.value;
335
+ function getFilterItem({ column, operator }: { column: AdminForthResourceColumnCommon; operator: AdminForthFilterOperators }) {
336
+ const filterValue = props.filtersStore.filters.find(f => f.field === column.name && f.operator === operator)?.value;
305
337
  return filterValue !== undefined ? filterValue : '';
306
338
  }
307
339
 
308
340
  async function clear() {
309
- filtersStore.filters = [...filtersStore.filters.filter(f => filtersStore.shouldFilterBeHidden(f.field))];
310
- emits('update:filters', [...filtersStore.filters]);
341
+ props.filtersStore.filters = [...props.filtersStore.filters.filter(f => props.filtersStore.shouldFilterBeHidden(f.field))];
342
+ emits('update:filters', [...props.filtersStore.filters]);
311
343
  }
312
344
 
313
- function getFilterMinValue(columnName) {
345
+ function getFilterMinValue(columnName: string) {
314
346
  if(props.columnsMinMax && props.columnsMinMax[columnName]) {
315
347
  return props.columnsMinMax[columnName]?.min
316
348
  }
317
349
  }
318
350
 
319
- function getFilterMaxValue(columnName) {
351
+ function getFilterMaxValue(columnName: string) {
320
352
  if(props.columnsMinMax && props.columnsMinMax[columnName]) {
321
353
  return props.columnsMinMax[columnName]?.max
322
354
  }
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="rounded-lg shadow-resourseFormShadow dark:shadow-darkResourseFormShadow dark:shadow-2xl">
2
+ <div class="rounded-lg shadow-resourseFormShadow dark:shadow-darkResourseFormShadow dark:shadow-2xl border dark:border-darkFormBorder">
3
3
  <div v-if="group.groupName && !group.noTitle" class="text-md font-semibold px-6 py-3 flex flex-1 items-center dark:border-darkFormBorder text-lightListTableHeadingText bg-lightFormHeading dark:bg-darkFormHeading dark:text-darkListTableHeadingText rounded-t-lg">
4
4
  {{ group.groupName }}
5
5
  </div>
@@ -30,7 +30,7 @@
30
30
  <Tooltip v-if="column.required[mode]">
31
31
 
32
32
  <IconExclamationCircleSolid v-if="column.required[mode]" class="w-4 h-4"
33
- :class="(columnError(column) && validating) ? 'text-lightInputErrorColor dark:text-darkInputErrorColor' : 'text-lightRequiredIconColor dark:text-darkRequiredIconColor'"
33
+ :class="(columnsWithErrors[column.name] && validatingMode && !isValidating) ? 'text-lightInputErrorColor dark:text-darkInputErrorColor' : 'text-lightRequiredIconColor dark:text-darkRequiredIconColor'"
34
34
  />
35
35
 
36
36
  <template #tooltip>
@@ -56,9 +56,10 @@
56
56
  @update:inValidity="customComponentsInValidity[$event.name] = $event.value"
57
57
  @update:emptiness="customComponentsEmptiness[$event.name] = $event.value"
58
58
  :readonly="readonlyColumns?.includes(column.name)"
59
+ :columnsWithErrors="columnsWithErrors"
60
+ :isValidating="isValidating"
61
+ :validatingMode="validatingMode"
59
62
  />
60
- <div v-if="columnError(column) && validating" class="af-invalid-field-message mt-1 text-xs text-lightInputErrorColor dark:text-darkInputErrorColor">{{ columnError(column) }}</div>
61
- <div v-if="column.editingNote && column.editingNote[mode]" class="mt-1 text-xs text-lightFormFieldTextColor dark:text-darkFormFieldTextColor">{{ column.editingNote[mode] }}</div>
62
63
  </td>
63
64
  </tr>
64
65
  </tbody>
@@ -79,14 +80,16 @@
79
80
  const props = defineProps<{
80
81
  source: 'create' | 'edit',
81
82
  group: any,
82
- mode: string,
83
- validating: boolean,
83
+ mode: 'create' | 'edit',
84
+ validatingMode: boolean,
84
85
  currentValues: any,
85
86
  unmasked: any,
86
87
  columnError: (column: any) => string,
87
88
  setCurrentValue: (columnName: string, value: any) => void,
88
89
  columnOptions: any,
89
90
  readonlyColumns?: string[],
91
+ columnsWithErrors: Record<string, string>,
92
+ isValidating: boolean
90
93
  }>();
91
94
 
92
95
  const customComponentsInValidity: Ref<Record<string, boolean>> = ref({});
@@ -35,7 +35,7 @@
35
35
  >
36
36
  {{ item.label }}
37
37
  </div>
38
- <span class="absolute flex items-center justify-center right-1 top-1/2 -translate-y-1/2" v-if="item.badge && showExpandedBadge">
38
+ <span class="absolute flex items-center justify-center right-1 top-1/2 -translate-y-1/2" v-if="(item.badge || item.badge === 0) && showExpandedBadge ">
39
39
  <Tooltip v-if="item.badgeTooltip">
40
40
  <div class="af-badge inline-flex items-center justify-center h-3 py-2.5 px-1 ms-3 text-xs font-medium rounded-full bg-lightAnnouncementBG dark:bg-darkAnnouncementBG
41
41
  fill-lightAnnouncementText dark:fill-darkAccent text-lightAnnouncementText dark:text-darkAccent min-w-[1.5rem] max-w-[3rem]">{{ item.badge }}</div>
@@ -48,7 +48,7 @@
48
48
  fill-lightAnnouncementText dark:fill-darkAccent text-lightAnnouncementText dark:text-darkAccent min-w-[1.5rem] max-w-[3rem]">{{ item.badge }}</div>
49
49
  </template>
50
50
  </span>
51
- <div v-if="item.badge && isSidebarIconOnly && !isSidebarHovering" class="af-badge absolute right-0.5 bottom-1 -translate-y-1/2 inline-flex items-center justify-center h-2 w-2 text-sm font-medium rounded-full bg-lightAnnouncementBG dark:bg-darkAnnouncementBG
51
+ <div v-if="(item.badge || item.badge === 0) && isSidebarIconOnly && !isSidebarHovering" class="af-badge absolute right-0.5 bottom-1 -translate-y-1/2 inline-flex items-center justify-center h-2 w-2 text-sm font-medium rounded-full bg-lightAnnouncementBG dark:bg-darkAnnouncementBG
52
52
  fill-lightAnnouncementText dark:fill-darkAccent text-lightAnnouncementText dark:text-darkAccent">
53
53
  </div>
54
54
  </RouterLink>
@@ -13,11 +13,13 @@
13
13
  :mode="mode"
14
14
  :unmasked="unmasked"
15
15
  :columnOptions="columnOptions"
16
- :validating="validating"
16
+ :validatingMode="validatingMode"
17
17
  :columnError="columnError"
18
18
  :setCurrentValue="setCurrentValue"
19
19
  @update:customComponentsInValidity="(data) => customComponentsInValidity = { ...customComponentsInValidity, ...data }"
20
20
  @update:customComponentsEmptiness="(data) => customComponentsEmptiness = { ...customComponentsEmptiness, ...data }"
21
+ :columnsWithErrors="columnsWithErrors"
22
+ :isValidating="isValidating"
21
23
  />
22
24
  </div>
23
25
  <div v-else class="flex flex-col gap-4">
@@ -31,11 +33,13 @@
31
33
  :mode="mode"
32
34
  :unmasked="unmasked"
33
35
  :columnOptions="columnOptions"
34
- :validating="validating"
36
+ :validatingMode="validatingMode"
35
37
  :columnError="columnError"
36
38
  :setCurrentValue="setCurrentValue"
37
39
  @update:customComponentsInValidity="(data) => customComponentsInValidity = { ...customComponentsInValidity, ...data }"
38
40
  @update:customComponentsEmptiness="(data) => customComponentsEmptiness = { ...customComponentsEmptiness, ...data }"
41
+ :columnsWithErrors="columnsWithErrors"
42
+ :isValidating="isValidating"
39
43
  />
40
44
  </template>
41
45
  <div v-if="otherColumns?.length || 0 > 0">
@@ -48,11 +52,13 @@
48
52
  :mode="mode"
49
53
  :unmasked="unmasked"
50
54
  :columnOptions="columnOptions"
51
- :validating="validating"
55
+ :validatingMode="validatingMode"
52
56
  :columnError="columnError"
53
57
  :setCurrentValue="setCurrentValue"
54
58
  @update:customComponentsInValidity="(data) => customComponentsInValidity = { ...customComponentsInValidity, ...data }"
55
59
  @update:customComponentsEmptiness="(data) => customComponentsEmptiness = { ...customComponentsEmptiness, ...data }"
60
+ :columnsWithErrors="columnsWithErrors"
61
+ :isValidating="isValidating"
56
62
  />
57
63
  </div>
58
64
  </div>
@@ -71,16 +77,20 @@ import { useCoreStore } from "@/stores/core";
71
77
  import GroupsTable from '@/components/GroupsTable.vue';
72
78
  import { useI18n } from 'vue-i18n';
73
79
  import { type AdminForthResourceColumnCommon, type AdminForthResourceCommon } from '@/types/Common';
80
+ import { Mutex } from 'async-mutex';
81
+ import debounce from 'lodash.debounce';
74
82
 
75
83
  const { t } = useI18n();
76
84
 
85
+ const mutex = new Mutex();
86
+
77
87
  const coreStore = useCoreStore();
78
88
  const router = useRouter();
79
89
  const route = useRoute();
80
90
  const props = defineProps<{
81
91
  resource: AdminForthResourceCommon,
82
92
  record: any,
83
- validating: boolean,
93
+ validatingMode: boolean,
84
94
  source: 'create' | 'edit',
85
95
  readonlyColumns?: string[],
86
96
  }>();
@@ -99,6 +109,11 @@ const columnOptions = ref<Record<string, any[]>>({});
99
109
  const columnLoadingState = reactive<Record<string, { loading: boolean; hasMore: boolean }>>({});
100
110
  const columnOffsets = reactive<Record<string, number>>({});
101
111
  const columnEmptyResultsCount = reactive<Record<string, number>>({});
112
+ const columnsWithErrors = ref<Record<string, string>>({});
113
+ const isValidating = ref(false);
114
+ const blockSettingIsValidating = ref(false);
115
+ const isValid = ref(true);
116
+ const doesUserHaveCustomValidation = computed(() => props.resource.columns.some(column => column.validation && column.validation.some((val) => val.validator)));
102
117
 
103
118
  const columnError = (column: AdminForthResourceColumnCommon) => {
104
119
  const val = computed(() => {
@@ -329,10 +344,48 @@ const editableColumns = computed(() => {
329
344
  return props.resource?.columns?.filter(column => column.showIn?.[mode.value] && (currentValues.value ? checkShowIf(column, currentValues.value, props.resource.columns) : true));
330
345
  });
331
346
 
332
- const isValid = computed(() => {
333
- return editableColumns.value?.every(column => !columnError(column));
347
+ function checkIfColumnHasError(column: AdminForthResourceColumnCommon) {
348
+ const error = columnError(column);
349
+ if (error) {
350
+ columnsWithErrors.value[column.name] = error;
351
+ } else {
352
+ delete columnsWithErrors.value[column.name];
353
+ }
354
+ }
355
+
356
+ const checkIfAnyColumnHasErrors = () => {
357
+ return Object.keys(columnsWithErrors.value).length > 0 ? false : true;
358
+ }
359
+
360
+ const debouncedValidation = debounce(async (columns: AdminForthResourceColumnCommon[]) => {
361
+ await mutex.runExclusive(async () => {
362
+ await validateUsingUserValidationFunction(columns);
363
+ });
364
+ setIsValidatingValue(false);
365
+ isValid.value = checkIfAnyColumnHasErrors();
366
+ }, 500);
367
+
368
+ watch(() => [editableColumns.value, props.validatingMode], async () => {
369
+ setIsValidatingValue(true);
370
+
371
+ editableColumns.value?.forEach(column => {
372
+ checkIfColumnHasError(column);
373
+ });
374
+
375
+ if (props.validatingMode && doesUserHaveCustomValidation.value) {
376
+ debouncedValidation(editableColumns.value);
377
+ } else {
378
+ setIsValidatingValue(false);
379
+ isValid.value = checkIfAnyColumnHasErrors();
380
+ }
334
381
  });
335
382
 
383
+ const setIsValidatingValue = (value: boolean) => {
384
+ if (!blockSettingIsValidating.value) {
385
+ isValidating.value = value;
386
+ }
387
+ }
388
+
336
389
 
337
390
  const groups = computed(() => {
338
391
  let fieldGroupType;
@@ -381,9 +434,50 @@ watch(() => isValid.value, (value) => {
381
434
  emit('update:isValid', value);
382
435
  });
383
436
 
437
+ async function validateUsingUserValidationFunction(editableColumnsInner: AdminForthResourceColumnCommon[]): Promise<void> {
438
+ if (doesUserHaveCustomValidation.value) {
439
+ try {
440
+ blockSettingIsValidating.value = true;
441
+ const res = await callAdminForthApi({
442
+ method: 'POST',
443
+ path: '/validate_columns',
444
+ body: {
445
+ resourceId: props.resource.resourceId,
446
+ editableColumns: editableColumnsInner.map(col => {return {name: col.name, value: currentValues.value?.[col.name]} }),
447
+ record: currentValues.value,
448
+ }
449
+ })
450
+ if (res.validationResults && Object.keys(res.validationResults).length > 0) {
451
+ for (const [columnName, validationResult] of Object.entries(res.validationResults) as [string, any][]) {
452
+ if (!validationResult.isValid) {
453
+ columnsWithErrors.value[columnName] = validationResult.message || 'Invalid value';
454
+ } else {
455
+ delete columnsWithErrors.value[columnName];
456
+ }
457
+ }
458
+ const columnsToProcess = editableColumns.value.filter(col => res.validationResults[col.name] === undefined);
459
+ columnsToProcess.forEach(column => {
460
+ checkIfColumnHasError(column);
461
+ });
462
+ } else {
463
+ editableColumnsInner.forEach(column => {
464
+ checkIfColumnHasError(column);
465
+ });
466
+ }
467
+ blockSettingIsValidating.value = false;
468
+ } catch (e) {
469
+ console.error('Error during custom validation', e);
470
+ blockSettingIsValidating.value = false;
471
+ }
472
+ }
473
+ }
474
+
384
475
  defineExpose({
385
476
  columnError,
386
477
  editableColumns,
478
+ columnsWithErrors,
479
+ isValidating,
480
+ validateUsingUserValidationFunction
387
481
  })
388
482
 
389
483
  </script>
@@ -211,12 +211,18 @@
211
211
  <button
212
212
  type="button"
213
213
  class="border border-gray-300 dark:border-gray-700 dark:border-opacity-0 border-opacity-0 hover:border-opacity-100 dark:hover:border-opacity-100 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer"
214
+ :disabled="!!actionLoadingStates[`${action.id}_${row._primaryKeyValue}`]"
214
215
  >
215
216
  <component
216
- v-if="action.icon"
217
+ v-if="action.icon && !actionLoadingStates[`${action.id}_${row._primaryKeyValue}`]"
217
218
  :is="getIcon(action.icon)"
218
219
  class="w-6 h-6 text-lightPrimary dark:text-darkPrimary"
219
220
  />
221
+ <Spinner
222
+ v-if="actionLoadingStates[`${action.id}_${row._primaryKeyValue}`]"
223
+ class="w-5 h-5 text-gray-200 dark:text-gray-500 fill-gray-500 dark:fill-gray-300"
224
+ />
225
+ <span v-if="actionLoadingStates[`${action.id}_${row._primaryKeyValue}`]" class="sr-only">Loading...</span>
220
226
  </button>
221
227
  </component>
222
228
 
@@ -356,7 +362,7 @@ import {
356
362
  IconInboxOutline
357
363
  } from '@iconify-prerendered/vue-flowbite';
358
364
  import router from '@/router';
359
- import { Tooltip } from '@/afcl';
365
+ import { Tooltip, Spinner } from '@/afcl';
360
366
  import type { AdminForthResourceCommon, AdminForthResourceColumnCommon, AdminForthComponentDeclarationFull, AdminForthComponentDeclaration } from '@/types/Common';
361
367
  import { useAdminforth } from '@/adminforth';
362
368
  import Checkbox from '@/afcl/Checkbox.vue';
@@ -613,7 +619,7 @@ async function startCustomAction(actionId: string | number, row: any, extraData:
613
619
  recordId: row._primaryKeyValue,
614
620
  extra: extraData,
615
621
  setLoadingState: (loading: boolean) => {
616
- actionLoadingStates.value[actionId] = loading;
622
+ actionLoadingStates.value[`${actionId}_${row._primaryKeyValue}`] = loading;
617
623
  },
618
624
  onSuccess: async (data: any) => {
619
625
  emits('update:records', true);
@@ -638,10 +644,10 @@ function validatePageInput() {
638
644
  pageInput.value = validPage.toString();
639
645
  }
640
646
  /*
641
- *___________________________________________________________________
642
- * |
643
- * Virtual Scroll Implementation |
644
- *___________________________________________________________________|
647
+ * ___________________________________________________________________
648
+ *| |
649
+ *| Virtual Scroll Implementation |
650
+ *|___________________________________________________________________|
645
651
  */
646
652
  // Add throttle utility
647
653
  const throttle = (fn: Function, delay: number) => {
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="overflow-x-auto shadow-resourseFormShadow dark:shadow-darkResourseFormShadow"
2
+ <div class="overflow-x-auto shadow-resourseFormShadow dark:shadow-darkResourseFormShadow border dark:border-gray-700"
3
3
  :class="{'rounded-default' : isRounded}"
4
4
  >
5
5
  <div v-if="groupName && !noTitle" class="text-md font-semibold px-6 py-3 flex flex-1 items-center text-lightShowTableHeadingText bg-lightShowTableHeadingBackground dark:bg-darkShowTableHeadingBackground dark:text-darkShowTableHeadingText rounded-t-lg">