adminforth 2.17.0-next.9 → 2.17.1

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 (106) hide show
  1. package/commands/callTsProxy.js +2 -1
  2. package/commands/createApp/templates/adminuser.ts.hbs +2 -1
  3. package/commands/createApp/templates/index.ts.hbs +3 -2
  4. package/commands/createCustomComponent/main.js +0 -3
  5. package/commands/createCustomComponent/templates/customCrud/afterBreadcrumbs.vue.hbs +4 -2
  6. package/commands/createCustomComponent/templates/customCrud/beforeActionButtons.vue.hbs +3 -2
  7. package/commands/createCustomComponent/templates/customCrud/beforeBreadcrumbs.vue.hbs +4 -2
  8. package/commands/createCustomComponent/templates/customCrud/bottom.vue.hbs +4 -2
  9. package/commands/createCustomComponent/templates/customCrud/threeDotsDropdownItems.vue.hbs +4 -2
  10. package/commands/createPlugin/templates/index.ts.hbs +4 -0
  11. package/dist/auth.d.ts +2 -2
  12. package/dist/auth.d.ts.map +1 -1
  13. package/dist/auth.js +17 -10
  14. package/dist/auth.js.map +1 -1
  15. package/dist/basePlugin.d.ts +1 -0
  16. package/dist/basePlugin.d.ts.map +1 -1
  17. package/dist/basePlugin.js +6 -2
  18. package/dist/basePlugin.js.map +1 -1
  19. package/dist/dataConnectors/baseConnector.d.ts +1 -0
  20. package/dist/dataConnectors/baseConnector.d.ts.map +1 -1
  21. package/dist/dataConnectors/baseConnector.js +100 -14
  22. package/dist/dataConnectors/baseConnector.js.map +1 -1
  23. package/dist/dataConnectors/clickhouse.d.ts +2 -0
  24. package/dist/dataConnectors/clickhouse.d.ts.map +1 -1
  25. package/dist/dataConnectors/clickhouse.js +15 -4
  26. package/dist/dataConnectors/clickhouse.js.map +1 -1
  27. package/dist/dataConnectors/mongo.d.ts +8 -1
  28. package/dist/dataConnectors/mongo.d.ts.map +1 -1
  29. package/dist/dataConnectors/mongo.js +72 -28
  30. package/dist/dataConnectors/mongo.js.map +1 -1
  31. package/dist/dataConnectors/mysql.d.ts +2 -0
  32. package/dist/dataConnectors/mysql.d.ts.map +1 -1
  33. package/dist/dataConnectors/mysql.js +22 -23
  34. package/dist/dataConnectors/mysql.js.map +1 -1
  35. package/dist/dataConnectors/postgres.d.ts +2 -0
  36. package/dist/dataConnectors/postgres.d.ts.map +1 -1
  37. package/dist/dataConnectors/postgres.js +23 -26
  38. package/dist/dataConnectors/postgres.js.map +1 -1
  39. package/dist/dataConnectors/sqlite.d.ts +2 -0
  40. package/dist/dataConnectors/sqlite.d.ts.map +1 -1
  41. package/dist/dataConnectors/sqlite.js +19 -19
  42. package/dist/dataConnectors/sqlite.js.map +1 -1
  43. package/dist/index.d.ts +10 -4
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +60 -54
  46. package/dist/index.js.map +1 -1
  47. package/dist/modules/codeInjector.d.ts.map +1 -1
  48. package/dist/modules/codeInjector.js +45 -63
  49. package/dist/modules/codeInjector.js.map +1 -1
  50. package/dist/modules/configValidator.d.ts.map +1 -1
  51. package/dist/modules/configValidator.js +14 -9
  52. package/dist/modules/configValidator.js.map +1 -1
  53. package/dist/modules/logger.d.ts +5 -0
  54. package/dist/modules/logger.d.ts.map +1 -0
  55. package/dist/modules/logger.js +16 -0
  56. package/dist/modules/logger.js.map +1 -0
  57. package/dist/modules/restApi.d.ts.map +1 -1
  58. package/dist/modules/restApi.js +21 -23
  59. package/dist/modules/restApi.js.map +1 -1
  60. package/dist/modules/socketBroker.d.ts.map +1 -1
  61. package/dist/modules/socketBroker.js +6 -5
  62. package/dist/modules/socketBroker.js.map +1 -1
  63. package/dist/modules/styles.js +1 -1
  64. package/dist/servers/express.d.ts.map +1 -1
  65. package/dist/servers/express.js +11 -11
  66. package/dist/servers/express.js.map +1 -1
  67. package/dist/spa/src/App.vue +6 -3
  68. package/dist/spa/src/adminforth.ts +60 -1
  69. package/dist/spa/src/afcl/DatePicker.vue +0 -1
  70. package/dist/spa/src/afcl/Dropzone.vue +6 -4
  71. package/dist/spa/src/afcl/Tooltip.vue +38 -4
  72. package/dist/spa/src/components/ColumnValueInput.vue +14 -1
  73. package/dist/spa/src/components/CustomDateRangePicker.vue +0 -2
  74. package/dist/spa/src/components/CustomRangePicker.vue +9 -6
  75. package/dist/spa/src/components/Filters.vue +4 -4
  76. package/dist/spa/src/components/ListActionsThreeDots.vue +235 -0
  77. package/dist/spa/src/components/ResourceForm.vue +4 -4
  78. package/dist/spa/src/components/ResourceListTable.vue +30 -16
  79. package/dist/spa/src/components/ResourceListTableVirtual.vue +34 -18
  80. package/dist/spa/src/components/Sidebar.vue +4 -2
  81. package/dist/spa/src/components/ThreeDotsMenu.vue +35 -20
  82. package/dist/spa/src/composables/useFrontendApi.ts +8 -4
  83. package/dist/spa/src/renderers/CompactField.vue +3 -2
  84. package/dist/spa/src/renderers/CompactUUID.vue +3 -2
  85. package/dist/spa/src/stores/core.ts +3 -2
  86. package/dist/spa/src/types/Back.ts +33 -10
  87. package/dist/spa/src/types/Common.ts +7 -14
  88. package/dist/spa/src/types/FrontendAPI.ts +25 -10
  89. package/dist/spa/src/views/CreateView.vue +23 -31
  90. package/dist/spa/src/views/EditView.vue +23 -31
  91. package/dist/spa/src/views/ListView.vue +20 -10
  92. package/dist/spa/src/views/SettingsView.vue +3 -2
  93. package/dist/spa/src/views/ShowView.vue +7 -6
  94. package/dist/types/Back.d.ts +26 -4
  95. package/dist/types/Back.d.ts.map +1 -1
  96. package/dist/types/Back.js +6 -0
  97. package/dist/types/Back.js.map +1 -1
  98. package/dist/types/Common.d.ts +8 -15
  99. package/dist/types/Common.d.ts.map +1 -1
  100. package/dist/types/Common.js +2 -0
  101. package/dist/types/Common.js.map +1 -1
  102. package/dist/types/FrontendAPI.d.ts +32 -10
  103. package/dist/types/FrontendAPI.d.ts.map +1 -1
  104. package/dist/types/FrontendAPI.js.map +1 -1
  105. package/package.json +4 -1
  106. package/commands/createCustomComponent/templates/customCrud/saveButton.vue.hbs +0 -28
@@ -19,11 +19,12 @@ class FrontendAPI implements FrontendAPIInterface {
19
19
  public modalStore:any
20
20
  public filtersStore:any
21
21
  public coreStore:any
22
+ private saveInterceptors: Record<string, Array<(ctx: { action: 'create'|'edit'; values: any; resource: any; resourceId: string; }) => Promise<{ ok: boolean; error?: string | null; extra?: object; }>>> = {};
22
23
 
23
24
  public list: {
24
25
  refresh(): Promise<{ error? : string }>;
25
26
  silentRefresh(): Promise<{ error? : string }>;
26
- silentRefreshRow(): Promise<{ error? : string }>;
27
+ silentRefreshRow(pk: any): Promise<{ error? : string }>;
27
28
  closeThreeDotsDropdown(): Promise<{ error? : string }>;
28
29
  closeUserMenuDropdown: () => void;
29
30
  setFilter: (filter: FilterParams) => void;
@@ -84,6 +85,49 @@ class FrontendAPI implements FrontendAPIInterface {
84
85
  }
85
86
  }
86
87
 
88
+ registerSaveInterceptor(
89
+ handler: (ctx: { action: 'create'|'edit'; values: any; resource: any; }) => Promise<{ ok: boolean; error?: string | null; extra?: object; }>,
90
+ ): void {
91
+ const rid = router.currentRoute.value?.params?.resourceId as string;
92
+ if (!rid) {
93
+ return;
94
+ }
95
+ if (!this.saveInterceptors[rid]) {
96
+ this.saveInterceptors[rid] = [];
97
+ }
98
+ this.saveInterceptors[rid].push(handler);
99
+ }
100
+
101
+ async runSaveInterceptors(params: { action: 'create'|'edit'; values: any; resource: any; resourceId: string; }): Promise<{ ok: boolean; error?: string | null; extra?: object; }> {
102
+ const list = this.saveInterceptors[params.resourceId] || [];
103
+ const aggregatedExtra: Record<string, any> = {};
104
+ for (const fn of list) {
105
+ try {
106
+ const res = await fn(params);
107
+ if (typeof res !== 'object' || typeof res.ok !== 'boolean') {
108
+ return { ok: false, error: 'Invalid interceptor return value' };
109
+ }
110
+ if (!res.ok) {
111
+ return { ok: false, error: res.error ?? 'Interceptor failed' };
112
+ }
113
+ if (res.extra) {
114
+ Object.assign(aggregatedExtra, res.extra);
115
+ }
116
+ } catch (e: any) {
117
+ return { ok: false, error: e?.message || String(e) };
118
+ }
119
+ }
120
+ return { ok: true, extra: aggregatedExtra };
121
+ }
122
+
123
+ clearSaveInterceptors(resourceId?: string): void {
124
+ if (resourceId) {
125
+ delete this.saveInterceptors[resourceId];
126
+ } else {
127
+ this.saveInterceptors = {};
128
+ }
129
+ }
130
+
87
131
  confirm(params: ConfirmParams): Promise<boolean> {
88
132
  return new Promise((resolve, reject) => {
89
133
  this.modalStore.setModalContent({
@@ -180,5 +224,20 @@ export function initFrontedAPI() {
180
224
  api.filtersStore = useFiltersStore();
181
225
  }
182
226
 
227
+ export function useAdminforth() {
228
+ const api = frontendAPI as FrontendAPI;
229
+ return {
230
+ registerSaveInterceptor: (handler: (ctx: { action: 'create'|'edit'; values: any; resource: any; }) => Promise<{ ok: boolean; error?: string | null; extra?: object; }>) => api.registerSaveInterceptor(handler),
231
+ alert: (params: AlertParams) => api.alert(params),
232
+ confirm: (params: ConfirmParams) => api.confirm(params),
233
+ list: api.list,
234
+ show: api.show,
235
+ menu: api.menu,
236
+ closeUserMenuDropdown: () => api.closeUserMenuDropdown(),
237
+ runSaveInterceptors: (params: { action: 'create'|'edit'; values: any; resource: any; resourceId: string; }) => api.runSaveInterceptors(params),
238
+ clearSaveInterceptors: (resourceId?: string) => api.clearSaveInterceptors(resourceId),
239
+ };
240
+ }
241
+
183
242
 
184
243
  export default frontendAPI;
@@ -134,7 +134,6 @@ onMounted(() => {
134
134
  })
135
135
 
136
136
  watch(start, () => {
137
- //console.log('⚡ emit', start.value)
138
137
  emit('update:valueStart', start.value)
139
138
  })
140
139
 
@@ -100,7 +100,9 @@ import { humanifySize } from '@/utils';
100
100
  import { ref, type Ref, computed } from 'vue';
101
101
  import { IconFileSolid } from '@iconify-prerendered/vue-flowbite';
102
102
  import { watch } from 'vue';
103
- import adminforth from '@/adminforth';
103
+ import { useAdminforth } from '@/adminforth';
104
+
105
+ const { alert } = useAdminforth();
104
106
 
105
107
  const props = defineProps<{
106
108
  extensions: string[],
@@ -175,7 +177,7 @@ function doEmit(filesIn: FileList) {
175
177
  );
176
178
 
177
179
  if (isDuplicate) {
178
- adminforth.alert({
180
+ alert({
179
181
  message: `The file "${file.name}" is already selected.`,
180
182
  variant: 'warning',
181
183
  });
@@ -183,14 +185,14 @@ function doEmit(filesIn: FileList) {
183
185
  }
184
186
 
185
187
  if (!allowedExtensions.includes(`.${extension}`)) {
186
- adminforth.alert({
188
+ alert({
187
189
  message: `Sorry, the file type .${extension} is not allowed. Please upload a file with one of the following extensions: ${allowedExtensions.join(', ')}`,
188
190
  variant: 'danger',
189
191
  });
190
192
  return;
191
193
  }
192
194
  if (size > maxSizeBytes) {
193
- adminforth.alert({
195
+ alert({
194
196
  message: `Sorry, the file size ${humanifySize(size)} exceeds the maximum allowed size of ${humanifySize(maxSizeBytes)}.`,
195
197
  variant: 'danger',
196
198
  });
@@ -9,9 +9,7 @@
9
9
  ref="tooltip"
10
10
  >
11
11
  <slot name="tooltip"></slot>
12
- <div class="tooltip-arrow absolute -top-2" data-popper-arrow>
13
- <div class="absolute top-0 -left-0.5 w-0 h-0 border-l-8 border-r-8 border-b-8 border-l-transparent border-r-transparent border-b-lightTooltipBackground dark:border-b-darkTooltipBackground"></div>
14
- </div>
12
+ <div class="tooltip-arrow" data-popper-arrow></div>
15
13
  </div>
16
14
  </teleport>
17
15
  </template>
@@ -50,4 +48,40 @@ function mouseOff() {
50
48
  showTooltip.value = false;
51
49
  }
52
50
 
53
- </script>
51
+ </script>
52
+
53
+ <style>
54
+ .tooltip .tooltip-arrow,
55
+ .tooltip .tooltip-arrow::before {
56
+ position: absolute;
57
+ width: 8px;
58
+ height: 8px;
59
+ background: inherit;
60
+ }
61
+
62
+ .tooltip .tooltip-arrow {
63
+ visibility: hidden;
64
+ }
65
+
66
+ .tooltip .tooltip-arrow::before {
67
+ visibility: visible;
68
+ content: '';
69
+ transform: rotate(45deg);
70
+ }
71
+
72
+ .tooltip[data-popper-placement^='top'] > .tooltip-arrow {
73
+ bottom: -4px;
74
+ }
75
+
76
+ .tooltip[data-popper-placement^='bottom'] > .tooltip-arrow {
77
+ top: -4px;
78
+ }
79
+
80
+ .tooltip[data-popper-placement^='left'] > .tooltip-arrow {
81
+ right: -4px;
82
+ }
83
+
84
+ .tooltip[data-popper-placement^='right'] > .tooltip-arrow {
85
+ left: -4px;
86
+ }
87
+ </style>
@@ -86,7 +86,20 @@
86
86
  :readonly="(column.editReadonly && source === 'edit') || readonly"
87
87
  />
88
88
  <Input
89
- v-else-if="['decimal', 'float'].includes(type || column.type)"
89
+ v-else-if="(type || column.type) === 'decimal'"
90
+ ref="input"
91
+ type="number"
92
+ inputmode="decimal"
93
+ class="w-40"
94
+ placeholder="0.0"
95
+ :fullWidth="true"
96
+ :prefix="column.inputPrefix"
97
+ :suffix="column.inputSuffix"
98
+ :modelValue="String(value)"
99
+ @update:modelValue="$emit('update:modelValue', String($event))"
100
+ />
101
+ <Input
102
+ v-else-if="(type || column.type) === 'float'"
90
103
  ref="input"
91
104
  type="number"
92
105
  step="0.1"
@@ -197,12 +197,10 @@ onMounted(() => {
197
197
  })
198
198
 
199
199
  watch(start, () => {
200
- //console.log('⚡ emit', start.value)
201
200
  emit('update:valueStart', start.value)
202
201
  })
203
202
 
204
203
  watch(end, () => {
205
- //console.log('⚡ emit', end.value)
206
204
  emit('update:valueEnd', end.value)
207
205
  })
208
206
 
@@ -53,9 +53,6 @@ const emit = defineEmits(['update:valueStart', 'update:valueEnd']);
53
53
  const minFormatted = computed(() => Math.floor(<number>props.min));
54
54
  const maxFormatted = computed(() => Math.ceil(<number>props.max));
55
55
 
56
- const isChanged = computed(() => {
57
- return start.value && start.value !== minFormatted.value || end.value && end.value !== maxFormatted.value;
58
- });
59
56
 
60
57
  const start = ref<string | number>(props.valueStart);
61
58
  const end = ref<string | number>(props.valueEnd);
@@ -92,17 +89,23 @@ function updateEndFromProps() {
92
89
  }
93
90
 
94
91
  watch(start, () => {
95
- console.log('⚡ emit', start.value)
96
92
  emit('update:valueStart', start.value)
97
93
  })
98
94
 
99
95
  watch(end, () => {
100
- console.log('⚡ emit', end.value)
101
96
  emit('update:valueEnd', end.value);
102
97
  })
103
98
 
104
99
  watch([minFormatted,maxFormatted], () => {
105
- setSliderValues(minFormatted.value, maxFormatted.value)
100
+ if ( !start.value && end.value ) {
101
+ setSliderValues(minFormatted.value, end.value);
102
+ } else if ( start.value && !end.value ) {
103
+ setSliderValues(start.value, maxFormatted.value);
104
+ } else if ( !start.value && !end.value ) {
105
+ setSliderValues(minFormatted.value, maxFormatted.value);
106
+ } else {
107
+ setSliderValues(start.value, end.value);
108
+ }
106
109
  })
107
110
 
108
111
  function setSliderValues(start: any, end: any) {
@@ -123,9 +123,9 @@
123
123
  :min="getFilterMinValue(c.name)"
124
124
  :max="getFilterMaxValue(c.name)"
125
125
  :valueStart="getFilterItem({ column: c, operator: 'gte' })"
126
- @update:valueStart="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? $event : undefined })"
126
+ @update:valueStart="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? (c.type === 'decimal' ? String($event) : $event) : undefined })"
127
127
  :valueEnd="getFilterItem({ column: c, operator: 'lte' })"
128
- @update:valueEnd="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? $event : undefined })"
128
+ @update:valueEnd="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? (c.type === 'decimal' ? String($event) : $event) : undefined })"
129
129
  />
130
130
 
131
131
  <div v-else-if="['integer', 'decimal', 'float'].includes(c.type)" class="flex gap-2">
@@ -133,14 +133,14 @@
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) ? $event : undefined })"
136
+ @update:modelValue="onFilterInput[c.name]({ column: c, operator: 'gte', value: ($event !== '' && $event !== null) ? (c.type === 'decimal' ? String($event) : $event) : undefined })"
137
137
  :modelValue="getFilterItem({ column: c, operator: 'gte' })"
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) ? $event : undefined })"
143
+ @update:modelValue="onFilterInput[c.name]({ column: c, operator: 'lte', value: ($event !== '' && $event !== null) ? (c.type === 'decimal' ? String($event) : $event) : undefined })"
144
144
  :modelValue="getFilterItem({ column: c, operator: 'lte' })"
145
145
  />
146
146
  </div>
@@ -0,0 +1,235 @@
1
+ <template>
2
+ <div class="relative inline-block">
3
+ <div
4
+ ref="triggerRef"
5
+ 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"
6
+ @click="toggleMenu"
7
+ >
8
+ <IconDotsHorizontalOutline class="w-6 h-6 text-lightPrimary dark:text-darkPrimary" />
9
+ </div>
10
+ <teleport to="body">
11
+ <div
12
+ v-if="showMenu"
13
+ ref="menuRef"
14
+ class="z-50 bg-white dark:bg-gray-900 rounded-md shadow-lg border dark:border-gray-700 py-1"
15
+ :style="menuStyles"
16
+ >
17
+ <template v-if="!resourceOptions?.baseActionsAsQuickIcons || (resourceOptions?.baseActionsAsQuickIcons && !resourceOptions?.baseActionsAsQuickIcons.includes('show'))">
18
+ <RouterLink
19
+ v-if="resourceOptions?.allowedActions?.show"
20
+ class="flex text-nowrap p-1 hover:bg-gray-100 dark:hover:bg-gray-800 w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300"
21
+ :to="{
22
+ name: 'resource-show',
23
+ params: {
24
+ resourceId: props.resourceId,
25
+ primaryKey: record._primaryKeyValue,
26
+ }
27
+ }"
28
+
29
+ >
30
+ <IconEyeSolid class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"/>
31
+ {{ $t('Show item') }}
32
+ </RouterLink>
33
+ </template>
34
+ <template v-if="!resourceOptions?.baseActionsAsQuickIcons || (resourceOptions?.baseActionsAsQuickIcons && !resourceOptions?.baseActionsAsQuickIcons.includes('edit'))">
35
+ <RouterLink
36
+ v-if="resourceOptions?.allowedActions?.edit"
37
+ class="flex text-nowrap p-1 hover:bg-gray-100 dark:hover:bg-gray-800 w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300"
38
+ :to="{
39
+ name: 'resource-edit',
40
+ params: {
41
+ resourceId: props.resourceId,
42
+ primaryKey: record._primaryKeyValue,
43
+ }
44
+ }"
45
+ >
46
+ <IconPenSolid class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"/>
47
+ {{ $t('Edit item') }}
48
+ </RouterLink>
49
+ </template>
50
+ <template v-if="!resourceOptions?.baseActionsAsQuickIcons || (resourceOptions?.baseActionsAsQuickIcons && !resourceOptions?.baseActionsAsQuickIcons.includes('delete'))">
51
+ <button
52
+ v-if="resourceOptions?.allowedActions?.delete"
53
+ class="flex text-nowrap p-1 hover:bg-gray-100 dark:hover:bg-gray-800 w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300"
54
+ @click="deleteRecord(record)"
55
+ >
56
+ <IconTrashBinSolid class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"/>
57
+ {{ $t('Delete item') }}
58
+ </button>
59
+ </template>
60
+ <div v-for="action in (resourceOptions.actions ?? []).filter(a => a.showIn?.listThreeDotsMenu)" :key="action.id" >
61
+ <button class="flex text-nowrap p-1 hover:bg-gray-100 dark:hover:bg-gray-800 w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300" @click="() => { startCustomAction(action.id, record); showMenu = false; }">
62
+ <component
63
+ :is="action.customComponent ? getCustomComponent(action.customComponent) : CallActionWrapper"
64
+ :meta="action.customComponent?.meta"
65
+ :row="record"
66
+ :resource="resource"
67
+ :adminUser="adminUser"
68
+ @callAction="(payload? : Object) => startCustomAction(action.id, record, payload)"
69
+ >
70
+ <component
71
+ v-if="action.icon"
72
+ :is="getIcon(action.icon)"
73
+ class="w-5 h-5 mr-2 text-lightPrimary dark:text-darkPrimary"
74
+ />
75
+ {{ $t(action.name) }}
76
+ </component>
77
+ </button>
78
+ </div>
79
+ <template v-if="customActionIconsThreeDotsMenuItems">
80
+ <component
81
+ v-for="c in customActionIconsThreeDotsMenuItems"
82
+ :is="getCustomComponent(c)"
83
+ :meta="c.meta"
84
+ :resource="coreStore.resource"
85
+ :adminUser="coreStore.adminUser"
86
+ :record="record"
87
+ :updateRecords="props.updateRecords"
88
+ />
89
+ </template>
90
+ </div>
91
+ </teleport>
92
+ </div>
93
+ </template>
94
+
95
+ <script lang="ts" setup>
96
+ import {
97
+ IconEyeSolid,
98
+ IconPenSolid,
99
+ IconTrashBinSolid,
100
+ IconDotsHorizontalOutline
101
+ } from '@iconify-prerendered/vue-flowbite';
102
+ import { onMounted, onBeforeUnmount, ref, nextTick, watch } from 'vue';
103
+ import { getIcon, getCustomComponent } from '@/utils';
104
+ import { useCoreStore } from '@/stores/core';
105
+ import CallActionWrapper from '@/components/CallActionWrapper.vue'
106
+
107
+ const coreStore = useCoreStore();
108
+ const showMenu = ref(false);
109
+ const triggerRef = ref<HTMLElement | null>(null);
110
+ const menuRef = ref<HTMLElement | null>(null);
111
+ const menuStyles = ref<Record<string, string>>({});
112
+
113
+ const props = defineProps<{
114
+ resourceOptions: any;
115
+ record: any;
116
+ customActionIconsThreeDotsMenuItems: any[];
117
+ resourceId: string;
118
+ deleteRecord: (record: any) => void;
119
+ updateRecords: () => void;
120
+ startCustomAction: (actionId: string, record: any) => void;
121
+ }>();
122
+
123
+ onMounted(() => {
124
+ window.addEventListener('scroll', handleScrollOrResize, true);
125
+ window.addEventListener('resize', handleScrollOrResize);
126
+ document.addEventListener('click', handleOutsideClick, true);
127
+ });
128
+
129
+ onBeforeUnmount(() => {
130
+ window.removeEventListener('scroll', handleScrollOrResize, true);
131
+ window.removeEventListener('resize', handleScrollOrResize);
132
+ document.removeEventListener('click', handleOutsideClick, true);
133
+ });
134
+
135
+ watch(showMenu, async (isOpen) => {
136
+ if (isOpen) {
137
+ await nextTick();
138
+ // First pass: after DOM mount
139
+ updateMenuPosition();
140
+ // Second pass: after layout/paint to catch width changes (fonts/icons)
141
+ requestAnimationFrame(() => {
142
+ updateMenuPosition();
143
+ // Final safety: one micro-delay retry if width was still 0
144
+ setTimeout(() => updateMenuPosition(), 0);
145
+ });
146
+ }
147
+ });
148
+
149
+ function toggleMenu() {
150
+ if (!showMenu.value) {
151
+ // Provisional position to avoid flashing at left:0 on first open
152
+ const el = triggerRef.value;
153
+ if (el) {
154
+ const rect = el.getBoundingClientRect();
155
+ const gap = 8;
156
+ menuStyles.value = {
157
+ position: 'fixed',
158
+ top: `${Math.round(rect.bottom)}px`,
159
+ left: `${Math.round(rect.left)}px`,
160
+ };
161
+ }
162
+ }
163
+ showMenu.value = !showMenu.value;
164
+ }
165
+
166
+ function updateMenuPosition() {
167
+ const el = triggerRef.value;
168
+ if (!el) return;
169
+ const rect = el.getBoundingClientRect();
170
+ const margin = 8; // gap around the trigger/menu
171
+ const menuEl = menuRef.value;
172
+ // Measure current menu size to align and decide flipping
173
+ let menuWidth = rect.width; // fallback to trigger width
174
+ let menuHeight = 0;
175
+ if (menuEl) {
176
+ const menuRect = menuEl.getBoundingClientRect();
177
+ // Prefer bounding rect; fallback to offset/scroll width if needed
178
+ const measuredW = menuRect.width || menuEl.offsetWidth || menuEl.scrollWidth;
179
+ if (measuredW > 0) menuWidth = measuredW;
180
+ const measuredH = menuRect.height || menuEl.offsetHeight || menuEl.scrollHeight;
181
+ if (measuredH > 0) menuHeight = measuredH;
182
+ }
183
+ // Right-align: right edge of menu == right edge of trigger
184
+ let left = rect.right - menuWidth;
185
+ // Clamp within viewport with small margin so it doesn't render off-screen
186
+ const minLeft = margin;
187
+ const maxLeft = Math.max(minLeft, window.innerWidth - margin - menuWidth);
188
+ left = Math.min(Math.max(left, minLeft), maxLeft);
189
+
190
+ // Determine whether to place above or below based on available space
191
+ const spaceBelow = window.innerHeight - rect.bottom - margin;
192
+ const spaceAbove = rect.top - margin;
193
+ const maxMenuHeight = Math.max(0, window.innerHeight - 2 * margin);
194
+
195
+ let top: number;
196
+ if (menuHeight === 0) {
197
+ // Unknown height yet (first pass). Prefer placing below; a subsequent pass will correct if needed.
198
+ top = rect.bottom + margin;
199
+ } else if (menuHeight <= spaceBelow) {
200
+ // Enough space below
201
+ top = rect.bottom + margin;
202
+ } else if (menuHeight <= spaceAbove) {
203
+ // Not enough below but enough above -> flip
204
+ top = rect.top - margin - menuHeight;
205
+ } else {
206
+ // Not enough space on either side: pick the side with more room and clamp within viewport
207
+ if (spaceBelow >= spaceAbove) {
208
+ top = Math.min(rect.bottom + margin, window.innerHeight - margin - menuHeight);
209
+ } else {
210
+ top = Math.max(margin, rect.top - margin - menuHeight);
211
+ }
212
+ }
213
+
214
+ menuStyles.value = {
215
+ position: 'fixed',
216
+ top: `${Math.round(top)}px`,
217
+ left: `${Math.round(left)}px`,
218
+ maxHeight: `${Math.round(maxMenuHeight)}px`,
219
+ overflowY: 'auto',
220
+ };
221
+ }
222
+
223
+ function handleScrollOrResize() {
224
+ showMenu.value = false;
225
+ }
226
+
227
+ function handleOutsideClick(e: MouseEvent) {
228
+ const target = e.target as Node | null;
229
+ if (!target) return;
230
+ if (menuRef.value?.contains(target)) return;
231
+ if (triggerRef.value?.contains(target)) return;
232
+ showMenu.value = false;
233
+ }
234
+
235
+ </script>
@@ -206,7 +206,7 @@ const setCurrentValue = (key: any, value: any, index = null) => {
206
206
  } else if (index === currentValues.value[key].length) {
207
207
  currentValues.value[key].push(null);
208
208
  } else {
209
- if (['integer', 'float', 'decimal'].includes(col.isArray.itemType)) {
209
+ if (['integer', 'float'].includes(col.isArray.itemType)) {
210
210
  if (value || value === 0) {
211
211
  currentValues.value[key][index] = +value;
212
212
  } else {
@@ -215,12 +215,12 @@ const setCurrentValue = (key: any, value: any, index = null) => {
215
215
  } else {
216
216
  currentValues.value[key][index] = value;
217
217
  }
218
- if (col?.isArray && ['text', 'richtext', 'string'].includes(col.isArray.itemType) && col.enforceLowerCase) {
218
+ if (col?.isArray && ['text', 'richtext', 'string', 'decimal'].includes(col.isArray.itemType) && col.enforceLowerCase) {
219
219
  currentValues.value[key][index] = currentValues.value[key][index].toLowerCase();
220
220
  }
221
221
  }
222
222
  } else {
223
- if (col?.type && ['integer', 'float', 'decimal'].includes(col.type)) {
223
+ if (col?.type && ['integer', 'float'].includes(col.type)) {
224
224
  if (value || value === 0) {
225
225
  currentValues.value[key] = +value;
226
226
  } else {
@@ -229,7 +229,7 @@ const setCurrentValue = (key: any, value: any, index = null) => {
229
229
  } else {
230
230
  currentValues.value[key] = value;
231
231
  }
232
- if (col?.type && ['text', 'richtext', 'string'].includes(col?.type) && col.enforceLowerCase) {
232
+ if (col?.type && ['text', 'richtext', 'string', 'decimal'].includes(col?.type) && col.enforceLowerCase) {
233
233
  currentValues.value[key] = currentValues.value[key].toLowerCase();
234
234
  }
235
235
  }