Browse Source

Nc fix: Form view bug fixes (#7899)

* fix(nc-gui): show inline form field validation errors

* fix(nc-gui): display inline validation error in shared form and form builder

* fix(nc-gui): shared form default value issue

* fix(nc-gui): limit option spell mistake

* fix(nc-gui): form title update issue when toggle between grid & form view

* fix(nc-gui): form banner & logo display issue on upload

* chore(nc-gui): lint

* fix(nc-gui): show error message on press non numeric keys in numeric field

* fix(nc-gui): add key for form banner and logo

* fix(nc-gui): show currency suffix only in form

* fix(nc-gui): edit column default value input height issue

* fix(nc-gui): form checkbox field enter keypress should navigate to next question in survey form

* fix(nc-gui): escape should blur focus field in survey form

* fix(nc-gui): add currency code suffix in form view currency field

* chore(nc-gui): lint

* fix(nc-gui): add percent suffix in form view percent field

* fix(nc-gui): survey form pw test fail issue

* fix(nc-gui): filter pw test fail issue

* fix(nc-gui): add missing classname in oss

* fix(nc-gui): survey form ui break issue

* fix(nc-gui): update oss survey form file

* fix(nc-gui): in survey form branding text color should be dynamic based on form bg color

* chore(nc-gui): lint

* fix(nc-gui): ai pr review changes

* fix(nc-gui): pr review changes #2555

* fix(nc-gui): use handler instead on ternery condition
pull/7925/head
Ramesh Mane 4 months ago committed by GitHub
parent
commit
34cc8197d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 19
      packages/nc-gui/components/cell/Checkbox.vue
  2. 11
      packages/nc-gui/components/cell/Currency.vue
  3. 21
      packages/nc-gui/components/cell/Email.vue
  4. 15
      packages/nc-gui/components/cell/Integer.vue
  5. 5
      packages/nc-gui/components/cell/MultiSelect.vue
  6. 38
      packages/nc-gui/components/cell/Percent.vue
  7. 22
      packages/nc-gui/components/cell/SingleSelect.vue
  8. 21
      packages/nc-gui/components/cell/Url.vue
  9. 17
      packages/nc-gui/components/cell/attachment/index.vue
  10. 10
      packages/nc-gui/components/general/FormBanner.vue
  11. 2
      packages/nc-gui/components/smartsheet/Cell.vue
  12. 75
      packages/nc-gui/components/smartsheet/Form.vue
  13. 39
      packages/nc-gui/composables/useSharedFormViewStore.ts
  14. 2
      packages/nc-gui/lang/en.json
  15. 19
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index.vue
  16. 10
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/index.vue
  17. 155
      packages/nc-gui/pages/index/[typeOrId]/form/[viewId]/index/survey.vue
  18. 2
      packages/nc-gui/utils/dataUtils.ts
  19. 2
      tests/playwright/pages/Dashboard/Form/index.ts
  20. 5
      tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts
  21. 1
      tests/playwright/pages/SharedForm/index.ts

19
packages/nc-gui/components/cell/Checkbox.vue

@ -63,7 +63,7 @@ const vModel = computed<boolean | number>({
set: (val: any) => emits('update:modelValue', isMssql(column?.value?.source_id) ? +val : val),
})
function onClick(force?: boolean, event?: MouseEvent) {
function onClick(force?: boolean, event?: MouseEvent | KeyboardEvent) {
if (
(event?.target as HTMLElement)?.classList?.contains('nc-checkbox') ||
(event?.target as HTMLElement)?.closest('.nc-checkbox')
@ -75,6 +75,19 @@ function onClick(force?: boolean, event?: MouseEvent) {
}
}
const keydownEnter = (e: KeyboardEvent) => {
if (!isSurveyForm.value) {
onClick(true, e)
e.stopPropagation()
}
}
const keydownSpace = (e: KeyboardEvent) => {
if (isSurveyForm.value) {
onClick(true, e)
e.stopPropagation()
}
}
useSelectedCellKeyupListener(active, (e) => {
switch (e.key) {
case 'Enter':
@ -101,8 +114,8 @@ useSelectedCellKeyupListener(active, (e) => {
}"
:tabindex="readOnly ? -1 : 0"
@click="onClick(false, $event)"
@keydown.enter.stop="!isSurveyForm ? onClick(true, $event) : undefined"
@keydown.space.stop="isSurveyForm ? onClick(true, $event) : undefined"
@keydown.enter="keydownEnter"
@keydown.space="keydownSpace($event)"
>
<div
class="flex items-center"

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

@ -103,12 +103,21 @@ onMounted(() => {
</script>
<template>
<div
v-if="isForm && !isEditColumn"
class="nc-currency-code h-full !bg-gray-100 border-r border-gray-200 px-3 mr-1 flex items-center"
>
<span>
{{ currencyMeta.currency_code }}
</span>
</div>
<input
v-if="!readOnly && editEnabled"
:ref="focus"
v-model="vModel"
type="number"
class="nc-cell-field w-full h-full text-sm border-none rounded-md py-1 outline-none focus:outline-none focus:ring-0"
class="nc-cell-field h-full text-sm 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') : ''"
@blur="onBlur"
@keydown.enter="onKeydownEnter"

21
packages/nc-gui/components/cell/Email.vue

@ -5,7 +5,6 @@ import {
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
IsSurveyFormInj,
ReadonlyInj,
computed,
inject,
@ -31,12 +30,14 @@ const editEnabled = inject(EditModeInj)!
const column = inject(ColumnInj)!
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
// Used in the logic of when to display error since we are not storing the email if it's not valid
const localState = ref(value)
@ -44,7 +45,7 @@ const vModel = computed({
get: () => value,
set: (val) => {
localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && validateEmail(val)) || !val || isSurveyForm.value) {
if (!parseProp(column.value.meta)?.validate || (val && validateEmail(val)) || !val || isForm.value) {
emit('update:modelValue', val)
}
},
@ -52,17 +53,19 @@ const vModel = computed({
const validEmail = computed(() => vModel.value && validateEmail(vModel.value))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
watch(
() => editEnabled.value,
() => {
if (parseProp(column.value.meta)?.validate && !editEnabled.value && localState.value && !validateEmail(localState.value)) {
if (
!isForm.value &&
parseProp(column.value.meta)?.validate &&
!editEnabled.value &&
localState.value &&
!validateEmail(localState.value)
) {
message.error(t('msg.error.invalidEmail'))
localState.value = undefined
return

15
packages/nc-gui/components/cell/Integer.vue

@ -25,6 +25,10 @@ const isEditColumn = inject(EditColumnInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const _vModel = useVModel(props, 'modelValue', emits)
const displayValue = computed(() => {
@ -42,15 +46,15 @@ const vModel = computed({
// if we clear / empty a cell in sqlite,
// the value is considered as ''
_vModel.value = null
} else if (isForm.value && !isEditColumn.value) {
_vModel.value = isNaN(Number(value)) ? value : Number(value)
} else {
_vModel.value = value
}
},
})
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const inputType = computed(() => (isForm.value && !isEditColumn.value ? 'text' : 'number'))
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
@ -91,7 +95,7 @@ function onKeyDown(e: any) {
:ref="focus"
v-model="vModel"
class="nc-cell-field outline-none py-1 border-none w-full h-full text-sm"
type="number"
:type="inputType"
style="letter-spacing: 0.06rem"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="editEnabled = false"
@ -109,7 +113,8 @@ function onKeyDown(e: any) {
</template>
<style scoped lang="scss">
input[type='number']:focus {
input[type='number']:focus,
input[type='text']:focus {
@apply ring-transparent;
}

5
packages/nc-gui/components/cell/MultiSelect.vue

@ -371,6 +371,9 @@ const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Tab') {
isOpen.value = false
return
} else if (e.key === 'Escape' && isForm.value) {
isOpen.value = false
return
}
e.stopPropagation()
@ -394,7 +397,7 @@ const onFocus = () => {
@click="toggleMenu"
>
<div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
<a-checkbox-group v-model:value="vModel" :disabled="readOnly || !editAllowed" class="nc-field-layout-list">
<a-checkbox-group v-model:value="vModel" :disabled="readOnly || !editAllowed" class="nc-field-layout-list" @click.stop>
<a-checkbox
v-for="op of options"
:key="op.title"

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

@ -24,17 +24,6 @@ const _vModel = useVModel(props, 'modelValue', emits)
const wrapperRef = ref<HTMLElement>()
const vModel = computed({
get: () => _vModel.value,
set: (value) => {
if (value === '') {
_vModel.value = null
} else {
_vModel.value = value
}
},
})
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
@ -46,6 +35,23 @@ const cellFocused = ref(false)
const expandedEditEnabled = ref(false)
const vModel = computed({
get: () => {
return isForm.value && !isEditColumn.value && _vModel.value && !cellFocused.value && !isNaN(Number(_vModel.value))
? `${_vModel.value}%`
: _vModel.value
},
set: (value) => {
if (value === '') {
_vModel.value = null
} else if (isForm.value && !isEditColumn.value) {
_vModel.value = isNaN(Number(value)) ? value : Number(value)
} else {
_vModel.value = value
}
},
})
const percentMeta = computed(() => {
return {
is_progress: false,
@ -53,6 +59,8 @@ const percentMeta = computed(() => {
}
})
const inputType = computed(() => (isForm.value && !isEditColumn.value ? 'text' : 'number'))
const onBlur = () => {
if (editEnabled) {
editEnabled.value = false
@ -106,7 +114,11 @@ const onTabPress = (e: KeyboardEvent) => {
)
for (let i = focusesNcCellIndex - 1; i >= 0; i--) {
const lastFormItem = nodes[i].querySelector('[tabindex="0"]') as HTMLElement
const node = nodes[i]
const lastFormItem = (node.querySelector('[tabindex="0"]') ??
node.querySelector('input') ??
node.querySelector('textarea') ??
node.querySelector('button')) as HTMLElement
if (lastFormItem) {
lastFormItem.focus()
break
@ -132,7 +144,7 @@ const onTabPress = (e: KeyboardEvent) => {
:ref="focus"
v-model="vModel"
class="nc-cell-field w-full !text-sm !border-none !outline-none focus:ring-0 text-base py-1"
type="number"
:type="inputType"
:placeholder="isEditColumn ? $t('labels.optional') : ''"
@blur="onBlur"
@focus="onFocus"

22
packages/nc-gui/components/cell/SingleSelect.vue

@ -257,12 +257,26 @@ const onKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
isOpen.value = false
if (isForm.value) return
setTimeout(() => {
aselect.value?.$el.querySelector('.ant-select-selection-search > input').focus()
}, 100)
}
}
const handleKeyDownList = (e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowRight':
case 'ArrowLeft':
// skip
e.stopPropagation()
break
}
}
const onSelect = () => {
isOpen.value = false
isEditable.value = false
@ -315,7 +329,13 @@ const onFocus = () => {
@keydown.enter.stop.prevent="toggleMenu"
>
<div v-if="!isEditColumn && isForm && parseProp(column.meta)?.isList" class="w-full max-w-full">
<a-radio-group v-model:value="vModel" :disabled="readOnly || !editAllowed" class="nc-field-layout-list">
<a-radio-group
v-model:value="vModel"
:disabled="readOnly || !editAllowed"
class="nc-field-layout-list"
@keydown="handleKeyDownList"
@click.stop
>
<a-radio
v-for="op of options"
:key="op.title"

21
packages/nc-gui/components/cell/Url.vue

@ -7,7 +7,6 @@ import {
EditModeInj,
IsExpandedFormOpenInj,
IsFormInj,
IsSurveyFormInj,
ReadonlyInj,
computed,
inject,
@ -42,10 +41,12 @@ const disableOverlay = inject(CellUrlDisableOverlayInj, ref(false))
const rowHeight = inject(RowHeightInj, ref(undefined))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const readOnly = inject(ReadonlyInj, ref(false))
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
// Used in the logic of when to display error since we are not storing the url if it's not valid
const localState = ref(value)
@ -53,7 +54,7 @@ const vModel = computed({
get: () => value,
set: (val) => {
localState.value = val
if (!parseProp(column.value.meta)?.validate || (val && isValidURL(val)) || !val || isSurveyForm.value) {
if (!parseProp(column.value.meta)?.validate || (val && isValidURL(val)) || !val || isForm.value) {
emit('update:modelValue', val)
}
},
@ -72,17 +73,19 @@ const url = computed(() => {
const { cellUrlOptions } = useCellUrlConfig(url)
const isExpandedFormOpen = inject(IsExpandedFormOpenInj, ref(false))!
const isForm = inject(IsFormInj)!
const focus: VNodeRef = (el) =>
!isExpandedFormOpen.value && !isEditColumn.value && !isForm.value && (el as HTMLInputElement)?.focus()
watch(
() => editEnabled.value,
() => {
if (parseProp(column.value.meta)?.validate && !editEnabled.value && localState.value && !isValidURL(localState.value)) {
if (
!isForm.value &&
parseProp(column.value.meta)?.validate &&
!editEnabled.value &&
localState.value &&
!isValidURL(localState.value)
) {
message.error(t('msg.error.invalidURL'))
localState.value = undefined
return

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

@ -178,6 +178,19 @@ const onImageClick = (item: any) => {
selectedImage.value = item
}
const keydownEnter = (e: KeyboardEvent) => {
if (!isSurveyForm.value) {
open(e)
e.stopPropagation()
}
}
const keydownSpace = (e: KeyboardEvent) => {
if (isSurveyForm.value) {
open(e)
e.stopPropagation()
}
}
</script>
<template>
@ -211,8 +224,8 @@ const onImageClick = (item: any) => {
data-testid="attachment-cell-file-picker-button"
tabindex="0"
@click="open"
@keydown.enter="!isSurveyForm ? open($event) : undefined"
@keydown.space="isSurveyForm ? open($event) : undefined"
@keydown.enter="keydownEnter"
@keydown.space="keydownSpace"
>
<component :is="iconMap.reload" v-if="isLoading" :class="{ 'animate-infinite animate-spin': isLoading }" />

10
packages/nc-gui/components/general/FormBanner.vue

@ -7,6 +7,10 @@ interface Props {
const { bannerImageUrl } = defineProps<Props>()
const { getPossibleAttachmentSrc } = useAttachment()
const getBannerImageSrc = computed(() => {
return getPossibleAttachmentSrc(parseProp(bannerImageUrl))
})
</script>
<template>
@ -15,11 +19,7 @@ const { getPossibleAttachmentSrc } = useAttachment()
:class="!bannerImageUrl ? 'shadow-sm' : ''"
:style="{ aspectRatio: 4 / 1 }"
>
<LazyCellAttachmentImage
v-if="bannerImageUrl"
:srcs="getPossibleAttachmentSrc(parseProp(bannerImageUrl))"
class="nc-form-banner-image object-cover w-full"
/>
<LazyCellAttachmentImage v-if="bannerImageUrl" :srcs="getBannerImageSrc" class="nc-form-banner-image object-cover w-full" />
<div v-else class="h-full flex items-stretch justify-between bg-white">
<div class="flex -mt-1">
<img src="~assets/img/form-banner-left.png" alt="form-banner-left'" />

2
packages/nc-gui/components/smartsheet/Cell.vue

@ -196,7 +196,7 @@ onUnmounted(() => {
{
'text-brand-500': isPrimary(column) && !props.virtual && !isForm && !isCalendar,
'nc-grid-numeric-cell-right': isGrid && isNumericField && !isEditColumnMenu && !isForm && !isExpandedFormOpen,
'h-10': isForm && !isSurveyForm && !isAttachment(column) && !isTextArea(column) && !isJSON(column) && !props.virtual,
'h-10': !isEditColumnMenu && isForm && !isAttachment(column) && !isTextArea(column) && !isJSON(column) && !props.virtual,
'nc-grid-numeric-cell-left': (isForm && isNumericField && isExpandedFormOpen) || isEditColumnMenu,
'!min-h-30': isTextArea(column) && (isForm || isSurveyForm),
},

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

@ -3,6 +3,8 @@ 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,
ProjectRoles,
@ -186,6 +188,8 @@ const { open, onChange: onChangeFile } = useFileDialog({
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 updateView = useDebounceFn(
() => {
updateFormView(formViewData.value)
@ -546,6 +550,50 @@ 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' }),
},
]
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
}
onClickOutside(draggableRef, (e) => {
if (
(e.target as HTMLElement)?.closest(
@ -736,7 +784,7 @@ useEventListener(
<div class="flex justify-center">
<div class="w-full">
<a-alert class="!my-4 !py-4 text-left !rounded-lg" type="success" outlined>
<a-alert class="nc-form-success-msg !my-4 !py-4 text-left !rounded-lg" type="success" outlined>
<template #message>
<LazyCellRichText
v-if="formViewData?.success_msg?.trim()"
@ -806,7 +854,7 @@ useEventListener(
></GeneralImageCropper>
<!-- cover image -->
<div v-if="!parseProp(formViewData?.meta).hide_banner" class="group relative max-w-[max(33%,688px)] mx-auto">
<GeneralFormBanner :banner-image-url="formViewData.banner_image_url" />
<GeneralFormBanner :key="formViewData.banner_image_url?.path" :banner-image-url="formViewData.banner_image_url" />
<div class="absolute bottom-0 right-0 hidden group-hover:block">
<div class="flex items-center space-x-1 m-2">
<NcTooltip :disabled="isEeUI">
@ -880,7 +928,8 @@ useEventListener(
>
<LazyCellAttachmentImage
v-if="formViewData.logo_url"
:srcs="getPossibleAttachmentSrc(parseProp(formViewData.logo_url))"
:key="formViewData.logo_url?.path"
:srcs="getFormLogoSrc"
class="flex-none nc-form-logo !object-contain object-left max-h-full max-w-full !m-0"
/>
<div
@ -973,8 +1022,7 @@ useEventListener(
:bordered="false"
:data-testid="NcForm.heading"
:data-title="NcForm.heading"
@blur="updateView"
@keydown.enter="updateView"
@update:value="updateView"
/>
</a-form-item>
@ -1199,12 +1247,7 @@ useEventListener(
<a-form-item
:name="element.title"
class="!my-0 nc-input-required-error nc-form-input-item"
:rules="[
{
required: isRequired(element, element.required),
message: `${$t('msg.error.fieldRequired', { value: 'This field' })}`,
},
]"
:rules="formElementValidationRules(element)"
>
<LazySmartsheetDivDataCell
class="relative"
@ -1725,6 +1768,9 @@ useEventListener(
&.nc-cell-geodata {
@apply !py-1;
}
&.nc-cell-currency {
@apply !py-0 !pl-0 flex items-stretch;
}
:deep(input) {
@apply !px-1;
@ -1732,8 +1778,11 @@ useEventListener(
&.nc-cell-longtext {
@apply p-0 h-auto;
}
&:not(.nc-cell-longtext) {
@apply px-2 py-2;
&.nc-cell:not(.nc-cell-longtext) {
@apply p-2;
}
&.nc-virtual-cell {
@apply px-2 py-1;
}
&.nc-cell-json {

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

@ -22,6 +22,7 @@ import {
createEventHook,
extractSdkResponseErrorMsg,
isNumericFieldType,
isValidURL,
message,
parseProp,
provide,
@ -34,6 +35,7 @@ import {
useMetas,
useProvideSmartsheetRowStore,
useViewsStore,
validateEmail,
watch,
} from '#imports'
import type { SharedViewMeta } from '#imports'
@ -138,7 +140,7 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
c?.cdf &&
!/^\w+\(\)|CURRENT_TIMESTAMP$/.test(c.cdf)
) {
formState.value[c.title] = c.cdf
formState.value[c.title] = typeof c.cdf === 'string' ? c.cdf.replace(/^'|'$/g, '') : c.cdf
}
return {
@ -197,7 +199,9 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
!isVirtualCol(column) &&
((column.rqd && !column.cdf) || (column.pk && !(column.ai || column.cdf)) || column.required)
) {
obj.localState[column.title!] = { required: fieldRequired() }
obj.localState[column.title!] = {
required: fieldRequired(),
}
} else if (
isLinksOrLTAR(column) &&
column.colOptions &&
@ -216,6 +220,37 @@ const [useProvideSharedFormStore, useSharedFormStore] = useInjectionState((share
required: fieldRequired(),
}
}
if (
!isVirtualCol(column) &&
parseProp(column.meta)?.validate &&
[UITypes.URL, UITypes.Email].includes(column.uidt as UITypes)
) {
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
}),
}
}
}
return obj

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

@ -699,7 +699,7 @@
"hideNocodbBranding": "Hide NocoDB Branding",
"showOnConditions": "Show on condtions",
"showFieldOnConditionsMet": "Shows field only when conditions are met",
"limitOptions": "Limit ptions",
"limitOptions": "Limit options",
"limitOptionsSubtext": "Limit options visible to users by selecting available options",
"clearSelection": "Clear selection"
},

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

@ -28,7 +28,7 @@ router.afterEach((to) => shouldRedirect(to.name as string))
<template>
<div
class="scrollbar-thin-dull h-[100vh] overflow-y-auto overflow-x-hidden color-transition p-4 lg:p-10 nc-form-view relative min-h-[600px]"
class="scrollbar-thin scrollbar-track-transparent scrollbar-thumb-gray-200 hover-scrollbar-thumb-gray-300 h-[100vh] overflow-y-auto overflow-x-hidden flex flex-col color-transition p-4 lg:p-10 nc-form-view min-h-[600px]"
:class="{
'children:(!h-auto my-auto)': sharedViewMeta?.surveyMode,
}"
@ -62,7 +62,8 @@ p {
}
}
.nc-cell {
.nc-cell,
.nc-virtual-cell {
@apply bg-white dark:bg-slate-500 appearance-none;
&.nc-cell-checkbox {
@ -85,7 +86,7 @@ p {
@apply bg-white dark:bg-slate-500;
&.nc-input {
@apply w-full;
@apply w-full h-10;
&:not(.layout-list) {
@apply rounded-lg border-solid border-1 border-gray-200 focus-within:border-brand-500 overflow-hidden;
@ -162,9 +163,12 @@ p {
@apply px-3;
}
}
&:not(.nc-cell-longtext) {
&.nc-cell:not(.nc-cell-longtext) {
@apply p-2;
}
&.nc-virtual-cell {
@apply px-2 py-1;
}
&.nc-cell-json {
@apply h-auto;
@ -177,6 +181,13 @@ p {
input.nc-cell-field {
@apply !py-0 !px-1;
}
&.nc-cell-currency {
@apply !py-0 !pl-0 flex items-stretch;
.nc-currency-code {
@apply !bg-gray-100;
}
}
}
}

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

@ -113,7 +113,7 @@ const onDecode = async (scannedCodeValue: string) => {
<template v-else-if="submitted">
<div class="flex justify-center">
<div v-if="sharedFormView" class="w-full">
<a-alert class="!mt-2 !mb-4 !py-4 text-left !rounded-lg" type="success" outlined>
<a-alert class="nc-shared-form-success-msg !mt-2 !mb-4 !py-4 text-left !rounded-lg" type="success" outlined>
<template #message>
<LazyCellRichText
v-if="sharedFormView?.success_msg?.trim()"
@ -140,7 +140,6 @@ const onDecode = async (scannedCodeValue: string) => {
v-if="sharedFormView?.submit_another_form"
type="secondary"
:size="isMobileMode ? 'medium' : 'small'"
data-testid="nc-survey-form__btn-submit-another-form"
@click="submitted = false"
>
{{ $t('activity.submitAnotherForm') }}
@ -218,6 +217,11 @@ const onDecode = async (scannedCodeValue: string) => {
: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"
@ -232,7 +236,7 @@ const onDecode = async (scannedCodeValue: string) => {
</LazySmartsheetDivDataCell>
</NcTooltip>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-sm mt-2">
<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 }}

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

@ -2,21 +2,19 @@
import type { ColumnType, LinkToAnotherRecordType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isVirtualCol } from 'nocodb-sdk'
import { breakpointsTailwind } from '@vueuse/core'
import tinycolor from 'tinycolor2'
import {
DropZoneRef,
IsSurveyFormInj,
computed,
isValidURL,
onKeyStroke,
onMounted,
provide,
ref,
useBreakpoints,
useI18n,
usePointerSwipe,
useSharedFormStoreOrThrow,
useStepper,
validateEmail,
} from '#imports'
enum TransitionDirection {
@ -36,8 +34,6 @@ const { md } = useBreakpoints(breakpointsTailwind)
const { v$, formState, formColumns, submitForm, submitted, secondsRemain, sharedFormView, sharedViewMeta, onReset } =
useSharedFormStoreOrThrow()
const { t } = useI18n()
const { isMobileMode } = storeToRefs(useConfigStore())
const isTransitioning = ref(false)
@ -80,7 +76,17 @@ const { index, goToPrevious, goToNext, isFirst, isLast, goTo } = useStepper(step
const field = computed(() => formColumns.value?.[index.value])
const columnValidationError = ref(false)
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 false
})
function isRequired(column: ColumnType, required = false) {
let columnObj = column
@ -123,41 +129,20 @@ function animate(target: AnimationTarget) {
}, transitionDuration.value / 2)
}
async function validateColumn() {
const f = field.value!
if (parseProp(f.meta)?.validate && formState.value[f.title!]) {
if (f.uidt === UITypes.Email) {
if (!validateEmail(formState.value[f.title!])) {
columnValidationError.value = true
message.error(t('msg.error.invalidEmail'))
return false
}
} else if (f.uidt === UITypes.URL) {
if (!isValidURL(formState.value[f.title!])) {
columnValidationError.value = true
message.error(t('msg.error.invalidURL'))
return false
}
}
const validateField = async (title: string, type: 'cell' | 'virtual') => {
const validationField = type === 'cell' ? v$.value.localState[title] : v$.value.virtual[title]
if (validationField) {
return await validationField.$validate()
} else {
return true
}
return true
}
async function goNext(animationTarget?: AnimationTarget) {
columnValidationError.value = false
if (isLast.value || !isStarted.value || submitted.value || dialogShow.value || !field.value || !field.value.title) return
if (isLast.value || !isStarted.value || submitted.value) return
if (!field.value || !field.value.title) return
const validationField = v$.value.localState[field.value.title]
if (validationField) {
const isValid = await validationField.$validate()
if (!isValid) return
}
if (!(await validateColumn())) return
if (field.value?.title && !(await validateField(field.value.title, isVirtualCol(field.value) ? 'virtual' : 'cell'))) return
animate(animationTarget || AnimationTarget.ArrowRight)
@ -172,9 +157,7 @@ async function goNext(animationTarget?: AnimationTarget) {
}
async function goPrevious(animationTarget?: AnimationTarget) {
if (isFirst.value || !isStarted.value || submitted.value) return
columnValidationError.value = false
if (isFirst.value || !isStarted.value || submitted.value || dialogShow.value) return
animate(animationTarget || AnimationTarget.ArrowLeft)
@ -188,7 +171,7 @@ function focusInput() {
const inputEl =
(document.querySelector('.nc-cell input') as HTMLInputElement) ||
(document.querySelector('.nc-cell textarea') as HTMLTextAreaElement) ||
(document.querySelector('.nc-cell [tabindex="0"]') as HTMLInputElement)
(document.querySelector('.nc-cell [tabindex="0"]') as HTMLElement)
if (inputEl) {
activeCell.value = inputEl
@ -207,7 +190,7 @@ function resetForm() {
}
async function submit() {
if (submitted.value || !(await validateColumn())) return
if (submitted.value) return
dialogShow.value = false
submitForm()
}
@ -228,13 +211,27 @@ const handleFocus = () => {
}
}
const showSubmitConfirmModal = async () => {
if (field.value?.title && !(await validateField(field.value.title, isVirtualCol(field.value) ? 'virtual' : 'cell'))) {
return
}
dialogShow.value = true
setTimeout(() => {
// NcButton will only focus if document has already focused element
document.querySelector('.nc-survery-form__confirmation_modal div[tabindex="0"]')?.focus()
document.querySelector('.nc-survey-form-btn-submit.nc-button')?.focus()
}, 50)
}
onKeyStroke(['ArrowLeft', 'ArrowDown'], () => {
goPrevious(AnimationTarget.ArrowLeft)
})
onKeyStroke(['ArrowRight', 'ArrowUp'], () => {
goNext(AnimationTarget.ArrowRight)
})
onKeyStroke(['Enter', 'Space'], () => {
onKeyStroke(['Enter'], async (e) => {
if (submitted.value) return
if (!isStarted.value && !submitted.value) {
@ -244,7 +241,8 @@ onKeyStroke(['Enter', 'Space'], () => {
if (dialogShow.value) {
submit()
} else {
dialogShow.value = true
e.preventDefault()
showSubmitConfirmModal()
}
} else {
const activeElement = document.activeElement as HTMLElement
@ -277,16 +275,6 @@ onMounted(() => {
})
}
})
watch(
formState,
() => {
columnValidationError.value = false
},
{
deep: true,
},
)
</script>
<template>
@ -372,10 +360,7 @@ watch(
<div class="flex justify-end mt-12">
<div class="flex items-center gap-3">
<div class="hidden md:flex text-sm items-center gap-1 text-gray-800">
<span>
{{ $t('labels.pressEnter') }}
</span>
<NcBadge class="pl-4 pr-1 h-[21px] text-gray-600"> </NcBadge>
<span> {{ $t('labels.pressEnter') }} </span>
</div>
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
@ -429,6 +414,7 @@ watch(
:data-testid="`nc-survey-form__input-${field.title.replaceAll(' ', '')}`"
:column="field"
:read-only="field?.read_only"
@update:model-value="validateField(field.title, 'virtual')"
/>
<LazySmartsheetCell
@ -440,12 +426,20 @@ watch(
:column="field"
:edit-enabled="!field?.read_only"
:read-only="field?.read_only"
@update:model-value="validateField(field.title, 'cell')"
/>
<div class="flex flex-col gap-2 text-slate-500 dark:text-slate-300 text-[0.75rem] my-2 px-1">
<div v-for="error of v$.localState[field.title]?.$errors" :key="error" class="text-red-500">
{{ error.$message }}
</div>
<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="text-sm text-gray-500 flex flex-wrap items-center">
{{ $t('general.shift') }} <MdiAppleKeyboardShift class="mx-1 text-primary" /> + {{ $t('general.enter') }}
@ -458,7 +452,7 @@ watch(
<div class="ml-1 mt-4 flex w-full text-lg">
<div class="flex-1 flex justify-end">
<div v-if="isLast && !v$.$invalid">
<div v-if="isLast">
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
:class="
@ -466,9 +460,9 @@ watch(
? 'transform translate-y-[1px] translate-x-[1px] ring ring-accent ring-opacity-100'
: ''
"
:disabled="v$.localState[field.title]?.$error || columnValidationError"
:disabled="fieldHasError"
data-testid="nc-survey-form__btn-submit-confirm"
@click="dialogShow = true"
@click="showSubmitConfirmModal"
>
{{ $t('general.submit') }} form
</NcButton>
@ -477,17 +471,9 @@ watch(
<div v-else class="flex items-center gap-3">
<div
class="hidden md:flex text-sm items-center gap-1"
:class="v$.localState[field.title]?.$error || columnValidationError ? 'text-gray-200' : 'text-gray-800'"
:class="fieldHasError ? 'text-gray-200' : 'text-gray-800'"
>
<span>
{{ $t('labels.pressEnter') }}
</span>
<NcBadge
class="pl-4 pr-1 h-[21px]"
:class="v$.localState[field.title]?.$error || columnValidationError ? 'text-gray-200' : 'text-gray-600'"
>
</NcBadge>
<span> {{ $t('labels.pressEnter') }} </span>
</div>
<NcButton
:size="isMobileMode ? 'medium' : 'small'"
@ -498,7 +484,7 @@ watch(
? 'transform translate-y-[2px] translate-x-[2px] after:(!ring !ring-accent !ring-opacity-100)'
: '',
]"
:disabled="v$.localState[field.title]?.$error || columnValidationError"
:disabled="fieldHasError"
@click="goNext()"
>
{{ $t('labels.next') }}
@ -513,14 +499,26 @@ watch(
<div class="md:(absolute bottom-0 left-0 right-0 px-4 pb-4) lg:px-10 lg:pb-10">
<div class="flex justify-end items-center gap-4">
<div class="flex justify-center">
<GeneralFormBranding class="inline-flex mx-auto" />
<GeneralFormBranding
class="inline-flex mx-auto"
:style="{
color: tinycolor.isReadable(parseProp(sharedFormView?.meta)?.background_color || '#F9F9FA', '#D5D5D9', {
level: 'AA',
size: 'large',
})
? '#fff'
: tinycolor
.mostReadable(parseProp(sharedFormView?.meta)?.background_color || '#F9F9FA', ['#374151', '#D5D5D9'])
.toHex8String(),
}"
/>
</div>
<div v-if="isStarted && !submitted" class="flex items-center gap-3">
<NcButton
type="secondary"
:size="isMobileMode ? 'medium' : 'small'"
data-testid="nc-survey-form__icon-prev"
:disabled="isFirst || v$.localState[field.title]?.$error"
:disabled="isFirst"
@click="goPrevious()"
>
<GeneralIcon icon="ncArrowLeft"
@ -530,7 +528,7 @@ watch(
:size="isMobileMode ? 'medium' : 'small'"
type="secondary"
data-testid="nc-survey-form__icon-next"
:disabled="isLast || v$.localState[field.title]?.$error || columnValidationError"
:disabled="isLast || fieldHasError"
@click="goNext()"
>
<GeneralIcon icon="ncArrowRight" />
@ -552,6 +550,7 @@ watch(
type="primary"
:size="isMobileMode ? 'medium' : 'small'"
data-testid="nc-survey-form__btn-submit"
class="nc-survey-form-btn-submit"
@click="submit"
>
{{ $t('general.submit') }}

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

@ -107,7 +107,7 @@ export const rowDefaultData = (columns: ColumnType[] = []) => {
!/^\w+\(\)|CURRENT_TIMESTAMP$/.test(col.cdf)
) {
const defaultValue = col.cdf
acc[col.title!] = typeof defaultValue === 'string' ? defaultValue.replace(/^'/, '').replace(/'$/, '') : defaultValue
acc[col.title!] = typeof defaultValue === 'string' ? defaultValue.replace(/^'|'$/g, '') : defaultValue
}
return acc
}, {} as Record<string, any>)

2
tests/playwright/pages/Dashboard/Form/index.ts

@ -250,6 +250,8 @@ export class FormPage extends BasePage {
}
async verifyStatePostSubmit(param: { message?: string; submitAnotherForm?: boolean; showBlankForm?: boolean }) {
await this.rootPage.locator('.nc-form-success-msg').waitFor({ state: 'visible' });
if (undefined !== param.message) {
await expect(this.getFormAfterSubmit()).toContainText(param.message);
}

5
tests/playwright/pages/Dashboard/common/Toolbar/Filter.ts

@ -338,7 +338,10 @@ export class ToolbarFilterPage extends BasePage {
break;
default:
fillFilter = () => this.rootPage.locator('.nc-filter-value-select > input').last().fill(value);
fillFilter = async () => {
await this.rootPage.locator('.nc-filter-value-select > input').last().clear({ force: true });
return this.rootPage.locator('.nc-filter-value-select > input').last().fill(value);
};
await this.waitForResponse({
uiAction: fillFilter,
httpMethodsToMatch: ['GET'],

1
tests/playwright/pages/SharedForm/index.ts

@ -25,6 +25,7 @@ export class SharedFormPage extends BasePage {
}
async verifySuccessMessage() {
await this.rootPage.locator('.nc-shared-form-success-msg').waitFor({ state: 'visible' });
await expect(
this.get().locator('.ant-alert-success', {
hasText: 'Successfully submitted form data',

Loading…
Cancel
Save