adminforth 2.27.0-next.1 → 2.27.0-next.10

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 (32) hide show
  1. package/dist/modules/restApi.d.ts.map +1 -1
  2. package/dist/modules/restApi.js +40 -2
  3. package/dist/modules/restApi.js.map +1 -1
  4. package/dist/spa/package-lock.json +41 -0
  5. package/dist/spa/package.json +3 -0
  6. package/dist/spa/pnpm-lock.yaml +38 -0
  7. package/dist/spa/src/components/ColumnValueInputWrapper.vue +24 -1
  8. package/dist/spa/src/components/CustomRangePicker.vue +6 -0
  9. package/dist/spa/src/components/GroupsTable.vue +7 -4
  10. package/dist/spa/src/components/ResourceForm.vue +100 -6
  11. package/dist/spa/src/components/ResourceListTable.vue +21 -43
  12. package/dist/spa/src/components/ThreeDotsMenu.vue +47 -51
  13. package/dist/spa/src/types/Back.ts +3 -0
  14. package/dist/spa/src/types/Common.ts +18 -3
  15. package/dist/spa/src/types/adapters/StorageAdapter.ts +12 -0
  16. package/dist/spa/src/utils/createEditUtils.ts +65 -0
  17. package/dist/spa/src/utils/index.ts +2 -1
  18. package/dist/spa/src/utils/utils.ts +165 -4
  19. package/dist/spa/src/utils.ts +2 -1
  20. package/dist/spa/src/views/CreateView.vue +22 -49
  21. package/dist/spa/src/views/EditView.vue +20 -38
  22. package/dist/spa/src/views/ListView.vue +72 -13
  23. package/dist/spa/src/views/ShowView.vue +52 -46
  24. package/dist/types/Back.d.ts +3 -0
  25. package/dist/types/Back.d.ts.map +1 -1
  26. package/dist/types/Back.js.map +1 -1
  27. package/dist/types/Common.d.ts +23 -3
  28. package/dist/types/Common.d.ts.map +1 -1
  29. package/dist/types/Common.js.map +1 -1
  30. package/dist/types/adapters/StorageAdapter.d.ts +11 -0
  31. package/dist/types/adapters/StorageAdapter.d.ts.map +1 -1
  32. package/package.json +1 -1
@@ -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>
@@ -80,13 +81,15 @@
80
81
  source: 'create' | 'edit',
81
82
  group: any,
82
83
  mode: string,
83
- validating: boolean,
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({});
@@ -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>
@@ -341,7 +341,7 @@
341
341
 
342
342
 
343
343
  import { computed, onMounted, ref, watch, useTemplateRef, nextTick, type Ref } from 'vue';
344
- import { callAdminForthApi } from '@/utils';
344
+ import { callAdminForthApi, executeCustomAction } from '@/utils';
345
345
  import { useI18n } from 'vue-i18n';
346
346
  import ValueRenderer from '@/components/ValueRenderer.vue';
347
347
  import { getCustomComponent, formatComponent } from '@/utils';
@@ -607,50 +607,28 @@ async function deleteRecord(row: any) {
607
607
  const actionLoadingStates = ref<Record<string | number, boolean>>({});
608
608
 
609
609
  async function startCustomAction(actionId: string | number, row: any, extraData: Record<string, any> = {}) {
610
-
611
- actionLoadingStates.value[actionId] = true;
612
-
613
- const data = await callAdminForthApi({
614
- path: '/start_custom_action',
615
- method: 'POST',
616
- body: {
617
- resourceId: props.resource?.resourceId,
618
- actionId: actionId,
619
- recordId: row._primaryKeyValue,
620
- extra: extraData
621
- }
622
- });
623
-
624
- actionLoadingStates.value[actionId] = false;
625
-
626
- if (data?.redirectUrl) {
627
- // Check if the URL should open in a new tab
628
- if (data.redirectUrl.includes('target=_blank')) {
629
- window.open(data.redirectUrl.replace('&target=_blank', '').replace('?target=_blank', ''), '_blank');
630
- } else {
631
- // Navigate within the app
632
- if (data.redirectUrl.startsWith('http')) {
633
- window.location.href = data.redirectUrl;
634
- } else {
635
- router.push(data.redirectUrl);
610
+ await executeCustomAction({
611
+ actionId,
612
+ resourceId: props.resource?.resourceId || '',
613
+ recordId: row._primaryKeyValue,
614
+ extra: extraData,
615
+ setLoadingState: (loading: boolean) => {
616
+ actionLoadingStates.value[actionId] = loading;
617
+ },
618
+ onSuccess: async (data: any) => {
619
+ emits('update:records', true);
620
+
621
+ if (data.successMessage) {
622
+ alert({
623
+ message: data.successMessage,
624
+ variant: 'success'
625
+ });
636
626
  }
627
+ },
628
+ onError: (error: string) => {
629
+ showErrorTost(error);
637
630
  }
638
- return;
639
- }
640
- if (data?.ok) {
641
- emits('update:records', true);
642
-
643
- if (data.successMessage) {
644
- alert({
645
- message: data.successMessage,
646
- variant: 'success'
647
- });
648
- }
649
- }
650
-
651
- if (data?.error) {
652
- showErrorTost(data.error);
653
- }
631
+ });
654
632
  }
655
633
 
656
634
  function validatePageInput() {
@@ -30,9 +30,9 @@
30
30
  }"
31
31
  @click="injectedComponentClick(i)"
32
32
  >
33
- <div class="wrapper">
33
+ <div class="wrapper" v-if="getCustomComponent(item)">
34
34
  <component
35
- :ref="(el: any) => setComponentRef(el, i)" :is="getCustomComponent(item)"
35
+ :ref="(el: any) => setComponentRef(el, i)" :is="getCustomComponent(item)!"
36
36
  :meta="item.meta"
37
37
  :resource="coreStore.resource"
38
38
  :adminUser="coreStore.adminUser"
@@ -46,18 +46,30 @@
46
46
  <li v-for="action in customActions" :key="action.id">
47
47
  <div class="wrapper">
48
48
  <component
49
- v-if="action.customComponent"
50
49
  :is="(action.customComponent && getCustomComponent(formatComponent(action.customComponent))) || CallActionWrapper"
51
50
  :meta="formatComponent(action.customComponent).meta"
52
51
  @callAction="(payload? : Object) => handleActionClick(action, payload)"
53
52
  >
54
- <a @click.prevent class="block px-4 py-2 hover:text-lightThreeDotsMenuBodyTextHover hover:bg-lightThreeDotsMenuBodyBackgroundHover dark:hover:bg-darkThreeDotsMenuBodyBackgroundHover dark:hover:text-darkThreeDotsMenuBodyTextHover">
53
+ <a @click.prevent class="block">
55
54
  <div class="flex items-center gap-2">
56
55
  <component
57
- v-if="action.icon"
56
+ v-if="action.icon && !actionLoadingStates[action.id!]"
58
57
  :is="getIcon(action.icon)"
59
58
  class="w-4 h-4 text-lightPrimary dark:text-darkPrimary"
60
59
  />
60
+ <div v-if="actionLoadingStates[action.id!]">
61
+ <svg
62
+ aria-hidden="true"
63
+ class="w-4 h-4 animate-spin text-gray-200 dark:text-gray-500 fill-gray-500 dark:fill-gray-300"
64
+ viewBox="0 0 100 101"
65
+ fill="none"
66
+ xmlns="http://www.w3.org/2000/svg"
67
+ >
68
+ <path d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" fill="currentColor"/>
69
+ <path d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" fill="currentFill"/>
70
+ </svg>
71
+ <span class="sr-only">Loading...</span>
72
+ </div>
61
73
  {{ action.name }}
62
74
  </div>
63
75
  </a>
@@ -89,7 +101,7 @@
89
101
 
90
102
 
91
103
  <script setup lang="ts">
92
- import { getCustomComponent, getIcon, formatComponent } from '@/utils';
104
+ import { getCustomComponent, getIcon, formatComponent, executeCustomAction } from '@/utils';
93
105
  import { useCoreStore } from '@/stores/core';
94
106
  import { useAdminforth } from '@/adminforth';
95
107
  import { callAdminForthApi } from '@/utils';
@@ -105,6 +117,7 @@ const coreStore = useCoreStore();
105
117
  const router = useRouter();
106
118
  const threeDotsDropdownItemsRefs = ref<Array<ComponentPublicInstance | null>>([]);
107
119
  const showDropdown = ref(false);
120
+ const actionLoadingStates = ref<Record<string, boolean>>({});
108
121
  const dropdownRef = ref<HTMLElement | null>(null);
109
122
  const buttonTriggerRef = ref<HTMLElement | null>(null);
110
123
 
@@ -131,55 +144,35 @@ function setComponentRef(el: ComponentPublicInstance | null, index: number) {
131
144
 
132
145
  async function handleActionClick(action: AdminForthActionInput, payload: any) {
133
146
  list.closeThreeDotsDropdown();
134
-
135
- const actionId = action.id;
136
- const data = await callAdminForthApi({
137
- path: '/start_custom_action',
138
- method: 'POST',
139
- body: {
140
- resourceId: route.params.resourceId,
141
- actionId: actionId,
142
- recordId: route.params.primaryKey,
143
- extra: payload || {},
144
- }
145
- });
147
+ await executeCustomAction({
148
+ actionId: action.id,
149
+ resourceId: route.params.resourceId as string,
150
+ recordId: route.params.primaryKey as string,
151
+ extra: payload || {},
152
+ setLoadingState: (loading: boolean) => {
153
+ actionLoadingStates.value[action.id!] = loading;
154
+ },
155
+ onSuccess: async (data: any) => {
156
+ await coreStore.fetchRecord({
157
+ resourceId: route.params.resourceId as string,
158
+ primaryKey: route.params.primaryKey as string,
159
+ source: 'show',
160
+ });
146
161
 
147
- if (data?.redirectUrl) {
148
- // Check if the URL should open in a new tab
149
- if (data.redirectUrl.includes('target=_blank')) {
150
- window.open(data.redirectUrl.replace('&target=_blank', '').replace('?target=_blank', ''), '_blank');
151
- } else {
152
- // Navigate within the app
153
- if (data.redirectUrl.startsWith('http')) {
154
- window.location.href = data.redirectUrl;
155
- } else {
156
- router.push(data.redirectUrl);
162
+ if (data.successMessage) {
163
+ alert({
164
+ message: data.successMessage,
165
+ variant: 'success'
166
+ });
157
167
  }
158
- }
159
- return;
160
- }
161
-
162
- if (data?.ok) {
163
- await coreStore.fetchRecord({
164
- resourceId: route.params.resourceId as string,
165
- primaryKey: route.params.primaryKey as string,
166
- source: 'show',
167
- });
168
-
169
- if (data.successMessage) {
168
+ },
169
+ onError: (error: string) => {
170
170
  alert({
171
- message: data.successMessage,
172
- variant: 'success'
171
+ message: error,
172
+ variant: 'danger'
173
173
  });
174
174
  }
175
- }
176
-
177
- if (data?.error) {
178
- alert({
179
- message: data.error,
180
- variant: 'danger'
181
- });
182
- }
175
+ });
183
176
  }
184
177
 
185
178
  function startBulkAction(actionId: string) {
@@ -220,7 +213,10 @@ onUnmounted(() => {
220
213
 
221
214
  <style lang="scss" scoped>
222
215
  .wrapper > * {
223
- @apply px-4 py-2;
216
+ @apply px-4 py-2
217
+ hover:text-lightThreeDotsMenuBodyTextHover hover:bg-lightThreeDotsMenuBodyBackgroundHover
218
+ dark:hover:bg-darkThreeDotsMenuBodyBackgroundHover dark:hover:text-darkThreeDotsMenuBodyTextHover
219
+ cursor-pointer;
224
220
  }
225
221
  </style>
226
222
 
@@ -1291,11 +1291,14 @@ interface AdminForthInputConfigCustomization {
1291
1291
 
1292
1292
  export interface AdminForthActionInput {
1293
1293
  name: string;
1294
+ bulkConfirmationMessage?: string;
1295
+ bulkSuccessMessage?: string;
1294
1296
  showIn?: {
1295
1297
  list?: boolean,
1296
1298
  listThreeDotsMenu?: boolean,
1297
1299
  showButton?: boolean,
1298
1300
  showThreeDotsMenu?: boolean,
1301
+ bulkButton?: boolean,
1299
1302
  };
1300
1303
  allowed?: (params: {
1301
1304
  adminUser: AdminUser;
@@ -303,7 +303,7 @@ export interface AdminForthComponentDeclarationFull {
303
303
  [key: string]: any,
304
304
  }
305
305
  }
306
- import { type AdminForthActionInput, type AdminForthResource } from './Back.js'
306
+ import { type IAdminForth, type AdminForthActionInput, type AdminForthResource } from './Back.js'
307
307
  export { type AdminForthActionInput } from './Back.js'
308
308
 
309
309
  export type AdminForthComponentDeclaration = AdminForthComponentDeclarationFull | string;
@@ -436,6 +436,7 @@ export interface AdminForthResourceInputCommon {
436
436
  /**
437
437
  * Custom bulk actions list. Bulk actions available in list view when user selects multiple records by
438
438
  * using checkboxes.
439
+ * @deprecated in favor of defining .
439
440
  */
440
441
  bulkActions?: AdminForthBulkActionCommon[],
441
442
 
@@ -609,14 +610,14 @@ export type ValidationObject = {
609
610
  * ```
610
611
  *
611
612
  */
612
- regExp: string,
613
+ regExp?: string,
613
614
 
614
615
  /**
615
616
  * Error message shown to user if validation fails
616
617
  *
617
618
  * Example: "Invalid email format"
618
619
  */
619
- message: string,
620
+ message?: string,
620
621
 
621
622
  /**
622
623
  * Whether to check case sensitivity (i flag)
@@ -632,6 +633,20 @@ export type ValidationObject = {
632
633
  * Whether to check global strings (g flag)
633
634
  */
634
635
  global?: boolean
636
+
637
+ /**
638
+ * Custom validator function.
639
+ *
640
+ * Example:
641
+ *
642
+ * ```ts
643
+ * validator: async (value) => {
644
+ * // custom validation logic
645
+ * return { isValid: true, message: 'Validation passed' }; // or { isValid: false, message: 'Validation failed' }
646
+ * }
647
+ * ```
648
+ */
649
+ validator?: (value: any, record: any, adminForth: IAdminForth) => {isValid: boolean, message?: string} | Promise<{isValid: boolean, message?: string}> | boolean,
635
650
  }
636
651
 
637
652
 
@@ -70,6 +70,18 @@ export interface StorageAdapter {
70
70
  * @returns A promise that resolves to a string containing the data URL
71
71
  */
72
72
  getKeyAsDataURL(key: string): Promise<string>;
73
+
74
+ /**
75
+ * Determines whether the given URL points to a resource managed by this storage adapter.
76
+ * * This method is important for plugins (such as MarkdownPlugin) to distinguish between
77
+ * "own" resources (stored in your S3 bucket or local storage) and external links * (such as images from Unsplash or Google).
78
+ * * The implementation logic typically includes:
79
+ * 1. Checking whether the hostname of the URL matches the configured bucket domain or custom CDN.
80
+ * 2. Checking whether the URL path contains the adapter's specific download prefix.
81
+ * * @param url - The full URL string to check (can be a public URL or a pre-signed URL).
82
+ * @returns A promise that returns true if the URL belongs to this adapter, false otherwise.
83
+ */
84
+ isInternalUrl (url: string): Promise<boolean>;
73
85
  }
74
86
 
75
87
 
@@ -0,0 +1,65 @@
1
+ import type { AdminForthResourceColumn } from '@/types/Back';
2
+ import { useAdminforth } from '@/adminforth';
3
+ import { type Ref, nextTick } from 'vue';
4
+
5
+ export function scrollToInvalidField(resourceFormRef: any, t: (key: string) => string) {
6
+ const { alert } = useAdminforth();
7
+ let columnsWithErrors: {column: AdminForthResourceColumn, error: string}[] = [];
8
+ for (const column of resourceFormRef.value?.editableColumns || []) {
9
+ if (resourceFormRef.value?.columnsWithErrors[column.name]) {
10
+ columnsWithErrors.push({
11
+ column,
12
+ error: resourceFormRef.value?.columnsWithErrors[column.name]
13
+ });
14
+ }
15
+ }
16
+ const errorMessage = t('Failed to save. Please fix errors for the following fields:') + '<ul class="mt-2 list-disc list-inside">' + columnsWithErrors.map(c => `<li><strong>${c.column.label || c.column.name}</strong>: ${c.error}</li>`).join('') + '</ul>';
17
+ alert({
18
+ messageHtml: errorMessage,
19
+ variant: 'danger'
20
+ });
21
+ const firstInvalidElement = document.querySelector('.af-invalid-field-message');
22
+ if (firstInvalidElement) {
23
+ firstInvalidElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
24
+ }
25
+ }
26
+
27
+ export async function saveRecordPreparations(
28
+ viewMode: 'create' | 'edit',
29
+ validatingMode: Ref<boolean>,
30
+ resourceFormRef: Ref<any>,
31
+ isValid: Ref<boolean>,
32
+ t: (key: string) => string,
33
+ saving: Ref<boolean>,
34
+ runSaveInterceptors: any,
35
+ record: Ref<Record<string, any>>,
36
+ coreStore: any,
37
+ route: any
38
+ ) {
39
+ validatingMode.value = true;
40
+ await nextTick();
41
+ //wait for response for the user validation function if it exists
42
+ while (1) {
43
+ if (resourceFormRef.value?.isValidating) {
44
+ await new Promise(resolve => setTimeout(resolve, 100));
45
+ } else {
46
+ break;
47
+ }
48
+ }
49
+ if (!isValid.value) {
50
+ await nextTick();
51
+ scrollToInvalidField(resourceFormRef, t);
52
+ return;
53
+ } else {
54
+ validatingMode.value = false;
55
+ }
56
+
57
+ saving.value = true;
58
+ const interceptorsResult = await runSaveInterceptors({
59
+ action: viewMode,
60
+ values: record.value,
61
+ resource: coreStore.resource,
62
+ resourceId: route.params.resourceId as string,
63
+ });
64
+ return interceptorsResult;
65
+ }
@@ -1,2 +1,3 @@
1
1
  export * from './utils';
2
- export * from './listUtils';
2
+ export * from './listUtils';
3
+ export * from './createEditUtils';