Skip to content

Commit 45d6b9b

Browse files
authored
Merge pull request #77 from devforth/add-is-array-field
feat: add support for array columns
2 parents 4002e05 + 75b4f51 commit 45d6b9b

File tree

12 files changed

+529
-209
lines changed

12 files changed

+529
-209
lines changed

adminforth/index.ts

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,55 @@ class AdminForth implements IAdminForth {
230230
});
231231
}
232232

233+
validateRecordValues(resource: AdminForthResource, record: any): any {
234+
// check if record with validation is valid
235+
for (const column of resource.columns.filter((col) => col.name in record && col.validation)) {
236+
let error = null;
237+
if (column.isArray?.enabled) {
238+
error = record[column.name].reduce((err, item) => {
239+
return err || AdminForth.Utils.applyRegexValidation(item, column.validation);
240+
}, null);
241+
} else {
242+
error = AdminForth.Utils.applyRegexValidation(record[column.name], column.validation);
243+
}
244+
if (error) {
245+
return error;
246+
}
247+
}
248+
249+
// check if record with minValue or maxValue is within limits
250+
for (const column of resource.columns.filter((col) => col.name in record
251+
&& ['integer', 'decimal', 'float'].includes(col.isArray?.enabled ? col.isArray.itemType : col.type)
252+
&& (col.minValue !== undefined || col.maxValue !== undefined))) {
253+
if (column.isArray?.enabled) {
254+
const error = record[column.name].reduce((err, item) => {
255+
if (err) return err;
256+
257+
if (column.minValue !== undefined && item < column.minValue) {
258+
return `Value in "${column.name}" must be greater than ${column.minValue}`;
259+
}
260+
if (column.maxValue !== undefined && item > column.maxValue) {
261+
return `Value in "${column.name}" must be less than ${column.maxValue}`;
262+
}
263+
264+
return null;
265+
}, null);
266+
if (error) {
267+
return error;
268+
}
269+
} else {
270+
if (column.minValue !== undefined && record[column.name] < column.minValue) {
271+
return `Value in "${column.name}" must be greater than ${column.minValue}`;
272+
}
273+
if (column.maxValue !== undefined && record[column.name] > column.maxValue) {
274+
return `Value in "${column.name}" must be less than ${column.maxValue}`;
275+
}
276+
}
277+
}
278+
279+
return null;
280+
}
281+
233282

234283
async discoverDatabases() {
235284
this.statuses.dbDiscover = 'running';
@@ -350,24 +399,9 @@ class AdminForth implements IAdminForth {
350399
{ resource: AdminForthResource, record: any, adminUser: AdminUser, extra?: HttpExtra }
351400
): Promise<{ error?: string, createdRecord?: any }> {
352401

353-
// check if record with validation is valid
354-
for (const column of resource.columns.filter((col) => col.name in record && col.validation)) {
355-
const error = AdminForth.Utils.applyRegexValidation(record[column.name], column.validation);
356-
if (error) {
357-
return { error };
358-
}
359-
}
360-
361-
// check if record with minValue or maxValue is within limits
362-
for (const column of resource.columns.filter((col) => col.name in record
363-
&& ['integer', 'decimal', 'float'].includes(col.type)
364-
&& (col.minValue !== undefined || col.maxValue !== undefined))) {
365-
if (column.minValue !== undefined && record[column.name] < column.minValue) {
366-
return { error: `Value in "${column.name}" must be greater than ${column.minValue}` };
367-
}
368-
if (column.maxValue !== undefined && record[column.name] > column.maxValue) {
369-
return { error: `Value in "${column.name}" must be less than ${column.maxValue}` };
370-
}
402+
const err = this.validateRecordValues(resource, record);
403+
if (err) {
404+
return { error: err };
371405
}
372406

373407
// execute hook if needed
@@ -435,24 +469,9 @@ class AdminForth implements IAdminForth {
435469
{ resource: AdminForthResource, recordId: any, record: any, oldRecord: any, adminUser: AdminUser, extra?: HttpExtra }
436470
): Promise<{ error?: string }> {
437471

438-
// check if record with validation is valid
439-
for (const column of resource.columns.filter((col) => col.name in record && col.validation)) {
440-
const error = AdminForth.Utils.applyRegexValidation(record[column.name], column.validation);
441-
if (error) {
442-
return { error };
443-
}
444-
}
445-
446-
// check if record with minValue or maxValue is within limits
447-
for (const column of resource.columns.filter((col) => col.name in record
448-
&& ['integer', 'decimal', 'float'].includes(col.type)
449-
&& (col.minValue !== undefined || col.maxValue !== undefined))) {
450-
if (column.minValue !== undefined && record[column.name] < column.minValue) {
451-
return { error: `Value in "${column.name}" must be greater than ${column.minValue}` };
452-
}
453-
if (column.maxValue !== undefined && record[column.name] > column.maxValue) {
454-
return { error: `Value in "${column.name}" must be less than ${column.maxValue}` };
455-
}
472+
const err = this.validateRecordValues(resource, record);
473+
if (err) {
474+
return { error: err };
456475
}
457476

458477
// remove editReadonly columns from record

adminforth/modules/configValidator.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
AllowedActionsEnum,
2323
AdminForthComponentDeclaration ,
2424
AdminForthResourcePages,
25+
AdminForthDataTypes,
2526
} from "../types/Common.js";
2627
import AdminForth from "adminforth";
2728
import { AdminForthConfigMenuItem } from "adminforth";
@@ -400,6 +401,35 @@ export default class ConfigValidator implements IConfigValidator {
400401

401402
col.editingNote = typeof inCol.editingNote === 'string' ? { create: inCol.editingNote, edit: inCol.editingNote } : inCol.editingNote;
402403

404+
if (col.isArray !== undefined) {
405+
if (typeof col.isArray !== 'object') {
406+
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray must be an object`);
407+
} else if (col.isArray.enabled) {
408+
if (col.primaryKey) {
409+
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray cannot be used for a primary key columns`);
410+
}
411+
if (col.masked) {
412+
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray cannot be used for a masked column`);
413+
}
414+
if (col.foreignResource) {
415+
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray cannot be used for a foreignResource column`);
416+
}
417+
418+
if (!col.type || col.type !== AdminForthDataTypes.JSON) {
419+
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray can be used only with column type JSON`);
420+
}
421+
422+
if (col.isArray.itemType === undefined) {
423+
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray must have itemType`);
424+
}
425+
if (col.isArray.itemType === AdminForthDataTypes.JSON) {
426+
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray itemType cannot be JSON`);
427+
}
428+
if (col.isArray.itemType === AdminForthDataTypes.RICHTEXT) {
429+
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray itemType cannot be RICHTEXT`);
430+
}
431+
}
432+
}
403433
if (col.foreignResource) {
404434

405435
if (!col.foreignResource.resourceId) {
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
<template>
2+
<div class="flex">
3+
<component
4+
v-if="column?.components?.[props.source]?.file"
5+
:is="getCustomComponent(column.components[props.source])"
6+
:column="column"
7+
:value="value"
8+
@update:value="$emit('update:modelValue', $event)"
9+
:meta="column.components[props.source].meta"
10+
:record="currentValues"
11+
@update:inValidity="$emit('update:inValidity', $event)"
12+
@update:emptiness="$emit('update:emptiness', $event)"
13+
/>
14+
<Select
15+
v-else-if="column.foreignResource"
16+
ref="input"
17+
class="w-full"
18+
:options="columnOptions[column.name] || []"
19+
:placeholder = "columnOptions[column.name]?.length ?$t('Select...'): $t('There are no options available')"
20+
:modelValue="value"
21+
:readonly="column.editReadonly && source === 'edit'"
22+
@update:modelValue="$emit('update:modelValue', $event)"
23+
/>
24+
<Select
25+
v-else-if="column.enum"
26+
ref="input"
27+
class="w-full"
28+
:options="column.enum"
29+
:modelValue="value"
30+
:readonly="column.editReadonly && source === 'edit'"
31+
@update:modelValue="$emit('update:modelValue', $event)"
32+
/>
33+
<Select
34+
v-else-if="(type || column.type) === 'boolean'"
35+
ref="input"
36+
class="w-full"
37+
:options="getBooleanOptions(column)"
38+
:modelValue="value"
39+
:readonly="column.editReadonly && source === 'edit'"
40+
@update:modelValue="$emit('update:modelValue', $event)"
41+
/>
42+
<input
43+
v-else-if="['integer'].includes(type || column.type)"
44+
ref="input"
45+
type="number"
46+
step="1"
47+
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-40 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-lightPrimary focus:border-lightPrimary dark:focus:ring-darkPrimary dark:focus:border-darkPrimary"
48+
placeholder="0"
49+
:readonly="column.editReadonly && source === 'edit'"
50+
:value="value"
51+
@input="$emit('update:modelValue', $event.target.value)"
52+
>
53+
<CustomDatePicker
54+
v-else-if="['datetime'].includes(type || column.type)"
55+
ref="input"
56+
:column="column"
57+
:valueStart="value"
58+
auto-hide
59+
@update:valueStart="$emit('update:modelValue', $event)"
60+
:readonly="column.editReadonly && source === 'edit'"
61+
/>
62+
<input
63+
v-else-if="['decimal', 'float'].includes(type || column.type)"
64+
ref="input"
65+
type="number"
66+
step="0.1"
67+
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-40 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-lightPrimary focus:border-lightPrimary dark:focus:ring-darkPrimary dark:focus:border-darkPrimary"
68+
placeholder="0.0"
69+
:value="value"
70+
@input="$emit('update:modelValue', $event.target.value)"
71+
:readonly="column.editReadonly && source === 'edit'"
72+
/>
73+
<textarea
74+
v-else-if="['text', 'richtext'].includes(type || column.type)"
75+
ref="input"
76+
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-lightPrimary focus:border-lightPrimary dark:focus:ring-darkPrimary dark:focus:border-darkPrimary"
77+
:placeholder="$t('Text')"
78+
:value="value"
79+
@input="$emit('update:modelValue', $event.target.value)"
80+
:readonly="column.editReadonly && source === 'edit'"
81+
/>
82+
<textarea
83+
v-else-if="['json'].includes(type || column.type)"
84+
ref="input"
85+
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-lightPrimary focus:border-lightPrimary dark:focus:ring-darkPrimary dark:focus:border-darkPrimary"
86+
:placeholder="$t('Text')"
87+
:value="value"
88+
@input="$emit('update:modelValue', $event.target.value)"
89+
/>
90+
<input
91+
v-else
92+
ref="input"
93+
:type="!column.masked || unmasked[column.name] ? 'text' : 'password'"
94+
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-lightPrimary focus:border-lightPrimary dark:focus:ring-darkPrimary dark:focus:border-darkPrimary"
95+
:placeholder="$t('Text')"
96+
:value="value"
97+
@input="$emit('update:modelValue', $event.target.value)"
98+
autocomplete="false"
99+
data-lpignore="true"
100+
readonly
101+
@focus="onFocusHandler($event, column, source)"
102+
>
103+
104+
<button
105+
v-if="deletable"
106+
type="button"
107+
class="h-6 inset-y-2 right-0 flex items-center px-2 pt-4 z-index-100 focus:outline-none"
108+
@click="$emit('delete')"
109+
>
110+
<IconTrashBinSolid class="w-6 h-6 text-gray-400"/>
111+
</button>
112+
<button
113+
v-else-if="column.masked"
114+
type="button"
115+
@click="$emit('update:unmasked')"
116+
class="h-6 inset-y-2 right-0 flex items-center px-2 pt-4 z-index-100 focus:outline-none"
117+
>
118+
<IconEyeSolid class="w-6 h-6 text-gray-400" v-if="!unmasked[column.name]"/>
119+
<IconEyeSlashSolid class="w-6 h-6 text-gray-400" v-else />
120+
</button>
121+
</div>
122+
</template>
123+
124+
<script setup lang="ts">
125+
import { IconEyeSlashSolid, IconEyeSolid, IconTrashBinSolid } from '@iconify-prerendered/vue-flowbite';
126+
import CustomDatePicker from "@/components/CustomDatePicker.vue";
127+
import Select from '@/afcl/Select.vue';
128+
import { ref } from 'vue';
129+
import { getCustomComponent } from '@/utils';
130+
import { useI18n } from 'vue-i18n';
131+
132+
const { t } = useI18n();
133+
134+
const props = defineProps<{
135+
source: 'create' | 'edit',
136+
column: any,
137+
type: string,
138+
value: any,
139+
currentValues: any,
140+
mode: string,
141+
columnOptions: any,
142+
unmasked: any,
143+
deletable: boolean,
144+
}>();
145+
146+
const input = ref(null);
147+
148+
const getBooleanOptions = (column: any) => {
149+
const options: Array<{ label: string; value: boolean | null }> = [
150+
{ label: t('Yes'), value: true },
151+
{ label: t('No'), value: false },
152+
];
153+
if (!column.required[props.mode]) {
154+
options.push({ label: t('Unset'), value: null });
155+
}
156+
return options;
157+
};
158+
159+
function onFocusHandler(event:FocusEvent, column:any, source:string, ) {
160+
const focusedInput = event.target as HTMLInputElement;
161+
if(!focusedInput) return;
162+
if (column.editReadonly && source === 'edit') return;
163+
else {
164+
focusedInput.removeAttribute('readonly');
165+
}
166+
}
167+
168+
function focus() {
169+
if (input.value?.focus) input.value?.focus();
170+
}
171+
172+
defineExpose({
173+
focus,
174+
});
175+
</script>

adminforth/spa/src/components/CustomDatePicker.vue

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
<div>
33
<div class="grid w-40 gap-4 mb-2">
44
<div>
5-
<label for="start-time" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ label }}</label>
5+
<label v-if="label" for="start-time" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ label }}</label>
66

77
<div class="relative">
88
<div class="absolute inset-y-0 end-0 top-0 flex items-center pe-3.5 pointer-events-none">
99
<IconCalendar class="w-4 h-4 text-gray-500 dark:text-gray-400"/>
1010
</div>
1111

1212
<input ref="datepickerStartEl" type="text"
13-
class="bg-gray-50 border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
13+
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-lightPrimary focus:border-lightPrimary dark:focus:ring-darkPrimary dark:focus:border-darkPrimary"
1414
:placeholder="$t('Select date')" :disabled="readonly" />
1515

1616
</div>
@@ -26,7 +26,7 @@
2626
</div>
2727

2828
<input v-model="startTime" type="time" id="start-time" onfocus="this.showPicker()" onclick="this.showPicker()" step="1"
29-
class="bg-gray-50 border leading-none border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
29+
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white focus:ring-lightPrimary focus:border-lightPrimary dark:focus:ring-darkPrimary dark:focus:border-darkPrimary"
3030
value="00:00" :disabled="readonly" required/>
3131
</div>
3232
</div>
@@ -177,4 +177,12 @@ onBeforeUnmount(() => {
177177
removeChangeDateListener();
178178
destroyDatepickerElement();
179179
});
180+
181+
function focus() {
182+
datepickerStartEl.value?.focus();
183+
}
184+
185+
defineExpose({
186+
focus,
187+
});
180188
</script>

0 commit comments

Comments
 (0)