+
{{ $t('msg.info.postFormSubmissionSettings') }}
@@ -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) {
diff --git a/packages/nc-gui/components/smartsheet/form/field-config-error.vue b/packages/nc-gui/components/smartsheet/form/field-config-error.vue
new file mode 100644
index 0000000000..368b264fbf
--- /dev/null
+++ b/packages/nc-gui/components/smartsheet/form/field-config-error.vue
@@ -0,0 +1,3 @@
+
+
+
diff --git a/packages/nc-gui/components/smartsheet/form/field-settings.vue b/packages/nc-gui/components/smartsheet/form/field-settings.vue
new file mode 100644
index 0000000000..8d8467998f
--- /dev/null
+++ b/packages/nc-gui/components/smartsheet/form/field-settings.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/nc-gui/composables/useFormViewStore.ts b/packages/nc-gui/composables/useFormViewStore.ts
new file mode 100644
index 0000000000..8c6e56d645
--- /dev/null
+++ b/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
| ComputedRef,
+ viewMeta: Ref | ComputedRef<(ViewType & { id: string }) | undefined>,
+ formViewData: Ref,
+ updateFormView: (view: FormType | undefined) => Promise,
+ isEditable: boolean,
+ ) => {
+ const { $api } = useNuxtApp()
+
+ const { t } = useI18n()
+
+ const formResetHook = createEventHook()
+
+ const formState = ref>({})
+
+ const localColumns = ref[]>([])
+
+ 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)[activeField.value?.fk_column_id]) {
+ return (_meta.value.columnsById as Record)[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 = {}
+
+ 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) => {
+ 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, required = false) {
+ let columnObj = _columnObj
+ if (isLinksOrLTAR(columnObj.uidt) && columnObj.colOptions && columnObj.colOptions.type === RelationTypes.BELONGS_TO) {
+ columnObj = (_meta?.value?.columns || []).find(
+ (c: Record) => c.id === columnObj.colOptions.fk_child_column_id,
+ ) as Record
+ }
+
+ 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
+}
diff --git a/packages/nc-gui/composables/useSharedFormViewStore.ts b/packages/nc-gui/composables/useSharedFormViewStore.ts
index be3df4b4f6..e12c036617 100644
--- a/packages/nc-gui/composables/useSharedFormViewStore.ts
+++ b/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> = {
- localState: {},
- virtual: {},
- }
+ const rulesObj: Record = {}
- 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) {
+ 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 }
diff --git a/packages/nc-gui/composables/useViewData.ts b/packages/nc-gui/composables/useViewData.ts
index 7d4312ab89..4417d827f9 100644
--- a/packages/nc-gui/composables/useViewData.ts
+++ b/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,
}))
diff --git a/packages/nc-gui/lang/en.json b/packages/nc-gui/lang/en.json
index 6006065a98..503ffd3e73 100644
--- a/packages/nc-gui/lang/en.json
+++ b/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",
diff --git a/packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue b/packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
index d89418a536..e5695aab78 100644
--- a/packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
+++ b/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) => {