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 2 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. 301
      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. 211
      packages/nc-gui/composables/useSharedFormViewStore.ts
  12. 2
      packages/nc-gui/composables/useViewData.ts
  13. 7
      packages/nc-gui/lang/en.json
  14. 214
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  15. 286
      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 {
modelValue: number | null | undefined
placeholder?: string
hidePrefix?: boolean
}
const props = defineProps<Props>()
@ -92,7 +94,7 @@ onMounted(() => {
<template>
<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"
>
<span>
@ -106,7 +108,7 @@ onMounted(() => {
type="number"
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'"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:placeholder="placeholder !== undefined ? placeholder : isEditColumn ? $t('labels.optional') : ''"
:disabled="readOnly"
@blur="onBlur"
@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 ""
// otherwise, it is null type
modelValue?: number | null | string
placeholder?: string
}
interface Emits {
@ -101,7 +102,7 @@ watch(isExpandedFormOpen, () => {
class="nc-cell-field outline-none py-1 border-none rounded-md w-full h-full"
type="number"
:step="precision"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:placeholder="placeholder !== undefined ? placeholder : isEditColumn ? $t('labels.optional') : ''"
style="letter-spacing: 0.06rem"
@blur="editEnabled = false"
@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 {
modelValue?: number | string | null
placeholder?: string
}
const props = defineProps<Props>()
@ -144,7 +145,7 @@ const onTabPress = (e: KeyboardEvent) => {
v-model="vModel"
class="nc-cell-field w-full !border-none !outline-none focus:ring-0 py-1"
:type="inputType"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
:placeholder="placeholder !== undefined ? placeholder : isEditColumn ? $t('labels.optional') : ''"
@blur="onBlur"
@focus="onFocus"
@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 isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isForm = inject(IsFormInj)!
const readOnly = inject(ReadonlyInj, ref(false))
@ -33,7 +33,7 @@ const vModel = computed({
get: () => value,
set: (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)
}
},
@ -43,8 +43,6 @@ const validPhoneNumber = computed(() => vModel.value && isMobilePhone(vModel.val
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!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
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 }" />
@ -342,7 +342,7 @@ const handleFileDelete = (i: number) => {
<component
: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"
/>
</NcTooltip>

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

@ -3,17 +3,15 @@ import Draggable from 'vuedraggable'
import tinycolor from 'tinycolor2'
import { Pane, Splitpanes } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'
import type { FormItemProps } from 'ant-design-vue'
import {
type AttachmentResType,
type ColumnType,
ProjectRoles,
RelationTypes,
UITypes,
ViewTypes,
getSystemColumns,
isLinksOrLTAR,
isSelectTypeCol,
isVirtualCol,
} from 'nocodb-sdk'
import type { ImageCropperConfig } from '~/lib/types'
@ -60,10 +58,6 @@ const { base } = storeToRefs(useBase())
const { getPossibleAttachmentSrc } = useAttachment()
const formRef = ref()
const formState = ref<Record<string, any>>({})
const secondsRemain = ref(0)
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 {
formState,
localColumns,
visibleColumns,
activeRow,
activeField,
activeColumn,
isRequired,
updateView,
updateColMeta,
validateInfos,
validate,
clearValidate,
} = useProvideFormViewStore(meta, view, formViewData, updateFormView, isEditable)
const { preFillFormSearchParams } = storeToRefs(useViewsStore())
const reloadEventHook = inject(ReloadViewDataHookInj, createEventHook())
@ -99,8 +108,6 @@ const { state, row } = useProvideSmartsheetRowStore(
const columns = computed(() => meta?.value?.columns || [])
const localColumns = ref<Record<string, any>[]>([])
const draggableRef = ref()
const systemFieldsIds = ref<Record<string, any>[]>([])
@ -116,8 +123,6 @@ const emailMe = ref(false)
const submitted = ref(false)
const activeRow = ref('')
const isLoadingFormView = ref(false)
const showCropper = ref(false)
@ -159,39 +164,14 @@ const autoScrollFormField = ref(false)
const { t } = useI18n()
const { betaFeatureToggleState } = useBetaFeatureToggle()
const { open, onChange: onChangeFile } = useFileDialog({
accept: 'image/*',
multiple: false,
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 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(() => {
if (isLocked.value || !isUIAllowed('dataInsert')) return
@ -211,8 +191,14 @@ const updatePreFillFormSearchParams = useDebounceFn(() => {
async function submitForm() {
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 {
await formRef.value?.validateFields()
await validate([...Object.keys(formState.value)])
} catch (e: any) {
if (e.errorFields.length) {
message.error(t('msg.error.someOfTheRequiredFieldsAreEmpty'))
@ -234,7 +220,7 @@ async function clearForm() {
formState.value = {}
state.value = {}
await formRef.value?.clearValidate()
clearValidate()
reloadEventHook.trigger()
}
@ -418,18 +404,6 @@ function setFormData() {
.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() {
try {
if (!(await checkSMTPStatus())) return
@ -476,20 +450,6 @@ async function deleteColumnCallback() {
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) => {
if (isLocked.value || !isEditable) return
@ -570,51 +530,6 @@ const handleOnUploadImage = (data: AttachmentResType = null) => {
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) => {
;(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 () => {
if (imageCropperData.value.src) {
URL.revokeObjectURL(imageCropperData.value.imageConfig.src)
@ -678,10 +586,8 @@ watch(
updatePreFillFormSearchParams()
try {
await formRef.value?.validateFields([...Object.keys(formState.value)])
} catch (e: any) {
e.errorFields.map((f: Record<string, any>) => console.error(f.errors.join(',')))
}
await validate([...Object.keys(formState.value)])
} catch {}
},
{
deep: true,
@ -951,7 +857,7 @@ useEventListener(
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 -->
<div class="flex flex-col px-4 lg:px-6">
<!-- Form logo -->
@ -1199,7 +1105,7 @@ useEventListener(
<a-form-item
:name="element.title"
class="!my-0 nc-input-required-error nc-form-input-item"
:rules="formElementValidationRules(element)"
v-bind="validateInfos[element.title]"
>
<LazySmartsheetDivDataCell class="relative" @click.stop>
<LazySmartsheetVirtualCell
@ -1225,6 +1131,8 @@ useEventListener(
/>
</LazySmartsheetDivDataCell>
</a-form-item>
<LazySmartsheetFormFieldConfigError :column="element" />
</div>
</div>
</div>
@ -1353,10 +1261,9 @@ useEventListener(
/>
</div>
</div>
<!-- Field text -->
<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
ref="focusLabel"
@ -1383,103 +1290,7 @@ useEventListener(
@update:value="updateColMeta(activeField)"
/>
</div>
<!-- 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>
<LazySmartsheetFormFieldSettings v-if="activeField"></LazySmartsheetFormFieldSettings>
</div>
<!-- Form Settings -->
@ -1638,17 +1449,24 @@ useEventListener(
</div>
</div>
<a-switch
:checked="!!field.show"
:disabled="field.required || isLocked || !isEditable"
class="flex-none nc-switch"
size="small"
@change="
(value) => {
showOrHideColumn(field, value, true)
}
"
/>
<NcTooltip
:disabled="!field.required || isLocked || !isEditable"
class="flex"
placement="topRight"
>
<template #title> You can't hide a required field.</template>
<a-switch
:checked="!!field.show"
:disabled="field.required || isLocked || !isEditable"
class="flex-none nc-switch"
size="small"
@change="
(value) => {
showOrHideColumn(field, value, true)
}
"
/>
</NcTooltip>
</div>
</div>
</template>
@ -1669,7 +1487,7 @@ useEventListener(
<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">
<!-- 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="isLocked || !isEditable ? 'pointer-events-none' : ''">
@ -1753,7 +1571,7 @@ useEventListener(
<div class="p-4 flex flex-col space-y-4">
<!-- 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') }}
</div>
@ -1870,7 +1688,7 @@ useEventListener(
&.layout-list {
@apply h-auto !pl-0 !py-1;
}
&.nc-cell-rating,
&.nc-cell-geodata {
@apply !py-1;
}
@ -1888,7 +1706,7 @@ useEventListener(
@apply p-2;
}
&.nc-virtual-cell {
@apply px-2 py-1;
@apply px-2 py-1 min-h-10;
}
&.nc-cell-json {
@ -1915,7 +1733,16 @@ useEventListener(
max-width: 100%;
white-space: pre-line;
:deep(.ant-form-item-explain-error) {
@apply mt-2;
&:first-child {
@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) {

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
}

211
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 type {
BoolType,
@ -14,8 +12,11 @@ import type {
import { RelationTypes, UITypes, isLinksOrLTAR, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import { isString } from '@vue/shared'
import { useTitle } from '@vueuse/core'
import type { RuleObject } from 'ant-design-vue/es/form'
import { filterNullOrUndefinedObjectProperties } from '~/helpers/parsers/parserHelpers'
const useForm = Form.useForm
const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((sharedViewId: string) => {
const progress = ref(false)
const notFound = ref(false)
@ -74,15 +75,13 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
}),
)
const fieldRequired = (fieldName = 'This field', isBoolean = false) =>
helpers.withMessage(t('msg.error.fieldRequired', { value: fieldName }), isBoolean ? sameAs(true) : required)
const formColumns = computed(() =>
columns.value
?.filter((c) => c.show)
.filter(
(col) => !isSystemColumn(col) && col.uidt !== UITypes.SpecificDBType && (!isVirtualCol(col) || isLinksOrLTAR(col.uidt)),
),
const formColumns = computed(
() =>
columns.value
?.filter((c) => c.show)
.filter(
(col) => !isSystemColumn(col) && col.uidt !== UITypes.SpecificDBType && (!isVirtualCol(col) || isLinksOrLTAR(col.uidt)),
) || [],
)
const loadSharedView = async () => {
@ -122,9 +121,21 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
!/^\w+\(\)|CURRENT_TIMESTAMP$/.test(c.cdf)
) {
const defaultValue = typeof c.cdf === 'string' ? c.cdf.replace(/^'|'$/g, '') : c.cdf
formState.value[c.title] = defaultValue
preFilledDefaultValueformState.value[c.title] = defaultValue
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
preFilledDefaultValueformState.value[c.title] = defaultValue
}
}
return {
@ -178,84 +189,80 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
}
const validators = computed(() => {
const obj: Record<string, Record<string, any>> = {
localState: {},
virtual: {},
}
const rulesObj: Record<string, RuleObject[]> = {}
if (!formColumns.value) return obj
if (!formColumns.value) return rulesObj
for (const column of formColumns.value) {
if (
!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)
let rules: RuleObject[] = []
rules.push({
validator: (_rule: RuleObject, value: any) => {
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'))
}
}
return resolve()
})
},
})
if ((col && col.rqd && !col.cdf) || column.required) {
if (col) {
obj.virtual[column.title!] = { required: fieldRequired() }
}
}
} else if (isVirtualCol(column) && column.required) {
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 (
!isVirtualCol(column) &&
parseProp(column.meta)?.validate &&
[UITypes.URL, UITypes.Email].includes(column.uidt as UITypes)
col.title &&
isRequired(col) &&
formState.value[col.title] === undefined &&
additionalState.value[col.title] === undefined
) {
if (column.uidt === UITypes.URL) {
obj.localState[column.title!] = {
...(obj.localState[column.title!] || {}),
validateFormURL: helpers.withMessage(t('msg.error.invalidURL'), (value) => {
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)) {
obj.localState[column.title!] = {
...(obj.localState[column.title!] || {}),
validateFormNumber: helpers.withMessage(t('msg.plsEnterANumber'), (value) => {
return value ? (column.uidt === UITypes.Number ? /^\d+$/.test(value) : /^\d*\.?\d+$/.test(value)) : true
}),
if (isVirtualCol(col)) {
additionalState.value[col.title] = null
} else {
formState.value[col.title] = null
}
}
}
}
return obj
})
const validateAllFields = async () => {
handleAddMissingRequiredFieldDefaultState()
const v$ = useVuelidate(
validators,
computed(() => ({ localState: formState.value, virtual: additionalState.value })),
)
try {
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
}
}
}
const submitForm = async () => {
try {
if (!(await v$.value?.$validate())) {
message.error(t('msg.error.someOfTheRequiredFieldsAreEmpty'))
if (!(await validateAllFields())) {
return
}
@ -300,7 +307,8 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
...preFilledDefaultValueformState.value,
...(sharedViewMeta.value.preFillEnabled ? preFilledformState.value : {}),
}
v$.value?.$reset()
clearValidate()
}
function handlePreFillForm() {
@ -344,7 +352,7 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
}
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) {
@ -412,9 +420,9 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
break
}
case UITypes.Checkbox: {
if (['true', '1'].includes(value.toLowerCase())) {
if (['true', true, '1', 1].includes(value.toLowerCase())) {
preFillValue = true
} else if (['false', '0'].includes(value.toLowerCase())) {
} else if (['false', false, '0', 0].includes(value.toLowerCase())) {
preFillValue = false
}
break
@ -516,9 +524,31 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
clearInterval(intvl)
}
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) => {
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 {
sharedView,
sharedFormView,
@ -543,7 +585,6 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
progress,
meta,
validators,
v$,
formColumns,
formState,
notFound,
@ -555,8 +596,14 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
isLoading,
sharedViewMeta,
onReset: formResetHook.on,
validate,
validateInfos,
clearValidate,
additionalState,
isRequired,
handleAddMissingRequiredFieldDefaultState,
}
}, 'expanded-form-store')
}, 'shared-form-view-store')
export { useProvideSharedFormStore }

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

@ -283,7 +283,7 @@ export function useViewData(
fk_column_id: c.id,
fk_view_id: viewMeta.value?.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++,
id: fieldById[c.id!] && fieldById[c.id!].id,
}))

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

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

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

@ -8,7 +8,6 @@ const {
sharedFormView,
submitForm,
clearForm,
v$,
formState,
notFound,
formColumns,
@ -16,6 +15,8 @@ const {
secondsRemain,
isLoading,
progress,
validateInfos,
validate,
} = useSharedFormStoreOrThrow()
const { isMobileMode } = storeToRefs(useConfigStore())
@ -171,112 +172,107 @@ const onDecode = async (scannedCodeValue: string) => {
</GeneralOverlay>
<div class="nc-form-wrapper">
<div class="nc-form h-full">
<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 class="nc-form-column-label text-sm font-semibold text-gray-800">
<span>
{{ field.label || field.title }}
</span>
<span v-if="isRequired(field, field.required)" class="text-red-500 text-base leading-[18px]">&nbsp;*</span>
</div>
<div v-if="field?.description" class="nc-form-column-description text-gray-500 text-sm">
<LazyCellRichText
:value="field?.description"
class="!h-auto -ml-1"
is-form-field
read-only
sync-value-change
/>
</div>
<div>
<NcTooltip :disabled="!field?.read_only">
<template #title> {{ $t('activity.preFilledFields.lockedFieldTooltip') }} </template>
<LazySmartsheetDivDataCell class="flex relative">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="null"
class="mt-0 nc-input nc-cell"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:read-only="field?.read_only"
/>
<a-form :model="formState">
<div class="nc-form h-full">
<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 class="nc-form-column-label text-sm font-semibold text-gray-800">
<span>
{{ field.label || field.title }}
</span>
<span v-if="isRequired(field, field.required)" class="text-red-500 text-base leading-[18px]">&nbsp;*</span>
</div>
<div v-if="field?.description" class="nc-form-column-description text-gray-500 text-sm">
<LazyCellRichText
:value="field?.description"
class="!h-auto -ml-1"
is-form-field
read-only
sync-value-change
/>
</div>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input truncate"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="[
`nc-form-input-${field.title?.replaceAll(' ', '')}`,
{ 'layout-list': parseProp(field?.meta)?.isList, 'readonly': field?.read_only },
]"
:column="field"
:edit-enabled="!field?.read_only"
:read-only="field?.read_only"
@update:model-value="
() => {
v$.localState[field.title]?.$validate()
}
"
/>
<a-button
v-if="field.enable_scanner"
class="nc-btn-fill-form-column-by-scan nc-toolbar-btn"
:alt="$t('activity.fillByCodeScan')"
@click="showCodeScannerForFieldTitle(field.title)"
<div>
<NcTooltip :disabled="!field?.read_only">
<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]"
>
<div class="flex items-center gap-1">
<component :is="iconMap.qrCodeScan" class="h-5 w-5" />
</div>
</a-button>
</LazySmartsheetDivDataCell>
</NcTooltip>
<LazySmartsheetDivDataCell class="flex relative">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
:model-value="null"
class="mt-0 nc-input nc-cell"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="[`nc-form-input-${field.title?.replaceAll(' ', '')}`, { readonly: field?.read_only }]"
:column="field"
:read-only="field?.read_only"
/>
<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>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input truncate"
:data-testid="`nc-form-input-cell-${field.label || field.title}`"
:class="[
`nc-form-input-${field.title?.replaceAll(' ', '')}`,
{ 'layout-list': parseProp(field?.meta)?.isList, 'readonly': field?.read_only },
]"
:column="field"
:edit-enabled="!field?.read_only"
:read-only="field?.read_only"
@update:model-value="
() => {
validate(field.title)
}
"
/>
<a-button
v-if="field.enable_scanner"
class="nc-btn-fill-form-column-by-scan nc-toolbar-btn"
:alt="$t('activity.fillByCodeScan')"
@click="showCodeScannerForFieldTitle(field.title)"
>
<div class="flex items-center gap-1">
<component :is="iconMap.qrCodeScan" class="h-5 w-5" />
</div>
</a-button>
</LazySmartsheetDivDataCell>
</a-form-item>
</NcTooltip>
</div>
</div>
</div>
</div>
<div class="flex justify-between items-center mt-6">
<NcButton
html-type="reset"
type="secondary"
:size="isMobileMode ? 'medium' : 'small'"
:disabled="isLoading"
class="nc-shared-form-button shared-form-clear-button"
data-testid="shared-form-clear-button"
@click="clearForm"
>
{{ $t('activity.clearForm') }}
</NcButton>
<div class="flex justify-between items-center mt-6">
<NcButton
html-type="reset"
type="secondary"
:size="isMobileMode ? 'medium' : 'small'"
:disabled="isLoading"
class="nc-shared-form-button shared-form-clear-button"
data-testid="shared-form-clear-button"
@click="clearForm"
>
{{ $t('activity.clearForm') }}
</NcButton>
<NcButton
html-type="submit"
:disabled="progress"
type="primary"
:size="isMobileMode ? 'medium' : 'small'"
class="nc-shared-form-button shared-form-submit-button"
data-testid="shared-form-submit-button"
@click="submitForm"
>
{{ $t('general.submit') }}
</NcButton>
<NcButton
html-type="submit"
:disabled="progress"
type="primary"
:size="isMobileMode ? 'medium' : 'small'"
class="nc-shared-form-button shared-form-submit-button"
data-testid="shared-form-submit-button"
@click="submitForm"
>
{{ $t('general.submit') }}
</NcButton>
</div>
</div>
</div>
</a-form>
</div>
<div>
<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;
}
}
.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>

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

@ -1,6 +1,5 @@
<script lang="ts" setup>
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { UITypes, isVirtualCol } from 'nocodb-sdk'
import { breakpointsTailwind } from '@vueuse/core'
import tinycolor from 'tinycolor2'
@ -18,8 +17,21 @@ enum AnimationTarget {
const { md } = useBreakpoints(breakpointsTailwind)
const { v$, formState, formColumns, submitForm, submitted, secondsRemain, sharedFormView, sharedViewMeta, onReset } =
useSharedFormStoreOrThrow()
const {
formState,
formColumns,
submitForm,
submitted,
secondsRemain,
sharedFormView,
sharedViewMeta,
onReset,
validateInfos,
validate,
clearValidate,
isRequired,
handleAddMissingRequiredFieldDefaultState,
} = useSharedFormStoreOrThrow()
const { isMobileMode } = storeToRefs(useConfigStore())
@ -65,31 +77,12 @@ const field = computed(() => formColumns.value?.[index.value])
const fieldHasError = computed(() => {
if (field.value?.title) {
if (isVirtualCol(field.value)) {
return v$.value.virtual[field.value.title]?.$error
} else {
return v$.value.localState[field.value.title]?.$error
}
return validateInfos[field.value.title].validateStatus === 'error'
}
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) {
isTransitioning.value = true
transitionName.value = direction
@ -116,20 +109,20 @@ function animate(target: AnimationTarget) {
}, transitionDuration.value / 2)
}
const validateField = async (title: string, type: 'cell' | 'virtual') => {
const validationField = type === 'cell' ? v$.value.localState[title] : v$.value.virtual[title]
const validateField = async (title: string) => {
try {
await validate(title)
if (validationField) {
return await validationField.$validate()
} else {
return true
} catch (_e: any) {
return false
}
}
async function goNext(animationTarget?: AnimationTarget) {
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)
@ -169,7 +162,7 @@ function focusInput() {
}
function resetForm() {
v$.value.$reset()
clearValidate()
submitted.value = false
isStarted.value = false
transition(TransitionDirection.Right)
@ -186,6 +179,7 @@ onReset(resetForm)
const onStart = () => {
isStarted.value = true
handleAddMissingRequiredFieldDefaultState()
setTimeout(() => {
focusInput()
@ -199,7 +193,7 @@ const handleFocus = () => {
}
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
}
@ -367,126 +361,125 @@ onMounted(() => {
</template>
<template v-if="isStarted && !submitted">
<Transition :name="`slide-${transitionName}`" :duration="transitionDuration" mode="out-in">
<div
ref="el"
:key="field?.title"
class="flex flex-col gap-4 w-full m-auto rounded-xl border-1 border-gray-200 bg-white p-6 lg:p-12"
>
<div class="select-none text-gray-500 mb-4 md:mb-2" data-testid="nc-survey-form__footer">
{{ index + 1 }} / {{ formColumns?.length }}
</div>
<div v-if="field" class="flex flex-col gap-2">
<div class="nc-form-column-label text-sm font-semibold text-gray-800" data-testid="nc-form-column-label">
<span>
{{ field.label || field.title }}
</span>
<span v-if="isRequired(field, field.required)" class="text-red-500 text-base leading-[18px]">&nbsp;*</span>
</div>
<div
v-if="field?.description"
class="nc-form-column-description text-gray-500 text-sm"
data-testid="nc-survey-form__field-description"
>
<LazyCellRichText :value="field?.description" class="!h-auto -ml-1" is-form-field read-only sync-value-change />
<a-form :model="formState">
<div
ref="el"
:key="field?.title"
class="flex flex-col gap-4 w-full m-auto rounded-xl border-1 border-gray-200 bg-white p-6 lg:p-12"
>
<div class="select-none text-gray-500 mb-4 md:mb-2" data-testid="nc-survey-form__footer">
{{ index + 1 }} / {{ formColumns?.length }}
</div>
<NcTooltip :disabled="!field?.read_only">
<template #title> {{ $t('activity.preFilledFields.lockedFieldTooltip') }} </template>
<SmartsheetDivDataCell v-if="field.title" class="relative nc-form-data-cell" @click.stop="handleFocus">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
v-model="formState[field.title]"
class="mt-0 nc-input h-auto"
:class="{
readonly: field?.read_only,
}"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:read-only="field?.read_only"
@update:model-value="validateField(field.title, 'virtual')"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input h-auto"
:class="{ 'layout-list': parseProp(field?.meta)?.isList, 'readonly': field?.read_only }"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="!field?.read_only"
:read-only="field?.read_only"
@update:model-value="validateField(field.title, 'cell')"
<div v-if="field" class="flex flex-col gap-2">
<div class="nc-form-column-label text-sm font-semibold text-gray-800" data-testid="nc-form-column-label">
<span>
{{ field.label || field.title }}
</span>
<span v-if="isRequired(field)" class="text-red-500 text-base leading-[18px]">&nbsp;*</span>
</div>
<div
v-if="field?.description"
class="nc-form-column-description text-gray-500 text-sm"
data-testid="nc-survey-form__field-description"
>
<LazyCellRichText
:value="field?.description"
class="!h-auto -ml-1"
is-form-field
read-only
sync-value-change
/>
</div>
<NcTooltip :disabled="!field?.read_only">
<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">
<LazySmartsheetVirtualCell
v-if="isVirtualCol(field)"
v-model="formState[field.title]"
class="mt-0 nc-input h-auto"
:class="{
readonly: field?.read_only,
}"
:row="{ row: {}, oldRow: {}, rowMeta: {} }"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:read-only="field?.read_only"
@update:model-value="validateField(field.title)"
/>
<LazySmartsheetCell
v-else
v-model="formState[field.title]"
class="nc-input h-auto"
:class="{ 'layout-list': parseProp(field?.meta)?.isList, 'readonly': field?.read_only }"
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:edit-enabled="!field?.read_only"
:read-only="field?.read_only"
@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">
<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
v-if="field.uidt === UITypes.LongText"
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') }}
<MaterialSymbolsKeyboardReturn class="mx-1 text-primary" /> {{ $t('msg.info.makeLineBreak') }}
<MaterialSymbolsKeyboardReturn class="mx-1 text-primary" />
{{ $t('msg.info.makeLineBreak') }}
</div>
</div>
</SmartsheetDivDataCell>
</NcTooltip>
</div>
</NcTooltip>
</div>
<div class="ml-1 mt-4 flex w-full text-lg">
<div class="flex-1 flex justify-end">
<div v-if="isLast">
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
:class="
animationTarget === AnimationTarget.SubmitButton && isAnimating
? 'transform translate-y-[1px] translate-x-[1px] ring ring-accent ring-opacity-100'
: ''
"
:disabled="fieldHasError"
data-testid="nc-survey-form__btn-submit-confirm"
@click="showSubmitConfirmModal"
>
{{ $t('general.submit') }} form
</NcButton>
</div>
<div class="ml-1 mt-4 flex w-full text-lg">
<div class="flex-1 flex justify-end">
<div v-if="isLast">
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
:class="
animationTarget === AnimationTarget.SubmitButton && isAnimating
? 'transform translate-y-[1px] translate-x-[1px] ring ring-accent ring-opacity-100'
: ''
"
:disabled="fieldHasError"
data-testid="nc-survey-form__btn-submit-confirm"
@click="showSubmitConfirmModal"
>
{{ $t('general.submit') }} form
</NcButton>
</div>
<div v-else class="flex items-center gap-3">
<div
class="hidden md:flex text-sm items-center gap-1"
:class="fieldHasError ? 'text-gray-200' : 'text-gray-800'"
>
<span> {{ $t('labels.pressEnter') }} </span>
<div v-else class="flex items-center gap-3">
<div
class="hidden md:flex text-sm items-center gap-1"
:class="fieldHasError ? 'text-gray-200' : 'text-gray-800'"
>
<span> {{ $t('labels.pressEnter') }} </span>
</div>
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
data-testid="nc-survey-form__btn-next"
class="nc-survey-form__btn-next"
:class="[
animationTarget === AnimationTarget.OkButton && isAnimating
? 'transform translate-y-[2px] translate-x-[2px] after:(!ring !ring-accent !ring-opacity-100)'
: '',
]"
:disabled="fieldHasError"
@click="goNext()"
>
{{ $t('labels.next') }}
</NcButton>
</div>
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
data-testid="nc-survey-form__btn-next"
class="nc-survey-form__btn-next"
:class="[
animationTarget === AnimationTarget.OkButton && isAnimating
? 'transform translate-y-[2px] translate-x-[2px] after:(!ring !ring-accent !ring-opacity-100)'
: '',
]"
:disabled="fieldHasError"
@click="goNext()"
>
{{ $t('labels.next') }}
</NcButton>
</div>
</div>
</div>
</div>
</a-form>
</Transition>
</template>
</div>
@ -555,6 +548,31 @@ onMounted(() => {
</div>
</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">
:global(html),
: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 NcSettings from '~icons/nc-icons/settings'
import NcHelp from '~icons/nc-icons/help'
import NcAlertTriangle from '~icons/nc-icons/alert-triangle'
// keep it for reference
// todo: remove it after all icons are migrated
@ -601,6 +602,7 @@ export const iconMap = {
puzzle: MdiPuzzle,
arrowDropUp: MaterialSymbolsArrowDropUp,
arrowDropDown: MaterialSymbolsArrowDropDown,
alertTriangle: NcAlertTriangle,
}
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/mergeSwaggerSchema';
export * from '~/lib/dateTimeHelper';
export * from '~/lib/form';

Loading…
Cancel
Save