mirror of https://github.com/nocodb/nocodb
Browse Source
* feat(nc-gui): custom validation setup * fix(nc-gui): custom validation table rounded issue * fix: add custom field validation type * fix(nc-gui): updated custom validator * feat(nc-gui): custom validation working state * fix(nc-gui): udpate default warning msg * chore(nc-gui): lint * fix(nc-gui): grayed out errors if input is focused * fix(nc-gui): input ring issue * fix(nc-gui): increase max height of validator select dropdown * fix(nc-gui): validator select dropdown item text color * fix(nc-gui): regex validation condition update * fix(nc-gui): add missing string validation types * fix(nc-gui): remove unwanted code * fix(nc-gui): move custom validation to ee * refacor(nc-gui): form view code * refactor(nc-gui): separate out formviewstore for ce & ee * fix(nc-gui): move all validations to another file * feat(nc-gui): add validation input component * feat(nc-gui): add time, month types * fix(nc-gui): add form field limit validations * fix(nc-gui): add limit link record validation * fix(nc-gui): add phonenumber & url validation type * feat(nc-gui): add email, url & phone number validators * fix(nc-gui): non working phone, email, url validation * chore(nc-giu): lint * feat(nc-gui): add attchment type validation * chore(nc-gui): lint * fix(nc-gui): add form field validation in shared form * fix(nc-gui): add form field validation in shared form oss * fix(nc-gui): oss validation conflict * fix(nc-gui): enter number validation function * fix(nc-gui): add config validators * fix(nc-gui): validation config error handling * fix(nc-gui): placeholder issue * fix(nc-gui): custom validation config error handling * fix(nc-gui): allow negative value validation * fix(nc-gui): add tooltip for required field switch * fix(nc-gui): refactor field validation from builder side * chore(nc-gui): lint * fix(nc-gui): update number validation logic * fix(nc-gui): rating field alignment issue * fix(nc-gui): small changes * fix(nc-gui): required field validation issue * fix(nc-gui): allow click on title to enable field config * feat(nc-gui): business email validation support * fix(nc-gui): add remove image btn in cell itself * fix(nc-gui): small changes * fix(nc-gui): survey form required field validation issue * fix(nc-gui): error field border issue * fix(nc-gui): currency validation input cell prefix issue * fix(nc-gui): remove console * chore(nc-gui): lint * fix: information text * fix(nc-gui): remove contains & doesn't contain option from phone number custom validation * fix(nc-gui): attachment merge conflict * fix(nc-gui): attachment cell expand btn size * fix(nc-gui): PR review changes * fix(nc-gui): lint * fix(nc-gui): updated form config heading text color * fix(nc-gui): small changes --------- Co-authored-by: Raju Udava <86527202+dstala@users.noreply.github.com>pull/8445/head
Ramesh Mane
7 months ago
committed by
GitHub
19 changed files with 935 additions and 565 deletions
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,3 @@ |
|||||||
|
<script setup lang="ts"></script> |
||||||
|
|
||||||
|
<template><div></div></template> |
@ -0,0 +1,116 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { UITypes, isSelectTypeCol } from 'nocodb-sdk' |
||||||
|
|
||||||
|
const { formState, activeField, updateColMeta, isRequired } = useFormViewStoreOrThrow() |
||||||
|
|
||||||
|
const { betaFeatureToggleState } = useBetaFeatureToggle() |
||||||
|
|
||||||
|
const updateSelectFieldLayout = (value: boolean) => { |
||||||
|
if (!activeField.value) return |
||||||
|
|
||||||
|
activeField.value.meta.isList = value |
||||||
|
updateColMeta(activeField.value) |
||||||
|
} |
||||||
|
|
||||||
|
const columnSupportsScanning = (elementType: UITypes) => |
||||||
|
betaFeatureToggleState.show && |
||||||
|
[UITypes.SingleLineText, UITypes.Number, UITypes.Email, UITypes.URL, UITypes.LongText].includes(elementType) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<!-- Field Settings --> |
||||||
|
<template v-if="activeField"> |
||||||
|
<div class="nc-form-field-settings p-4 flex flex-col gap-4 border-b border-gray-200"> |
||||||
|
<div class="text-base font-bold">{{ $t('objects.field') }} {{ $t('activity.validations').toLowerCase() }}</div> |
||||||
|
<div class="flex flex-col gap-6"> |
||||||
|
<div class="flex items-center justify-between gap-3"> |
||||||
|
<div |
||||||
|
class="nc-form-input-required text-gray-800 font-medium" |
||||||
|
@click=" |
||||||
|
() => { |
||||||
|
activeField.required = !activeField.required |
||||||
|
updateColMeta(activeField) |
||||||
|
} |
||||||
|
" |
||||||
|
> |
||||||
|
{{ $t('general.required') }} {{ $t('objects.field').toLowerCase() }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<a-switch |
||||||
|
v-model:checked="activeField.required" |
||||||
|
v-e="['a:form-view:field:mark-required']" |
||||||
|
size="small" |
||||||
|
data-testid="nc-form-input-required" |
||||||
|
@change="updateColMeta(activeField)" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div v-if="columnSupportsScanning(activeField.uidt)" class="!my-0 nc-form-input-enable-scanner-form-item"> |
||||||
|
<div class="flex items-center justify-between gap-3"> |
||||||
|
<div class="nc-form-input-enable-scanner text-gray-800 font-medium"> |
||||||
|
{{ $t('general.enableScanner') }} |
||||||
|
</div> |
||||||
|
<a-switch |
||||||
|
v-model:checked="activeField.enable_scanner" |
||||||
|
v-e="['a:form-view:field:mark-enable-scanner']" |
||||||
|
data-testid="nc-form-input-enable-scanner" |
||||||
|
size="small" |
||||||
|
@change="updateColMeta(activeField)" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Limit options --> |
||||||
|
<div v-if="isSelectTypeCol(activeField.uidt)" class="w-full flex items-start justify-between gap-3"> |
||||||
|
<div class="flex-1 max-w-[calc(100%_-_40px)]"> |
||||||
|
<div class="font-medium text-gray-800">{{ $t('labels.limitOptions') }}</div> |
||||||
|
<div class="text-gray-500 mt-2">{{ $t('labels.limitOptionsSubtext') }}.</div> |
||||||
|
<div v-if="activeField.meta.isLimitOption" class="mt-3"> |
||||||
|
<LazySmartsheetFormLimitOptions |
||||||
|
v-model:model-value="activeField.meta.limitOptions" |
||||||
|
:form-field-state="formState[activeField.title] || ''" |
||||||
|
:column="activeField" |
||||||
|
:is-required="isRequired(activeField, activeField.required)" |
||||||
|
@update:model-value="updateColMeta(activeField)" |
||||||
|
@update:form-field-state="(value)=>{ |
||||||
|
formState[activeField!.title] = value |
||||||
|
}" |
||||||
|
></LazySmartsheetFormLimitOptions> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<a-switch |
||||||
|
v-model:checked="activeField.meta.isLimitOption" |
||||||
|
v-e="['a:form-view:field:limit-options']" |
||||||
|
size="small" |
||||||
|
class="flex-none nc-form-switch-focus" |
||||||
|
@change="updateColMeta(activeField)" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Field Appearance Settings --> |
||||||
|
<div |
||||||
|
v-if="isSelectTypeCol(activeField.uidt)" |
||||||
|
class="nc-form-field-appearance-settings p-4 flex flex-col gap-4 border-b border-gray-200" |
||||||
|
> |
||||||
|
<div class="text-base font-bold">{{ $t('general.appearance') }}</div> |
||||||
|
<div class="flex flex-col gap-6"> |
||||||
|
<!-- Select type field Options Layout --> |
||||||
|
<div> |
||||||
|
<div class="text-gray-800 font-medium">Options layout</div> |
||||||
|
|
||||||
|
<a-radio-group |
||||||
|
:value="!!activeField.meta.isList" |
||||||
|
class="nc-form-field-layout !mt-3 max-w-[calc(100%_-_40px)]" |
||||||
|
@update:value="updateSelectFieldLayout" |
||||||
|
> |
||||||
|
<a-radio :value="false">{{ $t('general.dropdown') }}</a-radio> |
||||||
|
<a-radio :value="true">{{ $t('general.list') }}</a-radio> |
||||||
|
</a-radio-group> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</template> |
@ -0,0 +1,135 @@ |
|||||||
|
import type { Ref } from 'vue' |
||||||
|
import type { RuleObject } from 'ant-design-vue/es/form' |
||||||
|
import type { ColumnType, FormType, TableType, ViewType } from 'nocodb-sdk' |
||||||
|
import { RelationTypes, UITypes, isLinksOrLTAR } from 'nocodb-sdk' |
||||||
|
|
||||||
|
const useForm = Form.useForm |
||||||
|
|
||||||
|
const [useProvideFormViewStore, useFormViewStore] = useInjectionState( |
||||||
|
( |
||||||
|
_meta: Ref<TableType | undefined> | ComputedRef<TableType | undefined>, |
||||||
|
viewMeta: Ref<ViewType | undefined> | ComputedRef<(ViewType & { id: string }) | undefined>, |
||||||
|
formViewData: Ref<FormType | undefined>, |
||||||
|
updateFormView: (view: FormType | undefined) => Promise<any>, |
||||||
|
isEditable: boolean, |
||||||
|
) => { |
||||||
|
const { $api } = useNuxtApp() |
||||||
|
|
||||||
|
const { t } = useI18n() |
||||||
|
|
||||||
|
const formResetHook = createEventHook<void>() |
||||||
|
|
||||||
|
const formState = ref<Record<string, any>>({}) |
||||||
|
|
||||||
|
const localColumns = ref<Record<string, any>[]>([]) |
||||||
|
|
||||||
|
const activeRow = ref('') |
||||||
|
|
||||||
|
const visibleColumns = computed(() => localColumns.value.filter((f) => f.show).sort((a, b) => a.order - b.order)) |
||||||
|
|
||||||
|
const activeField = computed(() => visibleColumns.value.find((c) => c.id === activeRow.value) || null) |
||||||
|
|
||||||
|
const activeColumn = computed(() => { |
||||||
|
if (_meta.value && activeField.value) { |
||||||
|
if (_meta.value.columnsById && (_meta.value.columnsById as Record<string, ColumnType>)[activeField.value?.fk_column_id]) { |
||||||
|
return (_meta.value.columnsById as Record<string, ColumnType>)[activeField.value.fk_column_id] |
||||||
|
} else if (_meta.value.columns) { |
||||||
|
return _meta.value.columns.find((c) => c.id === activeField.value?.fk_column_id) ?? null |
||||||
|
} |
||||||
|
} |
||||||
|
return null |
||||||
|
}) |
||||||
|
|
||||||
|
const validators = computed(() => { |
||||||
|
const rulesObj: Record<string, RuleObject[]> = {} |
||||||
|
|
||||||
|
if (!visibleColumns.value) return rulesObj |
||||||
|
|
||||||
|
for (const column of visibleColumns.value) { |
||||||
|
let rules: RuleObject[] = [ |
||||||
|
{ |
||||||
|
required: isRequired(column, column.required), |
||||||
|
message: t('msg.error.fieldRequired'), |
||||||
|
...(column.uidt === UITypes.Checkbox && isRequired(column, column.required) ? { type: 'enum', enum: [1, true] } : {}), |
||||||
|
}, |
||||||
|
] |
||||||
|
|
||||||
|
const additionalRules = extractFieldValidator(parseProp(column.meta).validators ?? [], column) |
||||||
|
rules = [...rules, ...additionalRules] |
||||||
|
|
||||||
|
if (rules.length) { |
||||||
|
rulesObj[column.title!] = rules |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return rulesObj |
||||||
|
}) |
||||||
|
|
||||||
|
// Form field validation
|
||||||
|
const { validate, validateInfos, clearValidate } = useForm(formState, validators) |
||||||
|
|
||||||
|
const validateActiveField = async (col: ColumnType) => { |
||||||
|
try { |
||||||
|
await validate(col.title) |
||||||
|
} catch {} |
||||||
|
} |
||||||
|
|
||||||
|
const updateView = useDebounceFn( |
||||||
|
() => { |
||||||
|
updateFormView(formViewData.value) |
||||||
|
}, |
||||||
|
300, |
||||||
|
{ maxWait: 2000 }, |
||||||
|
) |
||||||
|
|
||||||
|
const updateColMeta = useDebounceFn(async (col: Record<string, any>) => { |
||||||
|
if (col.id && isEditable) { |
||||||
|
validateActiveField(col) |
||||||
|
|
||||||
|
try { |
||||||
|
await $api.dbView.formColumnUpdate(col.id, col) |
||||||
|
} catch (e: any) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
}, 250) |
||||||
|
|
||||||
|
function isRequired(_columnObj: Record<string, any>, required = false) { |
||||||
|
let columnObj = _columnObj |
||||||
|
if (isLinksOrLTAR(columnObj.uidt) && columnObj.colOptions && columnObj.colOptions.type === RelationTypes.BELONGS_TO) { |
||||||
|
columnObj = (_meta?.value?.columns || []).find( |
||||||
|
(c: Record<string, any>) => c.id === columnObj.colOptions.fk_child_column_id, |
||||||
|
) as Record<string, any> |
||||||
|
} |
||||||
|
|
||||||
|
return required || (columnObj && columnObj.rqd && !columnObj.cdf) |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
onReset: formResetHook.on, |
||||||
|
formState, |
||||||
|
localColumns, |
||||||
|
visibleColumns, |
||||||
|
activeRow, |
||||||
|
activeField, |
||||||
|
activeColumn, |
||||||
|
isRequired, |
||||||
|
updateView, |
||||||
|
updateColMeta, |
||||||
|
validate, |
||||||
|
validateInfos, |
||||||
|
clearValidate, |
||||||
|
} |
||||||
|
}, |
||||||
|
'form-view-store', |
||||||
|
) |
||||||
|
|
||||||
|
export { useProvideFormViewStore } |
||||||
|
|
||||||
|
export function useFormViewStoreOrThrow() { |
||||||
|
const sharedFormStore = useFormViewStore() |
||||||
|
|
||||||
|
if (sharedFormStore == null) throw new Error('Please call `useProvideFormViewStore` on the appropriate parent component') |
||||||
|
|
||||||
|
return sharedFormStore |
||||||
|
} |
@ -0,0 +1,109 @@ |
|||||||
|
import type { RuleObject } from 'ant-design-vue/es/form' |
||||||
|
import isMobilePhone from 'validator/lib/isMobilePhone' |
||||||
|
import { StringValidationType, UITypes } from 'nocodb-sdk' |
||||||
|
import type { ColumnType, Validation } from 'nocodb-sdk' |
||||||
|
import { getI18n } from '../plugins/a.i18n' |
||||||
|
|
||||||
|
export const formEmailValidator = (val: Validation) => { |
||||||
|
return { |
||||||
|
validator: (_rule: RuleObject, value: any) => { |
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
const { t } = getI18n().global |
||||||
|
|
||||||
|
if (value && !validateEmail(value)) { |
||||||
|
return reject(val.message || t('msg.error.invalidEmail')) |
||||||
|
} |
||||||
|
return resolve(true) |
||||||
|
}) |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const formPhoneNumberValidator = (val: Validation) => { |
||||||
|
return { |
||||||
|
validator: (_rule: RuleObject, value: any) => { |
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
const { t } = getI18n().global |
||||||
|
|
||||||
|
if (value && !isMobilePhone(value)) { |
||||||
|
return reject(val.message || t('msg.invalidPhoneNumber')) |
||||||
|
} |
||||||
|
return resolve(true) |
||||||
|
}) |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const formUrlValidator = (val: Validation) => { |
||||||
|
return { |
||||||
|
validator: (_rule: RuleObject, value: any) => { |
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
const { t } = getI18n().global |
||||||
|
|
||||||
|
if (value && !isValidURL(value)) { |
||||||
|
return reject(val.message || t('msg.error.invalidURL')) |
||||||
|
} |
||||||
|
return resolve(true) |
||||||
|
}) |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const formNumberInputValidator = (cal: ColumnType) => { |
||||||
|
return { |
||||||
|
validator: (_rule: RuleObject, value: any) => { |
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
const { t } = getI18n().global |
||||||
|
|
||||||
|
if (value && value !== '-' && !(cal.uidt === UITypes.Number ? /^-?\d+$/.test(value) : /^-?\d*\.?\d+$/.test(value))) { |
||||||
|
return reject(t('msg.plsEnterANumber')) |
||||||
|
} |
||||||
|
return resolve(true) |
||||||
|
}) |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const extractFieldValidator = (_validators: Validation[], element: ColumnType) => { |
||||||
|
const rules: RuleObject[] = [] |
||||||
|
|
||||||
|
// Add column default validators
|
||||||
|
if ([UITypes.Number, UITypes.Currency, UITypes.Percent].includes(element.uidt)) { |
||||||
|
rules.push(formNumberInputValidator(element)) |
||||||
|
} |
||||||
|
|
||||||
|
switch (element.uidt) { |
||||||
|
case UITypes.Email: { |
||||||
|
if (parseProp(element.meta).validate) { |
||||||
|
rules.push( |
||||||
|
formEmailValidator({ |
||||||
|
type: StringValidationType.Email, |
||||||
|
}), |
||||||
|
) |
||||||
|
} |
||||||
|
break |
||||||
|
} |
||||||
|
case UITypes.PhoneNumber: { |
||||||
|
if (parseProp(element.meta).validate) { |
||||||
|
rules.push( |
||||||
|
formPhoneNumberValidator({ |
||||||
|
type: StringValidationType.PhoneNumber, |
||||||
|
}), |
||||||
|
) |
||||||
|
} |
||||||
|
break |
||||||
|
} |
||||||
|
case UITypes.URL: { |
||||||
|
if (parseProp(element.meta).validate) { |
||||||
|
rules.push( |
||||||
|
formUrlValidator({ |
||||||
|
type: StringValidationType.Url, |
||||||
|
}), |
||||||
|
) |
||||||
|
} |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return rules |
||||||
|
} |
@ -0,0 +1,83 @@ |
|||||||
|
export interface Validation { |
||||||
|
type: |
||||||
|
| GenericValidationType |
||||||
|
| StringValidationType |
||||||
|
| NumberValidationType |
||||||
|
| DateValidationType |
||||||
|
| TimeValidationType |
||||||
|
| YearValidationType |
||||||
|
| SelectValidationType |
||||||
|
| AttachmentValidationType |
||||||
|
| null; |
||||||
|
// Additional properties depending on the type of validation
|
||||||
|
[key: string]: any; |
||||||
|
} |
||||||
|
|
||||||
|
export type ValidationType = Exclude< |
||||||
|
Validation['type'], |
||||||
|
null | GenericValidationType |
||||||
|
>; |
||||||
|
|
||||||
|
export enum GenericValidationType { |
||||||
|
Required = 'required', |
||||||
|
} |
||||||
|
|
||||||
|
export enum StringValidationType { |
||||||
|
MinLength = 'minLength', |
||||||
|
MaxLength = 'maxLength', |
||||||
|
StartsWith = 'startsWith', |
||||||
|
EndsWith = 'endsWith', |
||||||
|
Includes = 'includes', |
||||||
|
NotIncludes = 'notIncludes', |
||||||
|
Regex = 'regex', |
||||||
|
Email = 'email', |
||||||
|
BusinessEmail = 'businessEmail', |
||||||
|
PhoneNumber = 'phoneNumber', |
||||||
|
Url = 'url', |
||||||
|
} |
||||||
|
|
||||||
|
export enum NumberValidationType { |
||||||
|
Min = 'min', |
||||||
|
Max = 'max', |
||||||
|
} |
||||||
|
|
||||||
|
export enum DateValidationType { |
||||||
|
MinDate = 'minDate', |
||||||
|
MaxDate = 'maxDate', |
||||||
|
} |
||||||
|
|
||||||
|
export enum TimeValidationType { |
||||||
|
MinTime = 'minTime', |
||||||
|
MaxTime = 'maxTime', |
||||||
|
} |
||||||
|
|
||||||
|
export enum YearValidationType { |
||||||
|
MinYear = 'minYear', |
||||||
|
MaxYear = 'maxYear', |
||||||
|
} |
||||||
|
|
||||||
|
export enum SelectValidationType { |
||||||
|
MinSelected = 'minSelected', |
||||||
|
MaxSelected = 'maxSelected', |
||||||
|
LimitOptions = 'limitOptions', |
||||||
|
} |
||||||
|
|
||||||
|
export enum AttachmentValidationType { |
||||||
|
FileTypes = 'fileTypes', |
||||||
|
FileSize = 'fileSize', |
||||||
|
FileCount = 'fileCount', |
||||||
|
} |
||||||
|
|
||||||
|
export interface RequiredValidation extends Validation { |
||||||
|
type: GenericValidationType.Required; |
||||||
|
} |
||||||
|
|
||||||
|
export const oppositeValidationTypeMap = { |
||||||
|
[StringValidationType.MaxLength]: StringValidationType.MinLength, |
||||||
|
[StringValidationType.NotIncludes]: StringValidationType.Includes, |
||||||
|
[NumberValidationType.Max]: NumberValidationType.Min, |
||||||
|
[YearValidationType.MaxYear]: YearValidationType.MinYear, |
||||||
|
[DateValidationType.MaxDate]: DateValidationType.MinDate, |
||||||
|
[TimeValidationType.MaxTime]: TimeValidationType.MinTime, |
||||||
|
[SelectValidationType.MaxSelected]: SelectValidationType.MinSelected, |
||||||
|
}; |
Loading…
Reference in new issue