Browse Source

Nc feat/form validation (#8409)

* 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
parent
commit
f85240848d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      packages/nc-gui/assets/nc-icons/alert-triangle.svg
  2. 6
      packages/nc-gui/components/cell/Currency.vue
  3. 3
      packages/nc-gui/components/cell/Decimal.vue
  4. 3
      packages/nc-gui/components/cell/Percent.vue
  5. 6
      packages/nc-gui/components/cell/PhoneNumber.vue
  6. 4
      packages/nc-gui/components/cell/attachment/index.vue
  7. 277
      packages/nc-gui/components/smartsheet/Form.vue
  8. 3
      packages/nc-gui/components/smartsheet/form/field-config-error.vue
  9. 116
      packages/nc-gui/components/smartsheet/form/field-settings.vue
  10. 135
      packages/nc-gui/composables/useFormViewStore.ts
  11. 189
      packages/nc-gui/composables/useSharedFormViewStore.ts
  12. 2
      packages/nc-gui/composables/useViewData.ts
  13. 7
      packages/nc-gui/lang/en.json
  14. 48
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  15. 118
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue
  16. 109
      packages/nc-gui/utils/formValidations.ts
  17. 2
      packages/nc-gui/utils/iconUtils.ts
  18. 83
      packages/nocodb-sdk/src/lib/form.ts
  19. 1
      packages/nocodb-sdk/src/lib/index.ts

8
packages/nc-gui/assets/nc-icons/alert-triangle.svg

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M6.86001 2.57332L1.21335 12C1.09693 12.2016 1.03533 12.4302 1.03467 12.663C1.03402 12.8958 1.09434 13.1247 1.20963 13.327C1.32492 13.5293 1.49116 13.6978 1.69182 13.8159C1.89247 13.934 2.12055 13.9974 2.35335 14H13.6467C13.8795 13.9974 14.1076 13.934 14.3082 13.8159C14.5089 13.6978 14.6751 13.5293 14.7904 13.327C14.9057 13.1247 14.966 12.8958 14.9654 12.663C14.9647 12.4302 14.9031 12.2016 14.7867 12L9.14001 2.57332C9.02117 2.37739 8.85383 2.2154 8.65414 2.10297C8.45446 1.99055 8.22917 1.93149 8.00001 1.93149C7.77086 1.93149 7.54557 1.99055 7.34588 2.10297C7.1462 2.2154 6.97886 2.37739 6.86001 2.57332V2.57332Z"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M8 6V8.66667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
<path d="M8 11.3333H8.00667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

6
packages/nc-gui/components/cell/Currency.vue

@ -3,6 +3,8 @@ import type { VNodeRef } from '@vue/runtime-core'
interface Props { interface Props {
modelValue: number | null | undefined modelValue: number | null | undefined
placeholder?: string
hidePrefix?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@ -92,7 +94,7 @@ onMounted(() => {
<template> <template>
<div <div
v-if="isForm && !isEditColumn" v-if="isForm && !isEditColumn && !hidePrefix"
class="nc-currency-code h-full !bg-gray-100 border-r border-gray-200 px-3 mr-1 flex items-center" class="nc-currency-code h-full !bg-gray-100 border-r border-gray-200 px-3 mr-1 flex items-center"
> >
<span> <span>
@ -106,7 +108,7 @@ onMounted(() => {
type="number" type="number"
class="nc-cell-field h-full border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0" class="nc-cell-field h-full border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0"
:class="isForm && !isEditColumn ? 'flex flex-1' : 'w-full'" :class="isForm && !isEditColumn ? 'flex flex-1' : 'w-full'"
:placeholder="isEditColumn ? $t('labels.optional') : ''" :placeholder="placeholder !== undefined ? placeholder : isEditColumn ? $t('labels.optional') : ''"
:disabled="readOnly" :disabled="readOnly"
@blur="onBlur" @blur="onBlur"
@keydown.enter="onKeydownEnter" @keydown.enter="onKeydownEnter"

3
packages/nc-gui/components/cell/Decimal.vue

@ -6,6 +6,7 @@ interface Props {
// for sqlite, when we clear a cell or empty the cell, it returns "" // for sqlite, when we clear a cell or empty the cell, it returns ""
// otherwise, it is null type // otherwise, it is null type
modelValue?: number | null | string modelValue?: number | null | string
placeholder?: string
} }
interface Emits { interface Emits {
@ -101,7 +102,7 @@ watch(isExpandedFormOpen, () => {
class="nc-cell-field outline-none py-1 border-none rounded-md w-full h-full" class="nc-cell-field outline-none py-1 border-none rounded-md w-full h-full"
type="number" type="number"
:step="precision" :step="precision"
:placeholder="isEditColumn ? $t('labels.optional') : ''" :placeholder="placeholder !== undefined ? placeholder : isEditColumn ? $t('labels.optional') : ''"
style="letter-spacing: 0.06rem" style="letter-spacing: 0.06rem"
@blur="editEnabled = false" @blur="editEnabled = false"
@keydown.down.stop="onKeyDown" @keydown.down.stop="onKeyDown"

3
packages/nc-gui/components/cell/Percent.vue

@ -3,6 +3,7 @@ import type { VNodeRef } from '@vue/runtime-core'
interface Props { interface Props {
modelValue?: number | string | null modelValue?: number | string | null
placeholder?: string
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@ -144,7 +145,7 @@ const onTabPress = (e: KeyboardEvent) => {
v-model="vModel" v-model="vModel"
class="nc-cell-field w-full !border-none !outline-none focus:ring-0 py-1" class="nc-cell-field w-full !border-none !outline-none focus:ring-0 py-1"
:type="inputType" :type="inputType"
:placeholder="isEditColumn ? $t('labels.optional') : ''" :placeholder="placeholder !== undefined ? placeholder : isEditColumn ? $t('labels.optional') : ''"
@blur="onBlur" @blur="onBlur"
@focus="onFocus" @focus="onFocus"
@keydown.down.stop @keydown.down.stop

6
packages/nc-gui/components/cell/PhoneNumber.vue

@ -22,7 +22,7 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const column = inject(ColumnInj)! const column = inject(ColumnInj)!
const isSurveyForm = inject(IsSurveyFormInj, ref(false)) const isForm = inject(IsFormInj)!
const readOnly = inject(ReadonlyInj, ref(false)) const readOnly = inject(ReadonlyInj, ref(false))
@ -33,7 +33,7 @@ const vModel = computed({
get: () => value, get: () => value,
set: (val) => { set: (val) => {
localState.value = val localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && isMobilePhone(val)) || !val || isSurveyForm.value) { if (!parseProp(column.value.meta)?.validate || (val && isMobilePhone(val)) || !val || isForm.value) {
emit('update:modelValue', val) emit('update:modelValue', val)
} }
}, },
@ -43,8 +43,6 @@ const validPhoneNumber = computed(() => vModel.value && isMobilePhone(vModel.val
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))! const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) => const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus() !isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()

4
packages/nc-gui/components/cell/attachment/index.vue

@ -333,7 +333,7 @@ const handleFileDelete = (i: number) => {
<div <div
v-if="active || (isForm && visibleItems.length)" v-if="active || (isForm && visibleItems.length)"
class="xs:hidden h-6 w-5 group cursor-pointer flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-none p-1 hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)" class="xs:hidden h-6 w-5.5 group cursor-pointer flex gap-1 items-center active:(ring ring-accent ring-opacity-100) rounded border-none p-1 hover:(bg-primary bg-opacity-10) dark:(!bg-slate-500)"
> >
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" /> <component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />
@ -342,7 +342,7 @@ const handleFileDelete = (i: number) => {
<component <component
:is="iconMap.expand" :is="iconMap.expand"
class="flex-none transform dark:(!text-white) group-hover:(!text-grey-800 scale-120) text-gray-500 text-[2rem] h-3" class="flex-none transform dark:(!text-white) group-hover:(!text-grey-800 scale-120) text-gray-500 text-sm"
@click.stop="onExpand" @click.stop="onExpand"
/> />
</NcTooltip> </NcTooltip>

277
packages/nc-gui/components/smartsheet/Form.vue

@ -3,17 +3,15 @@ import Draggable from 'vuedraggable'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import { Pane, Splitpanes } from 'splitpanes' import { Pane, Splitpanes } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css' import 'splitpanes/dist/splitpanes.css'
import type { FormItemProps } from 'ant-design-vue'
import { import {
type AttachmentResType, type AttachmentResType,
type ColumnType,
ProjectRoles, ProjectRoles,
RelationTypes, RelationTypes,
UITypes, UITypes,
ViewTypes, ViewTypes,
getSystemColumns, getSystemColumns,
isLinksOrLTAR, isLinksOrLTAR,
isSelectTypeCol,
isVirtualCol, isVirtualCol,
} from 'nocodb-sdk' } from 'nocodb-sdk'
import type { ImageCropperConfig } from '~/lib/types' import type { ImageCropperConfig } from '~/lib/types'
@ -60,10 +58,6 @@ const { base } = storeToRefs(useBase())
const { getPossibleAttachmentSrc } = useAttachment() const { getPossibleAttachmentSrc } = useAttachment()
const formRef = ref()
const formState = ref<Record<string, any>>({})
const secondsRemain = ref(0) const secondsRemain = ref(0)
const isLocked = inject(IsLockedInj, ref(false)) const isLocked = inject(IsLockedInj, ref(false))
@ -78,6 +72,21 @@ const isPublic = inject(IsPublicInj, ref(false))
const { loadFormView, insertRow, formColumnData, formViewData, updateFormView } = useViewData(meta, view) const { loadFormView, insertRow, formColumnData, formViewData, updateFormView } = useViewData(meta, view)
const {
formState,
localColumns,
visibleColumns,
activeRow,
activeField,
activeColumn,
isRequired,
updateView,
updateColMeta,
validateInfos,
validate,
clearValidate,
} = useProvideFormViewStore(meta, view, formViewData, updateFormView, isEditable)
const { preFillFormSearchParams } = storeToRefs(useViewsStore()) const { preFillFormSearchParams } = storeToRefs(useViewsStore())
const reloadEventHook = inject(ReloadViewDataHookInj, createEventHook()) const reloadEventHook = inject(ReloadViewDataHookInj, createEventHook())
@ -99,8 +108,6 @@ const { state, row } = useProvideSmartsheetRowStore(
const columns = computed(() => meta?.value?.columns || []) const columns = computed(() => meta?.value?.columns || [])
const localColumns = ref<Record<string, any>[]>([])
const draggableRef = ref() const draggableRef = ref()
const systemFieldsIds = ref<Record<string, any>[]>([]) const systemFieldsIds = ref<Record<string, any>[]>([])
@ -116,8 +123,6 @@ const emailMe = ref(false)
const submitted = ref(false) const submitted = ref(false)
const activeRow = ref('')
const isLoadingFormView = ref(false) const isLoadingFormView = ref(false)
const showCropper = ref(false) const showCropper = ref(false)
@ -159,39 +164,14 @@ const autoScrollFormField = ref(false)
const { t } = useI18n() const { t } = useI18n()
const { betaFeatureToggleState } = useBetaFeatureToggle()
const { open, onChange: onChangeFile } = useFileDialog({ const { open, onChange: onChangeFile } = useFileDialog({
accept: 'image/*', accept: 'image/*',
multiple: false, multiple: false,
reset: true, reset: true,
}) })
const visibleColumns = computed(() => localColumns.value.filter((f) => f.show).sort((a, b) => a.order - b.order))
const getFormLogoSrc = computed(() => getPossibleAttachmentSrc(parseProp(formViewData.value?.logo_url))) const getFormLogoSrc = computed(() => getPossibleAttachmentSrc(parseProp(formViewData.value?.logo_url)))
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 updateView = useDebounceFn(
() => {
updateFormView(formViewData.value)
},
300,
{ maxWait: 2000 },
)
const updatePreFillFormSearchParams = useDebounceFn(() => { const updatePreFillFormSearchParams = useDebounceFn(() => {
if (isLocked.value || !isUIAllowed('dataInsert')) return if (isLocked.value || !isUIAllowed('dataInsert')) return
@ -211,8 +191,14 @@ const updatePreFillFormSearchParams = useDebounceFn(() => {
async function submitForm() { async function submitForm() {
if (isLocked.value || !isUIAllowed('dataInsert')) return if (isLocked.value || !isUIAllowed('dataInsert')) return
for (const col of visibleColumns.value) {
if (isRequired(col, col.required) && formState.value[col.title] === undefined) {
formState.value[col.title] = null
}
}
try { try {
await formRef.value?.validateFields() await validate([...Object.keys(formState.value)])
} catch (e: any) { } catch (e: any) {
if (e.errorFields.length) { if (e.errorFields.length) {
message.error(t('msg.error.someOfTheRequiredFieldsAreEmpty')) message.error(t('msg.error.someOfTheRequiredFieldsAreEmpty'))
@ -234,7 +220,7 @@ async function clearForm() {
formState.value = {} formState.value = {}
state.value = {} state.value = {}
await formRef.value?.clearValidate() clearValidate()
reloadEventHook.trigger() reloadEventHook.trigger()
} }
@ -418,18 +404,6 @@ function setFormData() {
.map((c) => ({ ...c, required: !!c.required })) .map((c) => ({ ...c, required: !!c.required }))
} }
function isRequired(_columnObj: Record<string, any>, required = false) {
let columnObj = _columnObj
if (isLinksOrLTAR(columnObj.uidt) && columnObj.colOptions && columnObj.colOptions.type === RelationTypes.BELONGS_TO) {
columnObj = columns.value.find((c: Record<string, any>) => c.id === columnObj.colOptions.fk_child_column_id) as Record<
string,
any
>
}
return required || (columnObj && columnObj.rqd && !columnObj.cdf)
}
async function updateEmail() { async function updateEmail() {
try { try {
if (!(await checkSMTPStatus())) return if (!(await checkSMTPStatus())) return
@ -476,20 +450,6 @@ async function deleteColumnCallback() {
reloadEventHook.trigger() reloadEventHook.trigger()
} }
const updateColMeta = useDebounceFn(async (col: Record<string, any>) => {
if (col.id && isEditable) {
try {
await $api.dbView.formColumnUpdate(col.id, col)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
}, 250)
const columnSupportsScanning = (elementType: UITypes) =>
betaFeatureToggleState.show &&
[UITypes.SingleLineText, UITypes.Number, UITypes.Email, UITypes.URL, UITypes.LongText].includes(elementType)
const onFormItemClick = (element: any, sidebarClick: boolean = false) => { const onFormItemClick = (element: any, sidebarClick: boolean = false) => {
if (isLocked.value || !isEditable) return if (isLocked.value || !isEditable) return
@ -570,51 +530,6 @@ const handleOnUploadImage = (data: AttachmentResType = null) => {
updateView() updateView()
} }
const validateFormEmail = async (_rule, value) => {
if (!value) {
return Promise.resolve()
} else if (!validateEmail(value)) {
return Promise.reject(t('msg.error.invalidEmail'))
}
}
const validateFormURL = async (_rule, value) => {
if (!value) {
return Promise.resolve()
} else if (!isValidURL(value)) {
return Promise.reject(t('msg.error.invalidURL'))
}
}
const formElementValidationRules = (element) => {
const rules: FormItemProps['rules'] = [
{
required: isRequired(element, element.required),
message: t('msg.error.fieldRequired', { value: 'This field' }),
...(element.uidt === UITypes.Checkbox && isRequired(element, element.required) ? { type: 'enum', enum: [1, true] } : {}),
},
]
if (parseProp(element.meta).validate && element.uidt === UITypes.URL) {
rules.push({
validator: validateFormURL,
})
} else if (parseProp(element.meta).validate && element.uidt === UITypes.Email) {
rules.push({
validator: validateFormEmail,
})
}
if ([UITypes.Number, UITypes.Currency, UITypes.Percent].includes(element.uidt)) {
rules.push({
type: 'number',
message: t('msg.plsEnterANumber'),
})
}
return rules
}
const onFocusActiveFieldLabel = (e: FocusEvent) => { const onFocusActiveFieldLabel = (e: FocusEvent) => {
;(e.target as HTMLTextAreaElement).select() ;(e.target as HTMLTextAreaElement).select()
} }
@ -629,13 +544,6 @@ const updateFieldTitle = (value: string) => {
} }
} }
const updateSelectFieldLayout = (value: boolean) => {
if (!activeField.value) return
activeField.value.meta.isList = value
updateColMeta(activeField.value)
}
onMounted(async () => { onMounted(async () => {
if (imageCropperData.value.src) { if (imageCropperData.value.src) {
URL.revokeObjectURL(imageCropperData.value.imageConfig.src) URL.revokeObjectURL(imageCropperData.value.imageConfig.src)
@ -678,10 +586,8 @@ watch(
updatePreFillFormSearchParams() updatePreFillFormSearchParams()
try { try {
await formRef.value?.validateFields([...Object.keys(formState.value)]) await validate([...Object.keys(formState.value)])
} catch (e: any) { } catch {}
e.errorFields.map((f: Record<string, any>) => console.error(f.errors.join(',')))
}
}, },
{ {
deep: true, deep: true,
@ -951,7 +857,7 @@ useEventListener(
padding: '0px !important', padding: '0px !important',
}" }"
> >
<a-form ref="formRef" :model="formState" class="nc-form" no-style> <a-form :model="formState" class="nc-form" no-style>
<!-- form header --> <!-- form header -->
<div class="flex flex-col px-4 lg:px-6"> <div class="flex flex-col px-4 lg:px-6">
<!-- Form logo --> <!-- Form logo -->
@ -1199,7 +1105,7 @@ useEventListener(
<a-form-item <a-form-item
:name="element.title" :name="element.title"
class="!my-0 nc-input-required-error nc-form-input-item" class="!my-0 nc-input-required-error nc-form-input-item"
:rules="formElementValidationRules(element)" v-bind="validateInfos[element.title]"
> >
<LazySmartsheetDivDataCell class="relative" @click.stop> <LazySmartsheetDivDataCell class="relative" @click.stop>
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
@ -1225,6 +1131,8 @@ useEventListener(
/> />
</LazySmartsheetDivDataCell> </LazySmartsheetDivDataCell>
</a-form-item> </a-form-item>
<LazySmartsheetFormFieldConfigError :column="element" />
</div> </div>
</div> </div>
</div> </div>
@ -1353,10 +1261,9 @@ useEventListener(
/> />
</div> </div>
</div> </div>
<!-- Field text --> <!-- Field text -->
<div class="nc-form-field-text p-4 flex flex-col gap-4 border-b border-gray-200"> <div class="nc-form-field-text p-4 flex flex-col gap-4 border-b border-gray-200">
<div class="text-base font-bold">{{ $t('objects.field') }} {{ $t('general.text') }}</div> <div class="text-base font-bold text-gray-600">{{ $t('objects.field') }} {{ $t('general.text').toLowerCase() }}</div>
<a-textarea <a-textarea
ref="focusLabel" ref="focusLabel"
@ -1383,103 +1290,7 @@ useEventListener(
@update:value="updateColMeta(activeField)" @update:value="updateColMeta(activeField)"
/> />
</div> </div>
<LazySmartsheetFormFieldSettings v-if="activeField"></LazySmartsheetFormFieldSettings>
<!-- Field Settings -->
<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.settings') }}</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">
{{ $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>
<!-- Todo: Show on conditions,... -->
<!-- eslint-disable vue/no-constant-condition -->
<div v-if="false" class="flex items-start justify-between gap-3">
<div>
<div class="font-medium text-gray-800">{{ $t('labels.showOnConditions') }}</div>
<div class="text-gray-500 mt-2">{{ $t('labels.showFieldOnConditionsMet') }}</div>
</div>
<a-switch v-e="['a:form-view:field:show-on-condition']" size="small" class="nc-form-switch-focus" />
</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>
</div> </div>
<!-- Form Settings --> <!-- Form Settings -->
@ -1638,6 +1449,12 @@ useEventListener(
</div> </div>
</div> </div>
<NcTooltip
:disabled="!field.required || isLocked || !isEditable"
class="flex"
placement="topRight"
>
<template #title> You can't hide a required field.</template>
<a-switch <a-switch
:checked="!!field.show" :checked="!!field.show"
:disabled="field.required || isLocked || !isEditable" :disabled="field.required || isLocked || !isEditable"
@ -1649,6 +1466,7 @@ useEventListener(
} }
" "
/> />
</NcTooltip>
</div> </div>
</div> </div>
</template> </template>
@ -1669,7 +1487,7 @@ useEventListener(
<Pane min-size="20" size="50" class="nc-form-right-splitpane-item !overflow-y-auto nc-form-scrollbar"> <Pane min-size="20" size="50" class="nc-form-right-splitpane-item !overflow-y-auto nc-form-scrollbar">
<div class="p-4 flex flex-col space-y-4 border-b border-gray-200"> <div class="p-4 flex flex-col space-y-4 border-b border-gray-200">
<!-- Appearance Settings --> <!-- Appearance Settings -->
<div class="text-base font-bold text-gray-900">{{ $t('labels.appearanceSettings') }}</div> <div class="text-base font-bold text-gray-600">{{ $t('labels.appearanceSettings') }}</div>
<div class="flex flex-col space-y-3"> <div class="flex flex-col space-y-3">
<div :class="isLocked || !isEditable ? 'pointer-events-none' : ''"> <div :class="isLocked || !isEditable ? 'pointer-events-none' : ''">
@ -1753,7 +1571,7 @@ useEventListener(
<div class="p-4 flex flex-col space-y-4"> <div class="p-4 flex flex-col space-y-4">
<!-- Post Form Submission Settings --> <!-- Post Form Submission Settings -->
<div class="text-base font-bold text-gray-900"> <div class="text-base font-bold text-gray-600">
{{ $t('msg.info.postFormSubmissionSettings') }} {{ $t('msg.info.postFormSubmissionSettings') }}
</div> </div>
@ -1870,7 +1688,7 @@ useEventListener(
&.layout-list { &.layout-list {
@apply h-auto !pl-0 !py-1; @apply h-auto !pl-0 !py-1;
} }
&.nc-cell-rating,
&.nc-cell-geodata { &.nc-cell-geodata {
@apply !py-1; @apply !py-1;
} }
@ -1888,7 +1706,7 @@ useEventListener(
@apply p-2; @apply p-2;
} }
&.nc-virtual-cell { &.nc-virtual-cell {
@apply px-2 py-1; @apply px-2 py-1 min-h-10;
} }
&.nc-cell-json { &.nc-cell-json {
@ -1915,8 +1733,17 @@ useEventListener(
max-width: 100%; max-width: 100%;
white-space: pre-line; white-space: pre-line;
:deep(.ant-form-item-explain-error) { :deep(.ant-form-item-explain-error) {
&:first-child {
@apply mt-2; @apply mt-2;
} }
}
}
.nc-input-required-error {
&:focus-within {
:deep(.ant-form-item-explain-error) {
@apply text-gray-400;
}
}
} }
:deep(.ant-form-item-has-error .ant-select:not(.ant-select-disabled) .ant-select-selector) { :deep(.ant-form-item-has-error .ant-select:not(.ant-select-disabled) .ant-select-selector) {
border: none !important; border: none !important;

3
packages/nc-gui/components/smartsheet/form/field-config-error.vue

@ -0,0 +1,3 @@
<script setup lang="ts"></script>
<template><div></div></template>

116
packages/nc-gui/components/smartsheet/form/field-settings.vue

@ -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>

135
packages/nc-gui/composables/useFormViewStore.ts

@ -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
}

189
packages/nc-gui/composables/useSharedFormViewStore.ts

@ -1,5 +1,3 @@
import useVuelidate from '@vuelidate/core'
import { helpers, minLength, required, sameAs } from '@vuelidate/validators'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { import type {
BoolType, BoolType,
@ -14,8 +12,11 @@ import type {
import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk' import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { isString } from '@vue/shared' import { isString } from '@vue/shared'
import { useTitle } from '@vueuse/core' import { useTitle } from '@vueuse/core'
import type { RuleObject } from 'ant-design-vue/es/form'
import { filterNullOrUndefinedObjectProperties } from '~/helpers/parsers/parserHelpers' import { filterNullOrUndefinedObjectProperties } from '~/helpers/parsers/parserHelpers'
const useForm = Form.useForm
const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((sharedViewId: string) => { const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((sharedViewId: string) => {
const progress = ref(false) const progress = ref(false)
const notFound = ref(false) const notFound = ref(false)
@ -74,15 +75,13 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
}), }),
) )
const fieldRequired = (fieldName = 'This field', isBoolean = false) => const formColumns = computed(
helpers.withMessage(t('msg.error.fieldRequired', { value: fieldName }), isBoolean ? sameAs(true) : required) () =>
const formColumns = computed(() =>
columns.value columns.value
?.filter((c) => c.show) ?.filter((c) => c.show)
.filter( .filter(
(col) => !isSystemColumn(col) && col.uidt !== UITypes.SpecificDBType && (!isVirtualCol(col) || isLinksOrLTAR(col.uidt)), (col) => !isSystemColumn(col) && col.uidt !== UITypes.SpecificDBType && (!isVirtualCol(col) || isLinksOrLTAR(col.uidt)),
), ) || [],
) )
const loadSharedView = async () => { const loadSharedView = async () => {
@ -122,10 +121,22 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
!/^\w+\(\)|CURRENT_TIMESTAMP$/.test(c.cdf) !/^\w+\(\)|CURRENT_TIMESTAMP$/.test(c.cdf)
) { ) {
const defaultValue = typeof c.cdf === 'string' ? c.cdf.replace(/^'|'$/g, '') : c.cdf const defaultValue = typeof c.cdf === 'string' ? c.cdf.replace(/^'|'$/g, '') : c.cdf
if ([UITypes.Number, UITypes.Duration, UITypes.Percent, UITypes.Currency, UITypes.Decimal].includes(c.uidt)) {
formState.value[c.title] = Number(defaultValue) || null
preFilledDefaultValueformState.value[c.title] = Number(defaultValue) || null
} else if (c.uidt === UITypes.Checkbox) {
if (['true', '1'].includes(String(defaultValue).toLowerCase())) {
formState.value[c.title] = true
preFilledDefaultValueformState.value[c.title] = true
} else if (['false', '0'].includes(String(defaultValue).toLowerCase())) {
formState.value[c.title] = false
preFilledDefaultValueformState.value[c.title] = false
}
} else {
formState.value[c.title] = defaultValue formState.value[c.title] = defaultValue
preFilledDefaultValueformState.value[c.title] = defaultValue preFilledDefaultValueformState.value[c.title] = defaultValue
} }
}
return { return {
...c, ...c,
@ -178,84 +189,80 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
} }
const validators = computed(() => { const validators = computed(() => {
const obj: Record<string, Record<string, any>> = { const rulesObj: Record<string, RuleObject[]> = {}
localState: {},
virtual: {},
}
if (!formColumns.value) return obj if (!formColumns.value) return rulesObj
for (const column of formColumns.value) { for (const column of formColumns.value) {
if ( let rules: RuleObject[] = []
!isVirtualCol(column) &&
((column.rqd && !column.cdf) || (column.pk && !(column.ai || column.cdf)) || column.required)
) {
obj.localState[column.title!] = {
required: fieldRequired(undefined, !!(column.uidt === UITypes.Checkbox && column.required)),
}
} else if (
isLinksOrLTAR(column) &&
column.colOptions &&
(column.colOptions as LinkToAnotherRecordType).type === RelationTypes.BELONGS_TO
) {
const col = columns.value?.find((c) => c.id === (column?.colOptions as LinkToAnotherRecordType)?.fk_child_column_id)
if ((col && col.rqd && !col.cdf) || column.required) { rules.push({
if (col) { validator: (_rule: RuleObject, value: any) => {
obj.virtual[column.title!] = { required: fieldRequired() } return new Promise((resolve, reject) => {
if (isRequired(column)) {
if (column.uidt === UITypes.Checkbox && !value) {
return reject(t('msg.error.fieldRequired'))
} else if (column.uidt !== UITypes.Checkbox)
if (value === null || !value?.length) {
return reject(t('msg.error.fieldRequired'))
} }
} }
} else if (isVirtualCol(column) && column.required) { return resolve()
obj.virtual[column.title!] = { })
minLength: minLength(1), },
required: fieldRequired(), })
const additionalRules = extractFieldValidator(parseProp(column.meta).validators ?? [], column)
rules = [...rules, ...additionalRules]
if (rules.length) {
rulesObj[column.title!] = rules
} }
} }
return rulesObj
})
const validationFieldState = computed(() => {
return { ...formState.value, ...additionalState.value }
})
const { validate, validateInfos, clearValidate } = useForm(validationFieldState, validators)
const handleAddMissingRequiredFieldDefaultState = () => {
for (const col of formColumns.value) {
if ( if (
!isVirtualCol(column) && col.title &&
parseProp(column.meta)?.validate && isRequired(col) &&
[UITypes.URL, UITypes.Email].includes(column.uidt as UITypes) formState.value[col.title] === undefined &&
additionalState.value[col.title] === undefined
) { ) {
if (column.uidt === UITypes.URL) { if (isVirtualCol(col)) {
obj.localState[column.title!] = { additionalState.value[col.title] = null
...(obj.localState[column.title!] || {}), } else {
validateFormURL: helpers.withMessage(t('msg.error.invalidURL'), (value) => { formState.value[col.title] = null
return value ? isValidURL(value) : true
}),
} }
} else if (column.uidt === UITypes.Email) {
obj.localState[column.title!] = {
...(obj.localState[column.title!] || {}),
validateFormEmail: helpers.withMessage(t('msg.error.invalidEmail'), (value) => {
return value ? validateEmail(value) : true
}),
} }
} }
} }
if ([UITypes.Number, UITypes.Currency, UITypes.Percent].includes(column.uidt as UITypes)) { const validateAllFields = async () => {
obj.localState[column.title!] = { handleAddMissingRequiredFieldDefaultState()
...(obj.localState[column.title!] || {}),
validateFormNumber: helpers.withMessage(t('msg.plsEnterANumber'), (value) => { try {
return value ? (column.uidt === UITypes.Number ? /^\d+$/.test(value) : /^\d*\.?\d+$/.test(value)) : true await validate([...Object.keys(formState.value), ...Object.keys(additionalState.value)])
}), return true
} catch (e: any) {
if (e.errorFields.length) {
message.error(t('msg.error.someOfTheRequiredFieldsAreEmpty'))
return false
} }
} }
} }
return obj
})
const v$ = useVuelidate(
validators,
computed(() => ({ localState: formState.value, virtual: additionalState.value })),
)
const submitForm = async () => { const submitForm = async () => {
try { try {
if (!(await v$.value?.$validate())) { if (!(await validateAllFields())) {
message.error(t('msg.error.someOfTheRequiredFieldsAreEmpty'))
return return
} }
@ -300,7 +307,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
...preFilledDefaultValueformState.value, ...preFilledDefaultValueformState.value,
...(sharedViewMeta.value.preFillEnabled ? preFilledformState.value : {}), ...(sharedViewMeta.value.preFillEnabled ? preFilledformState.value : {}),
} }
v$.value?.$reset()
clearValidate()
} }
function handlePreFillForm() { function handlePreFillForm() {
@ -344,7 +352,7 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
} }
function getColAbstractType(c: ColumnType) { function getColAbstractType(c: ColumnType) {
return (c?.source_id ? sqlUis.value[c?.source_id] : Object.values(sqlUis.value)[0]).getAbstractType(c) return (c?.source_id ? sqlUis.value[c?.source_id] : Object.values(sqlUis.value)[0])?.getAbstractType(c)
} }
function getPreFillValue(c: ColumnType, value: string) { function getPreFillValue(c: ColumnType, value: string) {
@ -412,9 +420,9 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
break break
} }
case UITypes.Checkbox: { case UITypes.Checkbox: {
if (['true', '1'].includes(value.toLowerCase())) { if (['true', true, '1', 1].includes(value.toLowerCase())) {
preFillValue = true preFillValue = true
} else if (['false', '0'].includes(value.toLowerCase())) { } else if (['false', false, '0', 0].includes(value.toLowerCase())) {
preFillValue = false preFillValue = false
} }
break break
@ -516,9 +524,31 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
clearInterval(intvl) clearInterval(intvl)
} }
clearForm() clearForm()
clearValidate()
} }
}) })
function isRequired(column: Record<string, any>) {
if (!isVirtualCol(column) && ((column.rqd && !column.cdf) || (column.pk && !(column.ai || column.cdf)) || column.required)) {
return true
} else if (
isLinksOrLTAR(column) &&
column.colOptions &&
(column.colOptions as LinkToAnotherRecordType).type === RelationTypes.BELONGS_TO
) {
const col = columns.value?.find((c) => c.id === (column?.colOptions as LinkToAnotherRecordType)?.fk_child_column_id)
if ((col && col.rqd && !col.cdf) || column.required) {
if (col) {
return true
}
}
} else if (isVirtualCol(column) && column.required) {
return true
}
return false
}
watch(password, (next, prev) => { watch(password, (next, prev) => {
if (next !== prev && passwordError.value) passwordError.value = null if (next !== prev && passwordError.value) passwordError.value = null
}) })
@ -533,6 +563,18 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
}, },
) )
watch(
additionalState,
async () => {
try {
await validate(Object.keys(additionalState.value))
} catch {}
},
{
deep: true,
},
)
return { return {
sharedView, sharedView,
sharedFormView, sharedFormView,
@ -543,7 +585,6 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
progress, progress,
meta, meta,
validators, validators,
v$,
formColumns, formColumns,
formState, formState,
notFound, notFound,
@ -555,8 +596,14 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
isLoading, isLoading,
sharedViewMeta, sharedViewMeta,
onReset: formResetHook.on, onReset: formResetHook.on,
} validate,
}, 'expanded-form-store') validateInfos,
clearValidate,
additionalState,
isRequired,
handleAddMissingRequiredFieldDefaultState,
}
}, 'shared-form-view-store')
export { useProvideSharedFormStore } export { useProvideSharedFormStore }

2
packages/nc-gui/composables/useViewData.ts

@ -283,7 +283,7 @@ export function useViewData(
fk_column_id: c.id, fk_column_id: c.id,
fk_view_id: viewMeta.value?.id, fk_view_id: viewMeta.value?.id,
...(fieldById[c.id!] ? fieldById[c.id!] : {}), ...(fieldById[c.id!] ? fieldById[c.id!] : {}),
meta: { ...parseProp(fieldById[c.id!]?.meta), ...parseProp(c.meta) }, meta: { validators: [], ...parseProp(fieldById[c.id!]?.meta), ...parseProp(c.meta) },
order: (fieldById[c.id!] && fieldById[c.id!].order) || order++, order: (fieldById[c.id!] && fieldById[c.id!].order) || order++,
id: fieldById[c.id!] && fieldById[c.id!].id, id: fieldById[c.id!] && fieldById[c.id!].id,
})) }))

7
packages/nc-gui/lang/en.json

@ -751,7 +751,7 @@
"selectField": "Select a field", "selectField": "Select a field",
"selectFieldLabel": "Make changes to field properties by selecting a field from the list" "selectFieldLabel": "Make changes to field properties by selecting a field from the list"
}, },
"appearanceSettings": "Appearance Settings", "appearanceSettings": "Appearance settings",
"backgroundColor": "Background Color", "backgroundColor": "Background Color",
"hideNocodbBranding": "Hide NocoDB Branding", "hideNocodbBranding": "Hide NocoDB Branding",
"showOnConditions": "Show on condtions", "showOnConditions": "Show on condtions",
@ -942,6 +942,7 @@
"importZip": "Import zip", "importZip": "Import zip",
"metaSync": "Sync Now", "metaSync": "Sync Now",
"settings": "Settings", "settings": "Settings",
"validations": "Validations",
"previewAs": "Preview as", "previewAs": "Preview as",
"resetReview": "Reset Preview", "resetReview": "Reset Preview",
"testDbConn": "Test Database Connection", "testDbConn": "Test Database Connection",
@ -1279,7 +1280,7 @@
"afterEnablePwd": "Access is password restricted", "afterEnablePwd": "Access is password restricted",
"privateLink": "This view is shared via a private link", "privateLink": "This view is shared via a private link",
"privateLinkAdditionalInfo": "People with private link can only see cells visible in this view", "privateLinkAdditionalInfo": "People with private link can only see cells visible in this view",
"postFormSubmissionSettings": "Post Form Submission Settings", "postFormSubmissionSettings": "Post form submission settings",
"apiOptions": "Access Base via", "apiOptions": "Access Base via",
"submitAnotherForm": "Show 'Submit Another Form' button", "submitAnotherForm": "Show 'Submit Another Form' button",
"showBlankForm": "Show a blank form after 5 seconds", "showBlankForm": "Show a blank form after 5 seconds",
@ -1491,7 +1492,7 @@
"theAcceptedFileTypesAreXlsXlsxXlsmOdsOts": "The accepted file types are .xls, .xlsx, .xlsm, .ods, .ots", "theAcceptedFileTypesAreXlsXlsxXlsmOdsOts": "The accepted file types are .xls, .xlsx, .xlsm, .ods, .ots",
"parameterKeyCannotBeEmpty": "Parameter key cannot be empty", "parameterKeyCannotBeEmpty": "Parameter key cannot be empty",
"duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed", "duplicateParameterKeysAreNotAllowed": "Duplicate parameter keys are not allowed",
"fieldRequired": "{value} cannot be empty.", "fieldRequired": "This field cannot be empty.",
"projectNotAccessible": "Base not accessible", "projectNotAccessible": "Base not accessible",
"copyToClipboardError": "Failed to copy to clipboard", "copyToClipboardError": "Failed to copy to clipboard",
"pasteFromClipboardError": "Failed to paste from clipboard", "pasteFromClipboardError": "Failed to paste from clipboard",

48
packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue

@ -8,7 +8,6 @@ const {
sharedFormView, sharedFormView,
submitForm, submitForm,
clearForm, clearForm,
v$,
formState, formState,
notFound, notFound,
formColumns, formColumns,
@ -16,6 +15,8 @@ const {
secondsRemain, secondsRemain,
isLoading, isLoading,
progress, progress,
validateInfos,
validate,
} = useSharedFormStoreOrThrow() } = useSharedFormStoreOrThrow()
const { isMobileMode } = storeToRefs(useConfigStore()) const { isMobileMode } = storeToRefs(useConfigStore())
@ -171,6 +172,7 @@ const onDecode = async (scannedCodeValue: string) => {
</GeneralOverlay> </GeneralOverlay>
<div class="nc-form-wrapper"> <div class="nc-form-wrapper">
<a-form :model="formState">
<div class="nc-form h-full"> <div class="nc-form h-full">
<div class="flex flex-col gap-3 md:gap-6"> <div class="flex flex-col gap-3 md:gap-6">
<div v-for="(field, index) in formColumns" :key="index" class="flex flex-col gap-2"> <div v-for="(field, index) in formColumns" :key="index" class="flex flex-col gap-2">
@ -193,6 +195,11 @@ const onDecode = async (scannedCodeValue: string) => {
<div> <div>
<NcTooltip :disabled="!field?.read_only"> <NcTooltip :disabled="!field?.read_only">
<template #title> {{ $t('activity.preFilledFields.lockedFieldTooltip') }} </template> <template #title> {{ $t('activity.preFilledFields.lockedFieldTooltip') }} </template>
<a-form-item
:name="field.title"
class="!my-0 nc-input-required-error"
v-bind="validateInfos[field.title]"
>
<LazySmartsheetDivDataCell class="flex relative"> <LazySmartsheetDivDataCell class="flex relative">
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-if="isVirtualCol(field)" v-if="isVirtualCol(field)"
@ -218,7 +225,7 @@ const onDecode = async (scannedCodeValue: string) => {
:read-only="field?.read_only" :read-only="field?.read_only"
@update:model-value=" @update:model-value="
() => { () => {
v$.localState[field.title]?.$validate() validate(field.title)
} }
" "
/> />
@ -233,20 +240,8 @@ const onDecode = async (scannedCodeValue: string) => {
</div> </div>
</a-button> </a-button>
</LazySmartsheetDivDataCell> </LazySmartsheetDivDataCell>
</a-form-item>
</NcTooltip> </NcTooltip>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-xs mt-2">
<template v-if="isVirtualCol(field)">
<div v-for="error of v$.virtual[field.title]?.$errors" :key="`${error}virtual`" class="text-red-500">
{{ error.$message }}
</div>
</template>
<template v-else>
<div v-for="error of v$.localState[field.title]?.$errors" :key="error" class="text-red-500">
{{ error.$message }}
</div>
</template>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -277,6 +272,7 @@ const onDecode = async (scannedCodeValue: string) => {
</NcButton> </NcButton>
</div> </div>
</div> </div>
</a-form>
</div> </div>
<div> <div>
<a-divider class="!my-6 !md:my-8" /> <a-divider class="!my-6 !md:my-8" />
@ -305,4 +301,26 @@ const onDecode = async (scannedCodeValue: string) => {
box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe; box-shadow: 0px 0px 0px 2px #fff, 0px 0px 0px 4px #3069fe;
} }
} }
.nc-input-required-error {
max-width: 100%;
white-space: pre-line;
:deep(.ant-form-item-explain-error) {
&:first-child {
@apply mt-2;
}
}
&:focus-within {
:deep(.ant-form-item-explain-error) {
@apply text-gray-400;
}
}
}
:deep(.ant-form-item-has-error .ant-select:not(.ant-select-disabled) .ant-select-selector) {
border: none !important;
}
:deep(.ant-form-item-has-success .ant-select:not(.ant-select-disabled) .ant-select-selector) {
border: none !important;
}
</style> </style>

118
packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue

@ -1,6 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk' import { UITypes, isVirtualCol } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { breakpointsTailwind } from '@vueuse/core' import { breakpointsTailwind } from '@vueuse/core'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
@ -18,8 +17,21 @@ enum AnimationTarget {
const { md } = useBreakpoints(breakpointsTailwind) const { md } = useBreakpoints(breakpointsTailwind)
const { v$, formState, formColumns, submitForm, submitted, secondsRemain, sharedFormView, sharedViewMeta, onReset } = const {
useSharedFormStoreOrThrow() formState,
formColumns,
submitForm,
submitted,
secondsRemain,
sharedFormView,
sharedViewMeta,
onReset,
validateInfos,
validate,
clearValidate,
isRequired,
handleAddMissingRequiredFieldDefaultState,
} = useSharedFormStoreOrThrow()
const { isMobileMode } = storeToRefs(useConfigStore()) const { isMobileMode } = storeToRefs(useConfigStore())
@ -65,31 +77,12 @@ const field = computed(() => formColumns.value?.[index.value])
const fieldHasError = computed(() => { const fieldHasError = computed(() => {
if (field.value?.title) { if (field.value?.title) {
if (isVirtualCol(field.value)) { return validateInfos[field.value.title].validateStatus === 'error'
return v$.value.virtual[field.value.title]?.$error
} else {
return v$.value.localState[field.value.title]?.$error
}
} }
return false return false
}) })
function isRequired(column: ColumnType, required = false) {
let columnObj = column
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
columnObj.colOptions &&
(columnObj.colOptions as { type: RelationTypes }).type === RelationTypes.BELONGS_TO
) {
columnObj = formColumns.value?.find(
(c) => c.id === (columnObj.colOptions as LinkToAnotherRecordType).fk_child_column_id,
) as ColumnType
}
return required || (columnObj && columnObj.rqd && !columnObj.cdf)
}
function transition(direction: TransitionDirection) { function transition(direction: TransitionDirection) {
isTransitioning.value = true isTransitioning.value = true
transitionName.value = direction transitionName.value = direction
@ -116,20 +109,20 @@ function animate(target: AnimationTarget) {
}, transitionDuration.value / 2) }, transitionDuration.value / 2)
} }
const validateField = async (title: string, type: 'cell' | 'virtual') => { const validateField = async (title: string) => {
const validationField = type === 'cell' ? v$.value.localState[title] : v$.value.virtual[title] try {
await validate(title)
if (validationField) {
return await validationField.$validate()
} else {
return true return true
} catch (_e: any) {
return false
} }
} }
async function goNext(animationTarget?: AnimationTarget) { async function goNext(animationTarget?: AnimationTarget) {
if (isLast.value || !isStarted.value || submitted.value || dialogShow.value || !field.value || !field.value.title) return if (isLast.value || !isStarted.value || submitted.value || dialogShow.value || !field.value || !field.value.title) return
if (field.value?.title && !(await validateField(field.value.title, isVirtualCol(field.value) ? 'virtual' : 'cell'))) return if (field.value?.title && !(await validateField(field.value.title))) return
animate(animationTarget || AnimationTarget.ArrowRight) animate(animationTarget || AnimationTarget.ArrowRight)
@ -169,7 +162,7 @@ function focusInput() {
} }
function resetForm() { function resetForm() {
v$.value.$reset() clearValidate()
submitted.value = false submitted.value = false
isStarted.value = false isStarted.value = false
transition(TransitionDirection.Right) transition(TransitionDirection.Right)
@ -186,6 +179,7 @@ onReset(resetForm)
const onStart = () => { const onStart = () => {
isStarted.value = true isStarted.value = true
handleAddMissingRequiredFieldDefaultState()
setTimeout(() => { setTimeout(() => {
focusInput() focusInput()
@ -199,7 +193,7 @@ const handleFocus = () => {
} }
const showSubmitConfirmModal = async () => { const showSubmitConfirmModal = async () => {
if (field.value?.title && !(await validateField(field.value.title, isVirtualCol(field.value) ? 'virtual' : 'cell'))) { if (field.value?.title && !(await validateField(field.value.title))) {
return return
} }
@ -367,6 +361,7 @@ onMounted(() => {
</template> </template>
<template v-if="isStarted && !submitted"> <template v-if="isStarted && !submitted">
<Transition :name="`slide-${transitionName}`" :duration="transitionDuration" mode="out-in"> <Transition :name="`slide-${transitionName}`" :duration="transitionDuration" mode="out-in">
<a-form :model="formState">
<div <div
ref="el" ref="el"
:key="field?.title" :key="field?.title"
@ -381,18 +376,25 @@ onMounted(() => {
<span> <span>
{{ field.label || field.title }} {{ field.label || field.title }}
</span> </span>
<span v-if="isRequired(field, field.required)" class="text-red-500 text-base leading-[18px]">&nbsp;*</span> <span v-if="isRequired(field)" class="text-red-500 text-base leading-[18px]">&nbsp;*</span>
</div> </div>
<div <div
v-if="field?.description" v-if="field?.description"
class="nc-form-column-description text-gray-500 text-sm" class="nc-form-column-description text-gray-500 text-sm"
data-testid="nc-survey-form__field-description" data-testid="nc-survey-form__field-description"
> >
<LazyCellRichText :value="field?.description" class="!h-auto -ml-1" is-form-field read-only sync-value-change /> <LazyCellRichText
:value="field?.description"
class="!h-auto -ml-1"
is-form-field
read-only
sync-value-change
/>
</div> </div>
<NcTooltip :disabled="!field?.read_only"> <NcTooltip :disabled="!field?.read_only">
<template #title> {{ $t('activity.preFilledFields.lockedFieldTooltip') }} </template> <template #title> {{ $t('activity.preFilledFields.lockedFieldTooltip') }} </template>
<a-form-item :name="field.title" class="!my-0 nc-input-required-error" v-bind="validateInfos[field.title]">
<SmartsheetDivDataCell v-if="field.title" class="relative nc-form-data-cell" @click.stop="handleFocus"> <SmartsheetDivDataCell v-if="field.title" class="relative nc-form-data-cell" @click.stop="handleFocus">
<LazySmartsheetVirtualCell <LazySmartsheetVirtualCell
v-if="isVirtualCol(field)" v-if="isVirtualCol(field)"
@ -405,7 +407,7 @@ onMounted(() => {
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`" :data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field" :column="field"
:read-only="field?.read_only" :read-only="field?.read_only"
@update:model-value="validateField(field.title, 'virtual')" @update:model-value="validateField(field.title)"
/> />
<LazySmartsheetCell <LazySmartsheetCell
@ -417,30 +419,20 @@ onMounted(() => {
:column="field" :column="field"
:edit-enabled="!field?.read_only" :edit-enabled="!field?.read_only"
:read-only="field?.read_only" :read-only="field?.read_only"
@update:model-value="validateField(field.title, 'cell')" @update:model-value="validateField(field.title)"
/> />
</SmartsheetDivDataCell>
</a-form-item>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-xs my-2 px-1"> <div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-xs my-2 px-1">
<template v-if="isVirtualCol(field)">
<div v-for="error of v$.virtual[field.title]?.$errors" :key="`${error}virtual`" class="text-red-500">
{{ error.$message }}
</div>
</template>
<template v-else>
<div v-for="error of v$.localState[field.title]?.$errors" :key="error" class="text-red-500">
{{ error.$message }}
</div>
</template>
<div <div
v-if="field.uidt === UITypes.LongText" v-if="field.uidt === UITypes.LongText"
class="hidden text-sm text-gray-500 md:flex flex-wrap items-center" class="hidden text-sm text-gray-500 md:flex flex-wrap items-center"
> >
{{ $t('general.shift') }} <MdiAppleKeyboardShift class="mx-1 text-primary" /> + {{ $t('general.enter') }} {{ $t('general.shift') }} <MdiAppleKeyboardShift class="mx-1 text-primary" /> + {{ $t('general.enter') }}
<MaterialSymbolsKeyboardReturn class="mx-1 text-primary" /> {{ $t('msg.info.makeLineBreak') }} <MaterialSymbolsKeyboardReturn class="mx-1 text-primary" />
{{ $t('msg.info.makeLineBreak') }}
</div> </div>
</div> </div>
</SmartsheetDivDataCell>
</NcTooltip> </NcTooltip>
</div> </div>
@ -487,6 +479,7 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</a-form>
</Transition> </Transition>
</template> </template>
</div> </div>
@ -555,6 +548,31 @@ onMounted(() => {
</div> </div>
</template> </template>
<style lang="scss" scoped>
.nc-input-required-error {
max-width: 100%;
white-space: pre-line;
:deep(.ant-form-item-explain-error) {
&:first-child {
@apply mt-2;
}
}
&:focus-within {
:deep(.ant-form-item-explain-error) {
@apply text-gray-400;
}
}
}
:deep(.ant-form-item-has-error .ant-select:not(.ant-select-disabled) .ant-select-selector) {
border: none !important;
}
:deep(.ant-form-item-has-success .ant-select:not(.ant-select-disabled) .ant-select-selector) {
border: none !important;
}
</style>
<style lang="scss"> <style lang="scss">
:global(html), :global(html),
:global(body) { :global(body) {

109
packages/nc-gui/utils/formValidations.ts

@ -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
}

2
packages/nc-gui/utils/iconUtils.ts

@ -184,6 +184,7 @@ import NcTwitter from '~icons/nc-icons/twitter'
import NcFile from '~icons/nc-icons/file' import NcFile from '~icons/nc-icons/file'
import NcSettings from '~icons/nc-icons/settings' import NcSettings from '~icons/nc-icons/settings'
import NcHelp from '~icons/nc-icons/help' import NcHelp from '~icons/nc-icons/help'
import NcAlertTriangle from '~icons/nc-icons/alert-triangle'
// keep it for reference // keep it for reference
// todo: remove it after all icons are migrated // todo: remove it after all icons are migrated
@ -601,6 +602,7 @@ export const iconMap = {
puzzle: MdiPuzzle, puzzle: MdiPuzzle,
arrowDropUp: MaterialSymbolsArrowDropUp, arrowDropUp: MaterialSymbolsArrowDropUp,
arrowDropDown: MaterialSymbolsArrowDropDown, arrowDropDown: MaterialSymbolsArrowDropDown,
alertTriangle: NcAlertTriangle,
} }
export const getMdiIcon = (type: string): any => { export const getMdiIcon = (type: string): any => {

83
packages/nocodb-sdk/src/lib/form.ts

@ -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,
};

1
packages/nocodb-sdk/src/lib/index.ts

@ -25,3 +25,4 @@ export { default as TemplateGenerator } from '~/lib/TemplateGenerator';
export * from '~/lib/passwordHelpers'; export * from '~/lib/passwordHelpers';
export * from '~/lib/mergeSwaggerSchema'; export * from '~/lib/mergeSwaggerSchema';
export * from '~/lib/dateTimeHelper'; export * from '~/lib/dateTimeHelper';
export * from '~/lib/form';

Loading…
Cancel
Save