diff --git a/packages/nc-gui/assets/nc-icons/alert-triangle.svg b/packages/nc-gui/assets/nc-icons/alert-triangle.svg new file mode 100644 index 0000000000..cf7b71c0a9 --- /dev/null +++ b/packages/nc-gui/assets/nc-icons/alert-triangle.svg @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/packages/nc-gui/components/cell/Currency.vue b/packages/nc-gui/components/cell/Currency.vue index 7bf6cd8c70..1254bc1f0b 100644 --- a/packages/nc-gui/components/cell/Currency.vue +++ b/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() @@ -92,7 +94,7 @@ onMounted(() => { @@ -1669,7 +1487,7 @@ useEventListener(
-
{{ $t('labels.appearanceSettings') }}
+
{{ $t('labels.appearanceSettings') }}
@@ -1753,7 +1571,7 @@ useEventListener(
-
+
{{ $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) => {
-
-
-
-
- - {{ field.label || field.title }} - -  * -
-
- -
- -
- - - - + +
+
+
+
+ + {{ field.label || field.title }} + +  * +
+
+ +
- - + + + -
- -
-
- - + + -
- - + + +
+ +
+
+ + +
-
-
- - {{ $t('activity.clearForm') }} - +
+ + {{ $t('activity.clearForm') }} + - - {{ $t('general.submit') }} - + + {{ $t('general.submit') }} + +
-
+
@@ -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; +} diff --git a/packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue b/packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue index 56d869d9f6..646d728fbd 100644 --- a/packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue +++ b/packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue @@ -1,6 +1,5 @@