Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 55 additions & 36 deletions adminforth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,55 @@ class AdminForth implements IAdminForth {
});
}

validateRecordValues(resource: AdminForthResource, record: any): any {
// check if record with validation is valid
for (const column of resource.columns.filter((col) => col.name in record && col.validation)) {
let error = null;
if (column.isArray?.enabled) {
error = record[column.name].reduce((err, item) => {
return err || AdminForth.Utils.applyRegexValidation(item, column.validation);
}, null);
} else {
error = AdminForth.Utils.applyRegexValidation(record[column.name], column.validation);
}
if (error) {
return error;
}
}

// check if record with minValue or maxValue is within limits
for (const column of resource.columns.filter((col) => col.name in record
&& ['integer', 'decimal', 'float'].includes(col.isArray?.enabled ? col.isArray.itemType : col.type)
&& (col.minValue !== undefined || col.maxValue !== undefined))) {
if (column.isArray?.enabled) {
const error = record[column.name].reduce((err, item) => {
if (err) return err;

if (column.minValue !== undefined && item < column.minValue) {
return `Value in "${column.name}" must be greater than ${column.minValue}`;
}
if (column.maxValue !== undefined && item > column.maxValue) {
return `Value in "${column.name}" must be less than ${column.maxValue}`;
}

return null;
}, null);
if (error) {
return error;
}
} else {
if (column.minValue !== undefined && record[column.name] < column.minValue) {
return `Value in "${column.name}" must be greater than ${column.minValue}`;
}
if (column.maxValue !== undefined && record[column.name] > column.maxValue) {
return `Value in "${column.name}" must be less than ${column.maxValue}`;
}
}
}

return null;
}


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

// check if record with validation is valid
for (const column of resource.columns.filter((col) => col.name in record && col.validation)) {
const error = AdminForth.Utils.applyRegexValidation(record[column.name], column.validation);
if (error) {
return { error };
}
}

// check if record with minValue or maxValue is within limits
for (const column of resource.columns.filter((col) => col.name in record
&& ['integer', 'decimal', 'float'].includes(col.type)
&& (col.minValue !== undefined || col.maxValue !== undefined))) {
if (column.minValue !== undefined && record[column.name] < column.minValue) {
return { error: `Value in "${column.name}" must be greater than ${column.minValue}` };
}
if (column.maxValue !== undefined && record[column.name] > column.maxValue) {
return { error: `Value in "${column.name}" must be less than ${column.maxValue}` };
}
const err = this.validateRecordValues(resource, record);
if (err) {
return { error: err };
}

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

// check if record with validation is valid
for (const column of resource.columns.filter((col) => col.name in record && col.validation)) {
const error = AdminForth.Utils.applyRegexValidation(record[column.name], column.validation);
if (error) {
return { error };
}
}

// check if record with minValue or maxValue is within limits
for (const column of resource.columns.filter((col) => col.name in record
&& ['integer', 'decimal', 'float'].includes(col.type)
&& (col.minValue !== undefined || col.maxValue !== undefined))) {
if (column.minValue !== undefined && record[column.name] < column.minValue) {
return { error: `Value in "${column.name}" must be greater than ${column.minValue}` };
}
if (column.maxValue !== undefined && record[column.name] > column.maxValue) {
return { error: `Value in "${column.name}" must be less than ${column.maxValue}` };
}
const err = this.validateRecordValues(resource, record);
if (err) {
return { error: err };
}

// remove editReadonly columns from record
Expand Down
30 changes: 30 additions & 0 deletions adminforth/modules/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
AllowedActionsEnum,
AdminForthComponentDeclaration ,
AdminForthResourcePages,
AdminForthDataTypes,
} from "../types/Common.js";
import AdminForth from "adminforth";
import { AdminForthConfigMenuItem } from "adminforth";
Expand Down Expand Up @@ -400,6 +401,35 @@ export default class ConfigValidator implements IConfigValidator {

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

if (col.isArray !== undefined) {
if (typeof col.isArray !== 'object') {
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray must be an object`);
} else if (col.isArray.enabled) {
if (col.primaryKey) {
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray cannot be used for a primary key columns`);
}
if (col.masked) {
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray cannot be used for a masked column`);
}
if (col.foreignResource) {
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray cannot be used for a foreignResource column`);
}

if (!col.type || col.type !== AdminForthDataTypes.JSON) {
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray can be used only with column type JSON`);
}

if (col.isArray.itemType === undefined) {
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray must have itemType`);
}
if (col.isArray.itemType === AdminForthDataTypes.JSON) {
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray itemType cannot be JSON`);
}
if (col.isArray.itemType === AdminForthDataTypes.RICHTEXT) {
errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray itemType cannot be RICHTEXT`);
}
}
}
if (col.foreignResource) {

if (!col.foreignResource.resourceId) {
Expand Down
175 changes: 175 additions & 0 deletions adminforth/spa/src/components/ColumnValueInput.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
<template>
<div class="flex">
<component
v-if="column?.components?.[props.source]?.file"
:is="getCustomComponent(column.components[props.source])"
:column="column"
:value="value"
@update:value="$emit('update:modelValue', $event)"
:meta="column.components[props.source].meta"
:record="currentValues"
@update:inValidity="$emit('update:inValidity', $event)"
@update:emptiness="$emit('update:emptiness', $event)"
/>
<Select
v-else-if="column.foreignResource"
ref="input"
class="w-full"
:options="columnOptions[column.name] || []"
:placeholder = "columnOptions[column.name]?.length ?$t('Select...'): $t('There are no options available')"
:modelValue="value"
:readonly="column.editReadonly && source === 'edit'"
@update:modelValue="$emit('update:modelValue', $event)"
/>
<Select
v-else-if="column.enum"
ref="input"
class="w-full"
:options="column.enum"
:modelValue="value"
:readonly="column.editReadonly && source === 'edit'"
@update:modelValue="$emit('update:modelValue', $event)"
/>
<Select
v-else-if="(type || column.type) === 'boolean'"
ref="input"
class="w-full"
:options="getBooleanOptions(column)"
:modelValue="value"
:readonly="column.editReadonly && source === 'edit'"
@update:modelValue="$emit('update:modelValue', $event)"
/>
<input
v-else-if="['integer'].includes(type || column.type)"
ref="input"
type="number"
step="1"
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"
placeholder="0"
:readonly="column.editReadonly && source === 'edit'"
:value="value"
@input="$emit('update:modelValue', $event.target.value)"
>
<CustomDatePicker
v-else-if="['datetime'].includes(type || column.type)"
ref="input"
:column="column"
:valueStart="value"
auto-hide
@update:valueStart="$emit('update:modelValue', $event)"
:readonly="column.editReadonly && source === 'edit'"
/>
<input
v-else-if="['decimal', 'float'].includes(type || column.type)"
ref="input"
type="number"
step="0.1"
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"
placeholder="0.0"
:value="value"
@input="$emit('update:modelValue', $event.target.value)"
:readonly="column.editReadonly && source === 'edit'"
/>
<textarea
v-else-if="['text', 'richtext'].includes(type || column.type)"
ref="input"
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"
:placeholder="$t('Text')"
:value="value"
@input="$emit('update:modelValue', $event.target.value)"
:readonly="column.editReadonly && source === 'edit'"
/>
<textarea
v-else-if="['json'].includes(type || column.type)"
ref="input"
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"
:placeholder="$t('Text')"
:value="value"
@input="$emit('update:modelValue', $event.target.value)"
/>
<input
v-else
ref="input"
:type="!column.masked || unmasked[column.name] ? 'text' : 'password'"
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"
:placeholder="$t('Text')"
:value="value"
@input="$emit('update:modelValue', $event.target.value)"
autocomplete="false"
data-lpignore="true"
readonly
@focus="onFocusHandler($event, column, source)"
>

<button
v-if="deletable"
type="button"
class="h-6 inset-y-2 right-0 flex items-center px-2 pt-4 z-index-100 focus:outline-none"
@click="$emit('delete')"
>
<IconTrashBinSolid class="w-6 h-6 text-gray-400"/>
</button>
<button
v-else-if="column.masked"
type="button"
@click="$emit('update:unmasked')"
class="h-6 inset-y-2 right-0 flex items-center px-2 pt-4 z-index-100 focus:outline-none"
>
<IconEyeSolid class="w-6 h-6 text-gray-400" v-if="!unmasked[column.name]"/>
<IconEyeSlashSolid class="w-6 h-6 text-gray-400" v-else />
</button>
</div>
</template>

<script setup lang="ts">
import { IconEyeSlashSolid, IconEyeSolid, IconTrashBinSolid } from '@iconify-prerendered/vue-flowbite';
import CustomDatePicker from "@/components/CustomDatePicker.vue";
import Select from '@/afcl/Select.vue';
import { ref } from 'vue';
import { getCustomComponent } from '@/utils';
import { useI18n } from 'vue-i18n';

const { t } = useI18n();

const props = defineProps<{
source: 'create' | 'edit',
column: any,
type: string,
value: any,
currentValues: any,
mode: string,
columnOptions: any,
unmasked: any,
deletable: boolean,
}>();

const input = ref(null);

const getBooleanOptions = (column: any) => {
const options: Array<{ label: string; value: boolean | null }> = [
{ label: t('Yes'), value: true },
{ label: t('No'), value: false },
];
if (!column.required[props.mode]) {
options.push({ label: t('Unset'), value: null });
}
return options;
};

function onFocusHandler(event:FocusEvent, column:any, source:string, ) {
const focusedInput = event.target as HTMLInputElement;
if(!focusedInput) return;
if (column.editReadonly && source === 'edit') return;
else {
focusedInput.removeAttribute('readonly');
}
}

function focus() {
if (input.value?.focus) input.value?.focus();
}

defineExpose({
focus,
});
</script>
14 changes: 11 additions & 3 deletions adminforth/spa/src/components/CustomDatePicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
<div>
<div class="grid w-40 gap-4 mb-2">
<div>
<label for="start-time" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ label }}</label>
<label v-if="label" for="start-time" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">{{ label }}</label>

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

<input ref="datepickerStartEl" type="text"
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"
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"
:placeholder="$t('Select date')" :disabled="readonly" />

</div>
Expand All @@ -26,7 +26,7 @@
</div>

<input v-model="startTime" type="time" id="start-time" onfocus="this.showPicker()" onclick="this.showPicker()" step="1"
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"
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"
value="00:00" :disabled="readonly" required/>
</div>
</div>
Expand Down Expand Up @@ -177,4 +177,12 @@ onBeforeUnmount(() => {
removeChangeDateListener();
destroyDatepickerElement();
});

function focus() {
datepickerStartEl.value?.focus();
}

defineExpose({
focus,
});
</script>
Loading