adminforth 2.17.0-next.8 → 2.17.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/commands/callTsProxy.js +2 -1
- package/commands/createApp/templates/adminuser.ts.hbs +2 -1
- package/commands/createApp/templates/index.ts.hbs +3 -2
- package/commands/createCustomComponent/main.js +0 -3
- package/commands/createCustomComponent/templates/customCrud/afterBreadcrumbs.vue.hbs +4 -2
- package/commands/createCustomComponent/templates/customCrud/beforeActionButtons.vue.hbs +3 -2
- package/commands/createCustomComponent/templates/customCrud/beforeBreadcrumbs.vue.hbs +4 -2
- package/commands/createCustomComponent/templates/customCrud/bottom.vue.hbs +4 -2
- package/commands/createCustomComponent/templates/customCrud/threeDotsDropdownItems.vue.hbs +4 -2
- package/commands/createPlugin/templates/index.ts.hbs +4 -0
- package/dist/auth.d.ts +2 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +17 -10
- package/dist/auth.js.map +1 -1
- package/dist/basePlugin.d.ts +1 -0
- package/dist/basePlugin.d.ts.map +1 -1
- package/dist/basePlugin.js +6 -2
- package/dist/basePlugin.js.map +1 -1
- package/dist/dataConnectors/baseConnector.d.ts +1 -0
- package/dist/dataConnectors/baseConnector.d.ts.map +1 -1
- package/dist/dataConnectors/baseConnector.js +100 -14
- package/dist/dataConnectors/baseConnector.js.map +1 -1
- package/dist/dataConnectors/clickhouse.d.ts +2 -0
- package/dist/dataConnectors/clickhouse.d.ts.map +1 -1
- package/dist/dataConnectors/clickhouse.js +15 -4
- package/dist/dataConnectors/clickhouse.js.map +1 -1
- package/dist/dataConnectors/mongo.d.ts +8 -1
- package/dist/dataConnectors/mongo.d.ts.map +1 -1
- package/dist/dataConnectors/mongo.js +72 -28
- package/dist/dataConnectors/mongo.js.map +1 -1
- package/dist/dataConnectors/mysql.d.ts +2 -0
- package/dist/dataConnectors/mysql.d.ts.map +1 -1
- package/dist/dataConnectors/mysql.js +22 -23
- package/dist/dataConnectors/mysql.js.map +1 -1
- package/dist/dataConnectors/postgres.d.ts +2 -0
- package/dist/dataConnectors/postgres.d.ts.map +1 -1
- package/dist/dataConnectors/postgres.js +23 -26
- package/dist/dataConnectors/postgres.js.map +1 -1
- package/dist/dataConnectors/sqlite.d.ts +2 -0
- package/dist/dataConnectors/sqlite.d.ts.map +1 -1
- package/dist/dataConnectors/sqlite.js +19 -19
- package/dist/dataConnectors/sqlite.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +60 -54
- package/dist/index.js.map +1 -1
- package/dist/modules/codeInjector.d.ts.map +1 -1
- package/dist/modules/codeInjector.js +45 -63
- package/dist/modules/codeInjector.js.map +1 -1
- package/dist/modules/configValidator.d.ts.map +1 -1
- package/dist/modules/configValidator.js +14 -9
- package/dist/modules/configValidator.js.map +1 -1
- package/dist/modules/logger.d.ts +5 -0
- package/dist/modules/logger.d.ts.map +1 -0
- package/dist/modules/logger.js +16 -0
- package/dist/modules/logger.js.map +1 -0
- package/dist/modules/restApi.d.ts.map +1 -1
- package/dist/modules/restApi.js +21 -23
- package/dist/modules/restApi.js.map +1 -1
- package/dist/modules/socketBroker.d.ts.map +1 -1
- package/dist/modules/socketBroker.js +6 -5
- package/dist/modules/socketBroker.js.map +1 -1
- package/dist/modules/styles.js +1 -1
- package/dist/servers/express.d.ts.map +1 -1
- package/dist/servers/express.js +11 -11
- package/dist/servers/express.js.map +1 -1
- package/dist/spa/src/App.vue +6 -3
- package/dist/spa/src/adminforth.ts +60 -1
- package/dist/spa/src/afcl/DatePicker.vue +0 -1
- package/dist/spa/src/afcl/Dropzone.vue +6 -4
- package/dist/spa/src/afcl/Tooltip.vue +38 -4
- package/dist/spa/src/components/ColumnValueInput.vue +14 -1
- package/dist/spa/src/components/CustomDateRangePicker.vue +0 -2
- package/dist/spa/src/components/CustomRangePicker.vue +9 -6
- package/dist/spa/src/components/Filters.vue +4 -4
- package/dist/spa/src/components/ListActionsThreeDots.vue +235 -0
- package/dist/spa/src/components/ResourceForm.vue +4 -4
- package/dist/spa/src/components/ResourceListTable.vue +30 -16
- package/dist/spa/src/components/ResourceListTableVirtual.vue +34 -18
- package/dist/spa/src/components/Sidebar.vue +4 -2
- package/dist/spa/src/components/ThreeDotsMenu.vue +35 -20
- package/dist/spa/src/composables/useFrontendApi.ts +8 -4
- package/dist/spa/src/renderers/CompactField.vue +3 -2
- package/dist/spa/src/renderers/CompactUUID.vue +3 -2
- package/dist/spa/src/stores/core.ts +3 -2
- package/dist/spa/src/types/Back.ts +34 -11
- package/dist/spa/src/types/Common.ts +7 -14
- package/dist/spa/src/types/FrontendAPI.ts +25 -10
- package/dist/spa/src/views/CreateView.vue +23 -31
- package/dist/spa/src/views/EditView.vue +27 -31
- package/dist/spa/src/views/ListView.vue +20 -10
- package/dist/spa/src/views/SettingsView.vue +3 -2
- package/dist/spa/src/views/ShowView.vue +7 -6
- package/dist/types/Back.d.ts +27 -5
- package/dist/types/Back.d.ts.map +1 -1
- package/dist/types/Back.js +6 -0
- package/dist/types/Back.js.map +1 -1
- package/dist/types/Common.d.ts +8 -15
- package/dist/types/Common.d.ts.map +1 -1
- package/dist/types/Common.js +2 -0
- package/dist/types/Common.js.map +1 -1
- package/dist/types/FrontendAPI.d.ts +32 -10
- package/dist/types/FrontendAPI.d.ts.map +1 -1
- package/dist/types/FrontendAPI.js.map +1 -1
- package/package.json +4 -1
- 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;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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="
|
|
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
|
-
|
|
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'
|
|
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'
|
|
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
|
}
|