Browse Source

Nc test/form view validations (#8548)

* fix(nc-gui): bulk update field header issue

* test: add custom validation test cases

* test: add custom validation test cases

* test: custom validation all type test cases

* test: add email, phone number validation test cases

* test: fix form fill textarea issue

* test: add form validation shared view test cases

* test: add survey form custom validation test cases

* test: add limit to range survery form test cases

* test: add attachment validation survey form test cases

* chore(nc-gui): lint
pull/8575/head
Ramesh Mane 6 months ago committed by GitHub
parent
commit
b5962ad591
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      packages/nc-gui/components/cell/DatePicker.vue
  2. 6
      packages/nc-gui/components/cell/DateTimePicker.vue
  3. 6
      packages/nc-gui/components/cell/TimePicker.vue
  4. 6
      packages/nc-gui/components/cell/YearPicker.vue
  5. 4
      packages/nc-gui/components/smartsheet/Form.vue
  6. 27
      packages/nc-gui/components/smartsheet/header/Cell.vue
  7. 23
      packages/nc-gui/components/smartsheet/header/VirtualCell.vue
  8. 2
      packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue
  9. 1
      packages/nc-gui/context/index.ts
  10. 694
      tests/playwright/pages/Dashboard/Form/index.ts
  11. 58
      tests/playwright/pages/Dashboard/SurveyForm/index.ts
  12. 16
      tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts
  13. 17
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  14. 32
      tests/playwright/pages/SharedForm/index.ts
  15. 926
      tests/playwright/tests/db/views/viewForm.spec.ts

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

@ -29,6 +29,8 @@ const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isDateInvalid = ref(false)
@ -193,6 +195,9 @@ const handleKeydown = (e: KeyboardEvent, _open?: boolean) => {
switch (e.key) {
case 'Enter':
e.preventDefault()
if (isSurveyForm.value) {
e.stopPropagation()
}
localState.value = tempDate.value
open.value = !_open

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

@ -25,6 +25,8 @@ const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const { t } = useI18n()
const isEditColumn = inject(EditColumnInj, ref(false))
@ -225,6 +227,10 @@ const handleKeydown = (e: KeyboardEvent, _open?: boolean, _isDatePicker: boolean
switch (e.key) {
case 'Enter':
e.preventDefault()
if (isSurveyForm.value) {
e.stopPropagation()
}
localState.value = tempDate.value
if (!_isDatePicker) {
e.stopPropagation()

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

@ -27,6 +27,8 @@ const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const column = inject(ColumnInj)!
@ -171,6 +173,10 @@ const handleKeydown = (e: KeyboardEvent, _open?: boolean) => {
switch (e.key) {
case 'Enter':
e.preventDefault()
if (isSurveyForm.value) {
e.stopPropagation()
}
localState.value = tempDate.value
open.value = !_open
if (!open.value) {

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

@ -27,6 +27,8 @@ const isGrid = inject(IsGridInj, ref(false))
const isForm = inject(IsFormInj, ref(false))
const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isYearInvalid = ref(false)
@ -157,6 +159,10 @@ const handleKeydown = (e: KeyboardEvent, _open?: boolean) => {
switch (e.key) {
case 'Enter':
e.preventDefault()
if (isSurveyForm.value) {
e.stopPropagation()
}
localState.value = tempDate.value
open.value = !_open
if (!open.value) {

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

@ -1196,7 +1196,7 @@ useEventListener(
}"
>
<!-- Form Field settings -->
<div v-if="activeField && activeColumn" :key="activeField?.id">
<div v-if="activeField && activeColumn" :key="activeField?.id" class="nc-form-field-right-panel">
<!-- Field header -->
<div class="px-3 pt-4 pb-2 flex items-center justify-between border-b border-gray-200 font-medium">
<div class="flex items-center">
@ -1297,7 +1297,7 @@ useEventListener(
<!-- Form Settings -->
<template v-else>
<Splitpanes v-if="formViewData" horizontal class="w-full nc-form-right-splitpane">
<Splitpanes v-if="formViewData" horizontal class="nc-form-settings w-full nc-form-right-splitpane">
<Pane min-size="30" size="50" class="nc-form-right-splitpane-item p-4 flex flex-col space-y-4 !min-h-200px">
<div class="flex flex-wrap justify-between items-center gap-2">
<div class="flex gap-3">

27
packages/nc-gui/components/smartsheet/header/Cell.vue

@ -26,6 +26,8 @@ const isSurveyForm = inject(IsSurveyFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isExpandedBulkUpdateForm = inject(IsExpandedBulkUpdateFormOpenInj, ref(false))
const isDropDownOpen = ref(false)
const isKanban = inject(IsKanbanInj, ref(false))
@ -58,7 +60,7 @@ const closeAddColumnDropdown = () => {
}
const openHeaderMenu = (e?: MouseEvent) => {
if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick')) return
if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return
if (!isForm.value && isUIAllowed('fieldEdit') && !isMobileMode.value) {
editColumnDropdown.value = true
@ -83,7 +85,7 @@ const onClick = (e: Event) => {
e.preventDefault()
e.stopPropagation()
} else {
if (isExpandedForm.value && !editColumnDropdown.value) {
if (isExpandedForm.value && !editColumnDropdown.value && !isExpandedBulkUpdateForm.value) {
isDropDownOpen.value = true
return
}
@ -99,9 +101,10 @@ const onClick = (e: Event) => {
:class="{
'h-full': column,
'!text-gray-400': isKanban,
'flex-col !items-start justify-center pt-0.5': isExpandedForm && !isMobileMode,
'cursor-pointer hover:bg-gray-100': isExpandedForm && !isMobileMode && isUIAllowed('fieldEdit'),
'bg-gray-100': isExpandedForm ? editColumnDropdown || isDropDownOpen : false,
'flex-col !items-start justify-center pt-0.5': isExpandedForm && !isMobileMode && !isExpandedBulkUpdateForm,
'cursor-pointer hover:bg-gray-100':
isExpandedForm && !isMobileMode && isUIAllowed('fieldEdit') && !isExpandedBulkUpdateForm,
'bg-gray-100': isExpandedForm && !isExpandedBulkUpdateForm ? editColumnDropdown || isDropDownOpen : false,
}"
@dblclick="openHeaderMenu"
@click.right="openDropDown"
@ -111,7 +114,7 @@ const onClick = (e: Event) => {
class="nc-cell-name-wrapper flex-1 flex items-center"
:class="{
'max-w-[calc(100%_-_23px)]': !isExpandedForm,
'max-w-full': isExpandedForm,
'max-w-full': isExpandedForm && !isExpandedBulkUpdateForm,
}"
>
<template v-if="column && !props.hideIcon">
@ -119,7 +122,7 @@ const onClick = (e: Event) => {
v-if="isGrid"
class="flex items-center"
placement="bottom"
:disabled="isExpandedForm ? editColumnDropdown || isDropDownOpen : false"
:disabled="isExpandedForm && !isExpandedBulkUpdateForm ? editColumnDropdown || isDropDownOpen : false"
>
<template #title> {{ columnTypeName }} </template>
<SmartsheetHeaderCellIcon
@ -145,14 +148,14 @@ const onClick = (e: Event) => {
class="name pl-1 max-w-full"
placement="bottom"
show-on-truncate-only
:disabled="isExpandedForm ? editColumnDropdown || isDropDownOpen : false"
:disabled="isExpandedForm && !isExpandedBulkUpdateForm ? editColumnDropdown || isDropDownOpen : false"
>
<template #title> {{ column.title }} </template>
<span
:data-test-id="column.title"
:class="{
'select-none': isExpandedForm,
'select-none': isExpandedForm && !isExpandedBulkUpdateForm,
}"
>
{{ column.title }}
@ -162,7 +165,7 @@ const onClick = (e: Event) => {
<span v-if="(column.rqd && !column.cdf) || required" class="text-red-500">&nbsp;*</span>
<GeneralIcon
v-if="isExpandedForm && !isMobileMode && isUIAllowed('fieldEdit')"
v-if="isExpandedForm && !isExpandedBulkUpdateForm && !isMobileMode && isUIAllowed('fieldEdit')"
icon="arrowDown"
class="flex-none cursor-pointer ml-1 group-hover:visible w-4 h-4"
:class="{
@ -187,10 +190,10 @@ const onClick = (e: Event) => {
v-model:visible="editColumnDropdown"
class="h-full"
:trigger="['click']"
:placement="isExpandedForm ? 'bottomLeft' : 'bottomRight'"
:placement="isExpandedForm && !isExpandedBulkUpdateForm ? 'bottomLeft' : 'bottomRight'"
overlay-class-name="nc-dropdown-edit-column"
>
<div v-if="isExpandedForm" class="h-[1px]" @dblclick.stop>&nbsp;</div>
<div v-if="isExpandedForm && !isExpandedBulkUpdateForm" class="h-[1px]" @dblclick.stop>&nbsp;</div>
<div v-else />
<template #overlay>

23
packages/nc-gui/components/smartsheet/header/VirtualCell.vue

@ -46,6 +46,8 @@ const isForm = inject(IsFormInj, ref(false))
const isExpandedForm = inject(IsExpandedFormOpenInj, ref(false))
const isExpandedBulkUpdateForm = inject(IsExpandedBulkUpdateFormOpenInj, ref(false))
const colOptions = computed(() => column.value?.colOptions)
const tableTile = computed(() => meta?.value?.title)
@ -140,7 +142,7 @@ const closeAddColumnDropdown = () => {
}
const openHeaderMenu = (e?: MouseEvent) => {
if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick')) return
if (isLocked.value || (isExpandedForm.value && e?.type === 'dblclick') || isExpandedBulkUpdateForm.value) return
if (!isForm.value && isUIAllowed('fieldEdit') && !isMobileMode.value) {
editColumnDropdown.value = true
@ -165,7 +167,7 @@ const onClick = (e: Event) => {
e.preventDefault()
e.stopPropagation()
} else {
if (isExpandedForm.value && !editColumnDropdown.value) {
if (isExpandedForm.value && !editColumnDropdown.value && !isExpandedBulkUpdateForm.value) {
isDropDownOpen.value = true
return
}
@ -179,9 +181,10 @@ const onClick = (e: Event) => {
<div
class="flex items-center w-full h-full text-small text-gray-500 font-weight-medium group"
:class="{
'flex-col !items-start justify-center pt-0.5': isExpandedForm && !isMobileMode,
'bg-gray-100': isExpandedForm ? editColumnDropdown || isDropDownOpen : false,
'cursor-pointer hover:bg-gray-100': isExpandedForm && !isMobileMode && isUIAllowed('fieldEdit'),
'flex-col !items-start justify-center pt-0.5': isExpandedForm && !isMobileMode && !isExpandedBulkUpdateForm,
'bg-gray-100': isExpandedForm && !isExpandedBulkUpdateForm ? editColumnDropdown || isDropDownOpen : false,
'cursor-pointer hover:bg-gray-100':
isExpandedForm && !isMobileMode && isUIAllowed('fieldEdit') && !isExpandedBulkUpdateForm,
}"
@dblclick="openHeaderMenu"
@click.right="openDropDown"
@ -191,7 +194,7 @@ const onClick = (e: Event) => {
class="nc-virtual-cell-name-wrapper flex-1 flex items-center"
:class="{
'max-w-[calc(100%_-_23px)]': !isExpandedForm,
'max-w-full': isExpandedForm,
'max-w-full': isExpandedForm && !isExpandedBulkUpdateForm,
}"
>
<template v-if="column && !props.hideIcon">
@ -208,7 +211,7 @@ const onClick = (e: Event) => {
<span
:data-test-id="column.title"
:class="{
'select-none': isExpandedForm,
'select-none': isExpandedForm && !isExpandedBulkUpdateForm,
}"
>
{{ column.title }}
@ -218,7 +221,7 @@ const onClick = (e: Event) => {
<span v-if="isVirtualColRequired(column, meta?.columns || []) || required" class="text-red-500">&nbsp;*</span>
<GeneralIcon
v-if="isExpandedForm && !isMobileMode && isUIAllowed('fieldEdit')"
v-if="isExpandedForm && !isMobileMode && isUIAllowed('fieldEdit') && !isExpandedBulkUpdateForm"
icon="arrowDown"
class="flex-none cursor-pointer ml-1 group-hover:visible w-4 h-4"
:class="{
@ -244,10 +247,10 @@ const onClick = (e: Event) => {
v-model:visible="editColumnDropdown"
class="h-full"
:trigger="['click']"
:placement="isExpandedForm ? 'bottomLeft' : 'bottomRight'"
:placement="isExpandedForm && !isExpandedBulkUpdateForm ? 'bottomLeft' : 'bottomRight'"
overlay-class-name="nc-dropdown-edit-column"
>
<div v-if="isExpandedForm" class="h-[1px]" @dblclick.stop>&nbsp;</div>
<div v-if="isExpandedForm && !isExpandedBulkUpdateForm" class="h-[1px]" @dblclick.stop>&nbsp;</div>
<div v-else />
<template #overlay>
<SmartsheetColumnEditOrAddProvider

2
packages/nc-gui/components/smartsheet/toolbar/ColumnFilterMenu.vue

@ -85,8 +85,8 @@ eventBus.on(async (event, column: ColumnType) => {
class="nc-table-toolbar-menu"
:auto-save="true"
data-testid="nc-filter-menu"
@update:filters-length="filtersLength = $event"
:is-open="open"
@update:filters-length="filtersLength = $event"
>
</SmartsheetToolbarColumnFilter>
</template>

1
packages/nc-gui/context/index.ts

@ -19,6 +19,7 @@ export const IsGalleryInj: InjectionKey<Ref<boolean>> = Symbol('is-gallery-injec
export const IsKanbanInj: InjectionKey<Ref<boolean>> = Symbol('is-kanban-injection')
export const IsLockedInj: InjectionKey<Ref<boolean>> = Symbol('is-locked-injection')
export const IsExpandedFormOpenInj: InjectionKey<Ref<boolean>> = Symbol('is-expanded-form-open-injection')
export const IsExpandedBulkUpdateFormOpenInj: InjectionKey<Ref<boolean>> = Symbol('is-expanded-bulk-update-form-open-injection')
export const CellValueInj: InjectionKey<Ref<any>> = Symbol('cell-value-injection')
export const ActiveViewInj: InjectionKey<Ref<ViewType>> = Symbol('active-view-injection')
export const ReadonlyInj: InjectionKey<Ref<boolean>> = Symbol('readonly-injection')

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

@ -1,4 +1,5 @@
import { expect, Locator } from '@playwright/test';
import { StringValidationType, UITypes } from 'nocodb-sdk';
import { DashboardPage } from '..';
import BasePage from '../../Base';
import { ToolbarPage } from '../common/Toolbar';
@ -23,6 +24,11 @@ export class FormPage extends BasePage {
readonly formFields: Locator;
// validation
readonly fieldPanel: Locator;
readonly customValidationBtn: Locator;
readonly customValidationDropdown: Locator;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
@ -45,6 +51,11 @@ export class FormPage extends BasePage {
this.afterSubmitMsg = dashboard.get().locator('[data-testid="nc-form-after-submit-msg"] .tiptap.ProseMirror');
this.formFields = dashboard.get().locator('.nc-form-fields-list');
// validation
this.fieldPanel = dashboard.get().locator('.nc-form-field-right-panel');
this.customValidationBtn = this.fieldPanel.locator('.nc-custom-validation-btn');
this.customValidationDropdown = this.rootPage.locator('.nc-custom-validator-dropdown');
}
get() {
@ -106,7 +117,7 @@ export class FormPage extends BasePage {
await src.dragTo(dst);
}
async removeField({ field, mode }: { mode: string; field: string }) {
async removeField({ field, mode }: { mode: 'dragDrop' | 'hideField'; field: string }) {
// TODO: Otherwise form input boxes are not visible sometimes
await this.rootPage.waitForTimeout(650);
@ -177,17 +188,53 @@ export class FormPage extends BasePage {
await expect.poll(async () => await this.formSubHeading.textContent()).toBe(param.subtitle);
}
async fillForm(param: { field: string; value: string }[]) {
async fillForm(param: { field: string; value: string; type?: UITypes }[]) {
for (let i = 0; i < param.length; i++) {
await this.get()
.locator(`[data-testid="nc-form-input-${param[i].field.replace(' ', '')}"]`)
.click();
await this.get()
.locator(`[data-testid="nc-form-input-${param[i].field.replace(' ', '')}"] >> input`)
.fill(param[i].value);
switch (param[i].type) {
case UITypes.LongText: {
await this.get()
.locator(`[data-testid="nc-form-input-${param[i].field.replace(' ', '')}"] >> textarea`)
.fill(param[i].value);
break;
}
default: {
await this.get()
.locator(`[data-testid="nc-form-input-${param[i].field.replace(' ', '')}"] >> input`)
.fill(param[i].value);
if ([UITypes.Date, UITypes.Time, UITypes.Year, UITypes.DateTime].includes(param[i].type)) {
await this.rootPage.keyboard.press('Enter');
}
await this.getVisibleField({ title: param[i].field }).click();
}
}
await this.rootPage.waitForTimeout(200);
}
}
getVisibleField({ title }: { title: string }) {
return this.get()
.locator(`.nc-form-drag-${title.replace(' ', '')}`)
.locator('[data-testid="nc-form-input-label"]');
}
async selectVisibleField({ title }: { title: string }) {
const field = this.getVisibleField({ title });
await field.scrollIntoViewIfNeeded();
await field.click();
// Wait for field settings right pannel
await this.fieldPanel.waitFor({ state: 'visible' });
}
async configureField({
field,
required,
@ -206,10 +253,7 @@ export class FormPage extends BasePage {
httpMethodsToMatch: ['PATCH'],
});
await this.get()
.locator(`.nc-form-drag-${field.replace(' ', '')}`)
.locator('[data-testid="nc-form-input-label"]')
.click();
await this.selectVisibleField({ title: field });
await waitForResponse(() => this.getFormFieldsInputLabel().fill(label));
await waitForResponse(() => this.getFormFieldsInputHelpText().fill(helpText));
@ -234,9 +278,8 @@ export class FormPage extends BasePage {
if (required) expectText = label + ' *';
else expectText = label;
const fieldLabel = this.get()
.locator(`.nc-form-drag-${field.replace(' ', '')}`)
.locator('div[data-testid="nc-form-input-label"]');
const fieldLabel = this.getVisibleField({ title: field });
await fieldLabel.scrollIntoViewIfNeeded();
await expect(fieldLabel).toHaveText(expectText);
const fieldHelpText = this.get()
@ -300,4 +343,631 @@ export class FormPage extends BasePage {
).toBeVisible();
}
}
async getCustomValidationTypeOption({
type,
currValItem,
index,
}: {
type: StringValidationType;
currValItem: Locator;
index: number;
}) {
await currValItem.locator('.nc-custom-validation-type-selector .ant-select-selector').click();
const typeSelectorDropdown = this.rootPage.locator(`.nc-custom-validation-type-dropdown-${index}`);
await typeSelectorDropdown.waitFor();
const option = typeSelectorDropdown.getByTestId(`nc-custom-validation-type-option-${type}`);
await option.scrollIntoViewIfNeeded();
return {
option: this.rootPage.getByTestId(`nc-custom-validation-type-option-${type}`),
select: async () => {
await option.click();
await typeSelectorDropdown.waitFor({ state: 'hidden' });
},
closeSelector: async () =>
await currValItem.locator('.nc-custom-validation-type-selector .ant-select-selector').click(),
verify: async ({ isDisabled = false }: { isDisabled: boolean }) => {
if (isDisabled) {
await expect(option).toHaveClass(/ant-select-item-option-disabled/);
} else {
await expect(option).not.toHaveClass(/ant-select-item-option-disabled/);
}
},
};
}
async addCustomValidation({
type,
value,
errorMsg,
index,
}: {
type: StringValidationType;
value: string;
errorMsg?: string;
index: number;
}) {
await this.customValidationBtn.waitFor({ state: 'visible' });
await this.customValidationBtn.click();
const dropdown = this.customValidationDropdown;
await dropdown.waitFor({ state: 'visible' });
await dropdown.locator('.nc-custom-validation-add-btn').click();
const currValItem = this.customValidationDropdown.getByTestId(`nc-custom-validation-item-${index}`);
await currValItem.waitFor({ state: 'visible' });
// Select type
const { select } = await this.getCustomValidationTypeOption({ type, currValItem, index });
await select();
// add value
const valValueInput = currValItem.locator('.custom-validation-input >> input');
await valValueInput.click();
await valValueInput.fill(value);
if (errorMsg !== undefined) {
const valErrorMsgInput = currValItem.locator('.nc-custom-validation-error-message-input');
await valErrorMsgInput.click();
await valErrorMsgInput.fill(errorMsg);
}
//close dropdown
await this.customValidationBtn.click();
await dropdown.waitFor({ state: 'hidden' });
}
async updateCustomValidation({
type,
value,
errorMsg,
index,
}: {
type?: StringValidationType;
value?: string;
errorMsg?: string;
index: number;
}) {
await this.customValidationBtn.waitFor({ state: 'visible' });
await this.customValidationBtn.click();
const dropdown = this.customValidationDropdown;
await dropdown.waitFor({ state: 'visible' });
const currValItem = this.customValidationDropdown.getByTestId(`nc-custom-validation-item-${index}`);
await currValItem.waitFor({ state: 'visible' });
if (type) {
// Select type
const { select } = await this.getCustomValidationTypeOption({ type, currValItem, index });
await select();
}
// update value
if (value !== undefined) {
const valValueInput = currValItem.locator('.custom-validation-input >> input');
await valValueInput.click();
await valValueInput.fill(value);
}
if (errorMsg !== undefined) {
const valErrorMsgInput = currValItem.locator('.nc-custom-validation-error-message-input');
await valErrorMsgInput.click();
await valErrorMsgInput.fill(errorMsg);
}
//close dropdown
await this.customValidationBtn.click();
await dropdown.waitFor({ state: 'hidden' });
}
async verifyCustomValidationSelector({
type,
value: _value,
errorMsg: _errorMsg,
index,
}: {
type: StringValidationType;
value?: string;
errorMsg?: string;
index: number;
}) {
await this.customValidationBtn.waitFor({ state: 'visible' });
await this.customValidationBtn.click();
const dropdown = this.customValidationDropdown;
await dropdown.waitFor({ state: 'visible' });
const currValItem = this.customValidationDropdown.getByTestId(`nc-custom-validation-item-${index}`);
await currValItem.waitFor({ state: 'visible' });
const { verify } = await this.getCustomValidationTypeOption({
type,
currValItem,
index,
});
await verify({ isDisabled: true });
//close dropdown
await this.customValidationBtn.click();
await dropdown.waitFor({ state: 'hidden' });
}
async verifyCustomValidationValue({ value, hasError, index }: { value?: string; hasError?: boolean; index: number }) {
await this.customValidationBtn.waitFor({ state: 'visible' });
await this.customValidationBtn.click();
const dropdown = this.customValidationDropdown;
await dropdown.waitFor({ state: 'visible' });
const currValItem = this.customValidationDropdown.getByTestId(`nc-custom-validation-item-${index}`);
await currValItem.waitFor({ state: 'visible' });
// value
if (value !== undefined) {
const valValueInput = currValItem.locator('.custom-validation-input >> input');
await expect(valValueInput).toHaveValue(value);
}
if (hasError) {
const valValueErr = currValItem.locator(
'.nc-custom-validation-input-wrapper .nc-custom-validation-item-error-icon'
);
await expect(valValueErr).toBeVisible();
}
//close dropdown
await this.customValidationBtn.click();
await dropdown.waitFor({ state: 'hidden' });
}
async removeCustomValidationItem({ index }: { index: number }) {
await this.customValidationBtn.waitFor({ state: 'visible' });
await this.customValidationBtn.click();
const dropdown = this.customValidationDropdown;
await dropdown.waitFor({ state: 'visible' });
const currValItem = this.customValidationDropdown.getByTestId(`nc-custom-validation-item-${index}`);
await currValItem.waitFor({ state: 'visible' });
await currValItem.locator('.nc-custom-validation-delete-item').click();
await currValItem.waitFor({ state: 'hidden' });
//close dropdown
await this.customValidationBtn.click();
await dropdown.waitFor({ state: 'hidden' });
}
async verifyCustomValidationCount({ count }: { count: number }) {
await this.customValidationBtn.waitFor({ state: 'visible' });
const countStr = await this.customValidationBtn.locator('.nc-custom-validation-count').textContent();
expect(parseInt(countStr) || 0).toEqual(count);
}
async getFormFieldsEmailPhoneUrlValidatorConfig({
type,
}: {
type: UITypes.Email | UITypes.PhoneNumber | UITypes.URL;
}) {
const validateBtn = this.get().getByTestId(`nc-form-field-validate-${type}`);
return {
locator: validateBtn,
click: async ({ enable }: { enable: boolean }) => {
await validateBtn.waitFor({ state: 'visible' });
const isEnabled = await validateBtn.isChecked();
if ((enable && !isEnabled) || (!enable && isEnabled)) {
await this.waitForResponse({
uiAction: async () => {
await validateBtn.click();
},
requestUrlPathToMatch: '/api/v1/db/meta/form-columns',
httpMethodsToMatch: ['PATCH'],
});
}
},
verify: async ({ isEnabled, isDisabled }: { isEnabled?: boolean; isDisabled?: boolean }) => {
await validateBtn.waitFor({ state: 'visible' });
if (isEnabled !== undefined) {
if (isEnabled) {
await expect(validateBtn).toBeChecked();
} else {
await expect(validateBtn).not.toBeChecked();
}
}
if (isDisabled !== undefined) {
if (isDisabled) {
await expect(validateBtn).toBeDisabled();
} else {
await expect(validateBtn).not.toBeDisabled();
}
}
},
};
}
async getFormFieldsValidateWorkEmailConfig() {
const validateWorkEmailBtn = this.get().getByTestId('nc-form-field-allow-only-work-email');
return {
locator: validateWorkEmailBtn,
click: async ({ enable }: { enable: boolean }) => {
await validateWorkEmailBtn.waitFor({ state: 'visible' });
const isEnabled = await validateWorkEmailBtn.isChecked();
if ((enable && !isEnabled) || (!enable && isEnabled)) {
await this.waitForResponse({
uiAction: async () => {
await validateWorkEmailBtn.click();
},
requestUrlPathToMatch: '/api/v1/db/meta/form-columns',
httpMethodsToMatch: ['PATCH'],
});
}
},
verify: async ({
isEnabled,
isDisabled,
isVisible,
}: {
isEnabled?: boolean;
isDisabled?: boolean;
isVisible?: boolean;
}) => {
if (isEnabled !== undefined) {
await validateWorkEmailBtn.waitFor({ state: 'visible' });
if (isEnabled) {
await expect(validateWorkEmailBtn).toBeChecked();
} else {
await expect(validateWorkEmailBtn).not.toBeChecked();
}
}
if (isDisabled !== undefined) {
await validateWorkEmailBtn.waitFor({ state: 'visible' });
if (isDisabled) {
await expect(validateWorkEmailBtn).toBeDisabled();
} else {
await expect(validateWorkEmailBtn).not.toBeDisabled();
}
}
if (isVisible !== undefined) {
if (isVisible) {
await expect(validateWorkEmailBtn).toBeVisible();
} else {
await expect(validateWorkEmailBtn).not.toBeVisible();
}
}
},
};
}
async getFormFieldErrors({ title }: { title: string }) {
// ant-form-item-explain
const field = this.get().locator(`.nc-form-drag-${title.replace(' ', '')}`);
await field.scrollIntoViewIfNeeded();
const fieldErrorEl = field.locator('.ant-form-item-explain');
return {
locator: fieldErrorEl,
verify: async ({ hasError, hasErrorMsg }: { hasError?: boolean; hasErrorMsg?: string | RegExp }) => {
if (hasError !== undefined) {
if (hasError) {
await expect(fieldErrorEl).toBeVisible();
} else {
await expect(fieldErrorEl).not.toBeVisible();
}
}
if (hasErrorMsg !== undefined) {
await expect(fieldErrorEl).toBeVisible();
await expect(fieldErrorEl.locator('> div').filter({ hasText: hasErrorMsg }).first()).toHaveText(hasErrorMsg);
}
},
};
}
async addLimitToRangeMinOrMax({ type, isMinValue, value }: { type: UITypes; isMinValue: boolean; value: string }) {
const fieldLocator = this.get().getByTestId(`nc-limit-to-range-${isMinValue ? 'min' : 'max'}-${type}`);
switch (type) {
default: {
await fieldLocator.locator('input:visible').waitFor();
await this.waitForResponse({
uiAction: async () => {
await fieldLocator.locator(`input`).click();
await fieldLocator.locator(`input`).fill(value);
await this.rootPage.keyboard.press('Enter');
},
requestUrlPathToMatch: '/api/v1/db/meta/form-columns',
httpMethodsToMatch: ['PATCH'],
});
}
}
}
async getFormFieldsValidateLimitToRange({ type }: { type: UITypes }) {
const validateBtn = this.get().getByTestId(`nc-limit-to-range-${type}`);
return {
locator: validateBtn,
click: async ({ enable, min, max }: { enable: boolean; min?: string; max?: string }) => {
await validateBtn.waitFor({ state: 'visible' });
const isEnabled = await validateBtn.isChecked();
if (enable && !isEnabled) {
await validateBtn.click();
await this.get().locator('.nc-limit-to-range-wrapper').first().waitFor({ state: 'visible' });
if (min !== undefined) {
await this.addLimitToRangeMinOrMax({ type, isMinValue: true, value: min });
}
if (max !== undefined) {
await this.addLimitToRangeMinOrMax({ type, isMinValue: false, value: max });
}
} else if (!enable && isEnabled) {
await this.waitForResponse({
uiAction: async () => {
await validateBtn.click();
},
requestUrlPathToMatch: '/api/v1/db/meta/form-columns',
httpMethodsToMatch: ['PATCH'],
});
}
},
verify: async ({
isEnabled,
isDisabled,
isVisible,
}: {
isEnabled?: boolean;
isDisabled?: boolean;
isVisible?: boolean;
}) => {
if (isEnabled !== undefined) {
await validateBtn.waitFor({ state: 'visible' });
if (isEnabled) {
await expect(validateBtn).toBeChecked();
} else {
await expect(validateBtn).not.toBeChecked();
}
}
if (isDisabled !== undefined) {
await validateBtn.waitFor({ state: 'visible' });
if (isDisabled) {
await expect(validateBtn).toBeDisabled();
} else {
await expect(validateBtn).not.toBeDisabled();
}
}
if (isVisible !== undefined) {
if (isVisible) {
await expect(validateWorkEmailBtn).toBeVisible();
} else {
await expect(validateWorkEmailBtn).not.toBeVisible();
}
}
},
};
}
async getFormFieldsValidateAttFileType() {
const validateBtn = this.get().getByTestId('nc-att-limit-file-type');
const validationWrapper = this.get().locator('.nc-att-limit-file-type-wrapper');
await validateBtn.scrollIntoViewIfNeeded();
return {
locator: validateBtn,
click: async ({ enable, fillValue }: { enable: boolean; fillValue?: string }) => {
await validateBtn.waitFor({ state: 'visible' });
const isEnabled = await validateBtn.isChecked();
if (enable && !isEnabled) {
await validateBtn.click();
} else if (!enable && isEnabled) {
await this.waitForResponse({
uiAction: async () => {
await validateBtn.click();
},
requestUrlPathToMatch: '/api/v1/db/meta/form-columns',
httpMethodsToMatch: ['PATCH'],
});
}
if (enable && fillValue !== undefined) {
await validationWrapper.locator('input').first().waitFor({ state: 'visible' });
await this.waitForResponse({
uiAction: async () => {
await validationWrapper.locator('input').fill(fillValue);
},
requestUrlPathToMatch: '/api/v1/db/meta/form-columns',
httpMethodsToMatch: ['PATCH'],
});
}
},
verify: async ({ isEnabled, hasError }: { isEnabled?: boolean; hasError?: boolean }) => {
if (isEnabled !== undefined) {
await validateBtn.waitFor({ state: 'visible' });
if (isEnabled) {
await expect(validateBtn).toBeChecked();
} else {
await expect(validateBtn).not.toBeChecked();
}
}
if (hasError !== undefined) {
await validateBtn.waitFor({ state: 'visible' });
await validationWrapper.waitFor({ state: 'visible' });
if (hasError) {
await expect(validationWrapper.locator('.validation-input-error')).toBeVisible();
} else {
await expect(validationWrapper.locator('.validation-input-error')).not.toBeVisible();
}
}
},
};
}
async getFormFieldsValidateAttFileCount() {
const validateBtn = this.get().getByTestId('nc-att-limit-file-count');
const validationWrapper = this.get().locator('.nc-att-limit-file-count-wrapper');
await validateBtn.scrollIntoViewIfNeeded();
return {
locator: validateBtn,
click: async ({ enable, fillValue }: { enable: boolean; fillValue?: string }) => {
await validateBtn.waitFor({ state: 'visible' });
const isEnabled = await validateBtn.isChecked();
if (enable && !isEnabled) {
await validateBtn.click();
} else if (!enable && isEnabled) {
await this.waitForResponse({
uiAction: async () => {
await validateBtn.click();
},
requestUrlPathToMatch: '/api/v1/db/meta/form-columns',
httpMethodsToMatch: ['PATCH'],
});
}
if (enable && fillValue !== undefined) {
await validationWrapper.locator('input').first().waitFor({ state: 'visible' });
await this.waitForResponse({
uiAction: async () => {
await validationWrapper.locator('input').fill(fillValue);
},
requestUrlPathToMatch: '/api/v1/db/meta/form-columns',
httpMethodsToMatch: ['PATCH'],
});
}
},
verify: async ({ isEnabled, hasError }: { isEnabled?: boolean; hasError?: boolean }) => {
if (isEnabled !== undefined) {
await validateBtn.waitFor({ state: 'visible' });
if (isEnabled) {
await expect(validateBtn).toBeChecked();
} else {
await expect(validateBtn).not.toBeChecked();
}
}
if (hasError !== undefined) {
await validateBtn.waitFor({ state: 'visible' });
await validationWrapper.waitFor({ state: 'visible' });
if (hasError) {
await expect(validationWrapper.locator('.validation-input-error')).toBeVisible();
} else {
await expect(validationWrapper.locator('.validation-input-error')).not.toBeVisible();
}
}
},
};
}
async getFormFieldsValidateAttFileSize() {
const validateBtn = this.get().getByTestId('nc-att-limit-file-size');
const validationWrapper = this.get().locator('.nc-att-limit-file-size-wrapper');
await validateBtn.scrollIntoViewIfNeeded();
return {
locator: validateBtn,
click: async ({
enable,
fillValue,
unit = 'KB',
}: {
enable: boolean;
fillValue?: string;
unit?: 'KB' | 'MB';
}) => {
await validateBtn.waitFor({ state: 'visible' });
const isEnabled = await validateBtn.isChecked();
if (enable && !isEnabled) {
await validateBtn.click();
} else if (!enable && isEnabled) {
await this.waitForResponse({
uiAction: async () => {
await validateBtn.click();
},
requestUrlPathToMatch: '/api/v1/db/meta/form-columns',
httpMethodsToMatch: ['PATCH'],
});
}
if (enable && fillValue !== undefined) {
await validationWrapper.locator('.nc-validation-input-wrapper input').first().waitFor({ state: 'visible' });
await validationWrapper.locator('.ant-select-selector').click();
const dropdown = this.rootPage.locator('.nc-att-limit-file-size-unit-selector-dropdown');
await dropdown.waitFor({ state: 'visible' });
const option = dropdown.locator('.ant-select-item-option').getByText(unit);
await option.scrollIntoViewIfNeeded();
await option.waitFor({ state: 'visible' });
await option.click();
await this.waitForResponse({
uiAction: async () => {
await validationWrapper.locator('.nc-validation-input-wrapper input').fill(fillValue);
},
requestUrlPathToMatch: '/api/v1/db/meta/form-columns',
httpMethodsToMatch: ['PATCH'],
});
}
},
verify: async ({ isEnabled, hasError }: { isEnabled?: boolean; hasError?: boolean }) => {
if (isEnabled !== undefined) {
await validateBtn.waitFor({ state: 'visible' });
if (isEnabled) {
await expect(validateBtn).toBeChecked();
} else {
await expect(validateBtn).not.toBeChecked();
}
}
if (hasError !== undefined) {
await validateBtn.waitFor({ state: 'visible' });
await validationWrapper.waitFor({ state: 'visible' });
if (hasError) {
await expect(validationWrapper.locator('.validation-input-error')).toBeVisible();
} else {
await expect(validationWrapper.locator('.validation-input-error')).not.toBeVisible();
}
}
},
};
}
}

58
tests/playwright/pages/Dashboard/SurveyForm/index.ts

@ -1,8 +1,12 @@
import { expect, Locator, Page } from '@playwright/test';
import { UITypes } from 'nocodb-sdk';
import BasePage from '../../Base';
import { getTextExcludeIconText } from '../../../tests/utils/general';
import { CellPageObject } from '../common/Cell';
export class SurveyFormPage extends BasePage {
readonly cell: CellPageObject;
readonly formHeading: Locator;
readonly formSubHeading: Locator;
readonly fillFormButton: Locator;
@ -16,6 +20,7 @@ export class SurveyFormPage extends BasePage {
constructor(rootPage: Page) {
super(rootPage);
this.cell = new CellPageObject(this);
this.formHeading = this.get().locator('[data-testid="nc-survey-form__heading"]');
this.formSubHeading = this.get().locator('[data-testid="nc-survey-form__sub-heading"]');
this.fillFormButton = this.get().locator('[data-testid="nc-survey-form__fill-form-btn"]');
@ -69,21 +74,44 @@ export class SurveyFormPage extends BasePage {
}
}
async fill(param: { fieldLabel: string; type?: string; value?: string }) {
async fill(param: { fieldLabel: string; type?: string; value?: string; skipNavigation?: boolean }) {
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"]`).click();
if (param.type === 'SingleLineText') {
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).fill(param.value);
// press enter key
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).press('Enter');
} else if (param.type === 'DateTime') {
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).first().click();
const modal = this.rootPage.locator('.nc-picker-datetime');
await expect(modal).toBeVisible();
await modal.locator('.nc-date-picker-now-btn').click();
await modal.waitFor({ state: 'hidden' });
await this.nextButton.click();
} else if (param.type === UITypes.LongText) {
await this.get()
.locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> textarea`)
.waitFor({ state: 'visible' });
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> textarea`).click();
await this.get()
.locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> textarea`)
.fill(param.value);
} else {
await this.get()
.locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`)
.waitFor({ state: 'visible' });
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).fill(param.value);
if ([UITypes.Date, UITypes.Time, UITypes.Year, UITypes.DateTime].includes(param.type)) {
// press enter key
await this.get().locator(`[data-testid="nc-survey-form__input-${param.fieldLabel}"] >> input`).press('Enter');
}
}
await this.get().locator(`[data-testid="nc-form-column-label"]`).click();
if (!param.skipNavigation) {
await this.nextButton.click();
}
// post next button click, allow transitions to complete
await this.rootPage.waitForTimeout(100);
}
@ -112,4 +140,26 @@ export class SurveyFormPage extends BasePage {
await this.submitButton.waitFor({ state: 'hidden' });
}
async getFormFieldErrors() {
const fieldErrorEl = this.get().locator('.ant-form-item-explain');
return {
locator: fieldErrorEl,
verify: async ({ hasError, hasErrorMsg }: { hasError?: boolean; hasErrorMsg?: string | RegExp }) => {
if (hasError !== undefined) {
if (hasError) {
await expect(fieldErrorEl).toBeVisible();
} else {
await expect(fieldErrorEl).not.toBeVisible();
}
}
if (hasErrorMsg !== undefined) {
await expect(fieldErrorEl).toBeVisible();
await expect(fieldErrorEl.locator('> div').filter({ hasText: hasErrorMsg }).first()).toHaveText(hasErrorMsg);
}
},
};
}
}

16
tests/playwright/pages/Dashboard/common/Cell/AttachmentCell.ts

@ -33,6 +33,22 @@ export class AttachmentCellPageObject extends BasePage {
await this.rootPage.waitForTimeout(750);
}
async removeFile({ attIndex, index, columnHeader }: { attIndex: number; index?: number; columnHeader: string }) {
await this.get({ index, columnHeader }).scrollIntoViewIfNeeded();
await this.get({ index, columnHeader }).click({ position: { x: 1, y: 1 } });
await this.get({ index, columnHeader }).locator('.nc-attachment-item').nth(attIndex).hover();
await this.get({ index, columnHeader })
.locator('.nc-attachment-item')
.nth(attIndex)
.locator('.nc-attachment-remove')
.click();
await this.rootPage.locator('.ant-modal.active').waitFor({ state: 'visible' });
await this.rootPage.locator('.ant-modal.active').getByTestId('nc-delete-modal-delete-btn').click();
await this.rootPage.locator('.ant-modal.active').waitFor({ state: 'hidden' });
}
async expandModalAddFile({ filePath }: { filePath: string[] }) {
const attachFileAction = this.rootPage
.locator('.ant-modal.nc-attachment-modal.active')

17
tests/playwright/pages/Dashboard/common/Cell/index.ts

@ -1,4 +1,5 @@
import { expect, Locator } from '@playwright/test';
import { UITypes } from 'nocodb-sdk';
import { GridPage } from '../../Grid';
import BasePage from '../../../Base';
import { AttachmentCellPageObject } from './AttachmentCell';
@ -14,6 +15,7 @@ import { YearCellPageObject } from './YearCell';
import { TimeCellPageObject } from './TimeCell';
import { GroupPageObject } from '../../Grid/Group';
import { UserOptionCellPageObject } from './UserOptionCell';
import { SurveyFormPage } from '../../SurveyForm';
export interface CellProps {
indexMap?: Array<number>;
@ -22,7 +24,7 @@ export interface CellProps {
}
export class CellPageObject extends BasePage {
readonly parent: GridPage | SharedFormPage | GroupPageObject;
readonly parent: GridPage | SharedFormPage | SurveyFormPage | GroupPageObject;
readonly selectOption: SelectOptionCellPageObject;
readonly attachment: AttachmentCellPageObject;
readonly checkbox: CheckboxCellPageObject;
@ -34,7 +36,7 @@ export class CellPageObject extends BasePage {
readonly dateTime: DateTimeCellPageObject;
readonly userOption: UserOptionCellPageObject;
constructor(parent: GridPage | SharedFormPage | GroupPageObject) {
constructor(parent: GridPage | SharedFormPage | SurveyFormPage | GroupPageObject) {
super(parent.rootPage);
this.parent = parent;
this.selectOption = new SelectOptionCellPageObject(this);
@ -52,6 +54,11 @@ export class CellPageObject extends BasePage {
get({ indexMap, index, columnHeader }: CellProps): Locator {
if (this.parent instanceof SharedFormPage) {
return this.parent.get().locator(`[data-testid="nc-form-input-cell-${columnHeader}"]`).first();
} else if (this.parent instanceof SurveyFormPage) {
return this.parent
.get()
.locator(`[data-testid="nc-survey-form__input-${columnHeader.replace(' ', '')}"]`)
.first();
} else if (this.parent instanceof GridPage) {
return this.parent.get().locator(`td[data-testid="cell-${columnHeader}-${index}"]`).first();
} else {
@ -68,7 +75,7 @@ export class CellPageObject extends BasePage {
return await this.get({ index, columnHeader }).dblclick();
}
async fillText({ index, columnHeader, text }: CellProps & { text: string }) {
async fillText({ index, columnHeader, text, type }: CellProps & { text: string; type?: UITypes }) {
await this.dblclick({
index,
columnHeader,
@ -84,6 +91,10 @@ export class CellPageObject extends BasePage {
if (await isInputBox()) {
await this.get({ index, columnHeader }).locator('input').fill(text);
if (type && [UITypes.Date, UITypes.Time, UITypes.Year, UITypes.DateTime].includes(type)) {
await this.rootPage.keyboard.press('Enter');
}
} else {
await this.get({ index, columnHeader }).locator('textarea').fill(text);
}

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

@ -75,4 +75,36 @@ export class SharedFormPage extends BasePage {
.locator('.nc-list-item-link-unlink-btn')
.click();
}
fieldLabel({ title }: { title: string }) {
return this.get()
.getByTestId(`nc-shared-form-item-${title.replace(' ', '')}`)
.locator('.nc-form-column-label');
}
async getFormFieldErrors({ title }: { title: string }) {
const field = this.get().getByTestId(`nc-shared-form-item-${title.replace(' ', '')}`);
await field.scrollIntoViewIfNeeded();
const fieldErrorEl = field.locator('.ant-form-item-explain');
return {
locator: fieldErrorEl,
verify: async ({ hasError, hasErrorMsg }: { hasError?: boolean; hasErrorMsg?: string | RegExp }) => {
if (hasError !== undefined) {
if (hasError) {
await fieldErrorEl.waitFor({ state: 'visible' });
await expect(fieldErrorEl).toBeVisible();
} else {
await expect(fieldErrorEl).not.toBeVisible();
}
}
if (hasErrorMsg !== undefined) {
await fieldErrorEl.waitFor({ state: 'visible' });
await expect(fieldErrorEl.locator('> div').filter({ hasText: hasErrorMsg }).first()).toHaveText(hasErrorMsg);
}
},
};
}
}

926
tests/playwright/tests/db/views/viewForm.spec.ts

@ -3,10 +3,11 @@ import { DashboardPage } from '../../../pages/Dashboard';
import setup, { unsetup } from '../../../setup';
import { FormPage } from '../../../pages/Dashboard/Form';
import { SharedFormPage } from '../../../pages/SharedForm';
import { Api, UITypes } from 'nocodb-sdk';
import { Api, StringValidationType, UITypes } from 'nocodb-sdk';
import { LoginPage } from '../../../pages/LoginPage';
import { getDefaultPwd } from '../../../tests/utils/general';
import { enableQuickRun, isEE } from '../../../setup/db';
import { SurveyFormPage } from '../../../pages/Dashboard/SurveyForm';
// todo: Move most of the ui actions to page object and await on the api response
test.describe('Form view', () => {
@ -252,7 +253,7 @@ test.describe('Form view with LTAR', () => {
loginPage = new LoginPage(page);
api = new Api({
baseURL: `http://localhost:8080/`,
baseURL: 'http://localhost:8080/',
headers: {
'xc-auth': context.token,
},
@ -408,7 +409,7 @@ test.describe('Form view', () => {
test('Select fields in form view', async () => {
api = new Api({
baseURL: `http://localhost:8080/`,
baseURL: 'http://localhost:8080/',
headers: {
'xc-auth': context.token,
},
@ -501,3 +502,922 @@ test.describe('Form view', () => {
});
});
});
test.describe('Form view: field validation', () => {
if (enableQuickRun() || !isEE()) test.skip();
let dashboard: DashboardPage;
let form: FormPage;
let context: any;
let api: Api<any>;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.base);
form = dashboard.form;
});
test.afterEach(async () => {
await unsetup(context);
});
async function createTable({ tableName, type }: { tableName: string; type?: 'limitToRange' | 'attachment' }) {
api = new Api({
baseURL: 'http://localhost:8080/',
headers: {
'xc-auth': context.token,
},
});
const columns = [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
...(type === 'limitToRange'
? [
{
column_name: 'Date',
title: 'Date',
uidt: 'Date',
meta: {
date_format: 'YYYY-MM-DD',
},
},
{
column_name: 'Time',
title: 'Time',
uidt: 'Time',
},
{
column_name: 'Year',
title: 'Year',
uidt: 'Year',
},
{
column_name: 'SingleSelect',
title: 'SingleSelect',
uidt: 'SingleSelect',
dtxp: "'jan','feb', 'mar','apr', 'may','jun','jul','aug','sep','oct','nov','dec'",
},
{
column_name: 'MultiSelect',
title: 'MultiSelect',
uidt: 'MultiSelect',
dtxp: "'jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'",
},
{
column_name: 'Number',
title: 'Number',
uidt: 'Number',
},
{
column_name: 'Decimal',
title: 'Decimal',
uidt: 'Decimal',
},
{
column_name: 'Currency',
title: 'Currency',
uidt: 'Currency',
meta: {
currency_locale: 'en-GB',
currency_code: 'UGX',
},
},
{
column_name: 'Percent',
title: 'Percent',
uidt: 'Percent',
},
{
column_name: 'Duration',
title: 'Duration',
uidt: 'Duration',
meta: {
duration: 0,
},
},
]
: type === 'attachment'
? [
{
column_name: 'Attachment',
title: 'Attachment',
uidt: UITypes.Attachment,
},
]
: [
{
column_name: 'SingleLineText',
title: 'SingleLineText',
uidt: UITypes.SingleLineText,
},
{
column_name: 'LongText',
title: 'LongText',
uidt: UITypes.LongText,
},
{
column_name: 'Email',
title: 'Email',
uidt: UITypes.Email,
},
{
column_name: 'PhoneNumber',
title: 'PhoneNumber',
uidt: UITypes.PhoneNumber,
meta: {
validate: true,
},
},
{
column_name: 'Url',
title: 'Url',
uidt: UITypes.URL,
},
]),
];
const base = await api.base.read(context.base.id);
await api.source.tableCreate(context.base.id, base.sources?.[0].id, {
table_name: tableName,
title: tableName,
columns: columns,
});
await dashboard.rootPage.reload();
await dashboard.rootPage.waitForTimeout(100);
await dashboard.treeView.openTable({ title: tableName });
await dashboard.rootPage.waitForTimeout(500);
await dashboard.viewSidebar.createFormView({ title: 'NewForm' });
}
test('Form builder field validation', async () => {
await createTable({ tableName: 'FormFieldValidation' });
const url = dashboard.rootPage.url();
await form.configureHeader({
title: 'Form validation',
subtitle: 'Test form field validation',
});
await form.verifyHeader({
title: 'Form validation',
subtitle: 'Test form field validation',
});
// 1.
await form.selectVisibleField({ title: 'SingleLineText' });
await form.addCustomValidation({ type: StringValidationType.MinLength, value: '2', index: 0 });
await form.addCustomValidation({ type: StringValidationType.MaxLength, value: '4', index: 1 });
// Verify already used selector is disable
await form.verifyCustomValidationSelector({ type: StringValidationType.MinLength, index: 1 });
// verify count
await form.verifyCustomValidationCount({ count: 2 });
// remove validation item
await form.removeCustomValidationItem({ index: 1 });
await form.verifyCustomValidationCount({ count: 1 });
await form.addCustomValidation({ type: StringValidationType.MaxLength, value: '4', index: 1 });
await form.verifyCustomValidationCount({ count: 2 });
// verify incomplete validator
await form.updateCustomValidation({ value: '', index: 1 });
await form.verifyCustomValidationCount({ count: 1 });
await form.verifyCustomValidationValue({ hasError: true, index: 1 });
await form.updateCustomValidation({ value: '1', index: 1 });
// Max value should be greater than min value
await form.verifyCustomValidationValue({ hasError: true, index: 1 });
await form.updateCustomValidation({ value: '15', index: 1 });
await form.addCustomValidation({
type: StringValidationType.StartsWith,
value: 'Lorem Ipsum is simply dummy text',
index: 2,
});
// max value is set to 12 charactors and startsWidth, endsWith, includes, notIncludes value must not be greater than maxLength
await form.verifyCustomValidationValue({ hasError: true, index: 2 });
await form.updateCustomValidation({ value: 'lorem', index: 2 });
await form.addCustomValidation({ type: StringValidationType.EndsWith, value: 'ipsum', index: 3 });
await form.addCustomValidation({ type: StringValidationType.Includes, value: 'lorem', index: 4 });
await form.addCustomValidation({ type: StringValidationType.NotIncludes, value: 'lorem', index: 5 });
// Includes and not includes value should be different
await form.verifyCustomValidationValue({ hasError: true, index: 5 });
await form.updateCustomValidation({ value: 'singleLineText', index: 5 });
// 2. Long text
await form.selectVisibleField({ title: 'LongText' });
// Regex
await form.addCustomValidation({ type: StringValidationType.Regex, value: '(ipsumf', index: 0 });
// verify invalid regex: `(` is invalid charactor
await form.verifyCustomValidationValue({ hasError: true, index: 0 });
await form.updateCustomValidation({ value: 'ipsum', index: 0 });
await form.verifyCustomValidationValue({ hasError: false, index: 0 });
// 3. Email
await form.selectVisibleField({ title: 'Email' });
const { click, verify } = await form.getFormFieldsEmailPhoneUrlValidatorConfig({
type: UITypes.Email,
});
const { click: _clickWorkEmail, verify: verifyWorkEmail } = await form.getFormFieldsValidateWorkEmailConfig();
// Work email validate switch is only visible if email valiator is enabled
await verifyWorkEmail({ isVisible: false });
await click({ enable: true });
await verifyWorkEmail({ isVisible: true });
await dashboard.rootPage.reload();
await form.selectVisibleField({ title: 'Email' });
await verify({ isEnabled: true });
// Verify regular email
await form.fillForm([{ field: 'Email', value: 'john@gmail.com' }]);
const emailErrorConfig = await form.getFormFieldErrors({ title: 'Email' });
await emailErrorConfig.verify({ hasError: false });
await form.fillForm([{ field: 'Email', value: 'john@gmail.com.' }]);
await emailErrorConfig.verify({ hasErrorMsg: /Invalid Email/i });
// Enable accept only work email & verify
await _clickWorkEmail({ enable: true });
await form.fillForm([{ field: 'Email', value: 'john@gmail.com' }]);
await emailErrorConfig.verify({ hasErrorMsg: /Invalid Work Email/i });
await form.fillForm([{ field: 'Email', value: 'john@nocodb.com' }]);
await emailErrorConfig.verify({ hasError: false });
// 4. Phone Number
await form.selectVisibleField({ title: 'PhoneNumber' });
const { verify: verifyPhoneNumber } = await form.getFormFieldsEmailPhoneUrlValidatorConfig({
type: UITypes.PhoneNumber,
});
// Validation infored by field schema settings
await verifyPhoneNumber({ isEnabled: true, isDisabled: true });
await form.addCustomValidation({ type: StringValidationType.MinLength, value: '10', index: 0 });
// 4. URL
await form.selectVisibleField({ title: 'Url' });
// Validation infored by field schema settings
await form.addCustomValidation({ type: StringValidationType.StartsWith, value: 'https://', index: 0 });
const validatorFillDetails = {
SingleLineText: [
{
type: UITypes.SingleLineText,
fillValue: 's',
errors: [
/The input must be at least 2 characters long/i,
/The input must start with 'lorem'/i,
/The input must end with 'ipsum'./i,
/The input must contain the string 'lorem'./i,
],
},
{
type: UITypes.SingleLineText,
fillValue: 'lorem',
errors: [/The input must end with 'ipsum'./i],
},
{
type: UITypes.SingleLineText,
fillValue: 'lorem ipsum x',
errors: [/The input must end with 'ipsum'./i],
},
{
type: UITypes.SingleLineText,
fillValue: 'lorem ipsum',
errors: [],
},
{
type: UITypes.SingleLineText,
fillValue: 'lorem ipsum ipsum ipsum',
errors: [/The input must not exceed 15 characters/i],
},
{
type: UITypes.SingleLineText,
fillValue: 'lorem ipsum',
errors: [],
},
],
LongText: [
{
type: UITypes.LongText,
fillValue: 'lorem',
errors: [/The input does not match the required format/i],
},
{
type: UITypes.LongText,
fillValue: 'ipsum',
errors: [],
},
],
Email: [
{
type: UITypes.Email,
fillValue: 'john@gmail.com',
errors: [/Invalid Work Email/i],
},
{
type: UITypes.Email,
fillValue: 'john@gmail.com.',
errors: [/Invalid Email/i, /Invalid Work Email/i],
},
{
type: UITypes.Email,
fillValue: 'john@nocodb.com',
errors: [],
},
],
PhoneNumber: [
{
type: UITypes.PhoneNumber,
fillValue: '12345',
errors: [/Invalid phone number/i, /The input must be at least 10 characters long/i],
},
{
type: UITypes.PhoneNumber,
fillValue: '1234567890',
errors: [],
},
],
Url: [
{
type: UITypes.URL,
fillValue: 'google.com',
errors: [/The input must start with 'https:\/\/'/i],
},
{
type: UITypes.URL,
fillValue: 'https://google.com',
errors: [],
},
],
};
for (const formField in validatorFillDetails) {
const fielConfigError = await form.getFormFieldErrors({ title: formField });
for (const fieldValue of validatorFillDetails[formField]) {
await form.fillForm([{ field: formField, value: fieldValue.fillValue, type: fieldValue.type }]);
await fielConfigError.verify({ hasError: !!fieldValue.errors.length });
for (const error of fieldValue.errors) {
await fielConfigError.verify({ hasErrorMsg: error });
}
}
}
await form.submitForm();
await form.verifyStatePostSubmit({
message: 'Successfully submitted form data',
});
await dashboard.rootPage.reload();
const formLink = await dashboard.form.topbar.getSharedViewUrl();
await dashboard.rootPage.goto(formLink);
// fix me! kludge@hub; page wasn't getting loaded from previous step
await dashboard.rootPage.reload();
const sharedForm = new SharedFormPage(dashboard.rootPage);
for (const formField in validatorFillDetails) {
const fielConfigError = await sharedForm.getFormFieldErrors({ title: formField });
for (const fieldValue of validatorFillDetails[formField]) {
await sharedForm.cell.fillText({
columnHeader: formField,
text: fieldValue.fillValue,
type: fieldValue.type,
});
await fielConfigError.verify({ hasError: !!fieldValue.errors.length });
for (const error of fieldValue.errors) {
await fielConfigError.verify({ hasErrorMsg: error });
}
}
}
await sharedForm.submit();
await sharedForm.verifySuccessMessage();
await dashboard.rootPage.goto(url);
// kludge- reload
await dashboard.rootPage.reload();
await dashboard.form.topbar.clickShare();
await dashboard.form.topbar.share.clickShareViewPublicAccess();
await dashboard.form.topbar.share.closeModal();
const surveyLink = await dashboard.form.topbar.getSharedViewUrl(true);
await dashboard.rootPage.reload();
await dashboard.form.configureSubmitMessage({
message: 'Thank you for submitting the form',
});
await dashboard.rootPage.goto(surveyLink);
// fix me! kludge@hub; page wasn't getting loaded from previous step
await dashboard.rootPage.reload();
await dashboard.rootPage.waitForTimeout(2000);
const surveyForm = new SurveyFormPage(dashboard.rootPage);
await surveyForm.clickFillForm();
for (const formField in validatorFillDetails) {
const fielConfigError = await surveyForm.getFormFieldErrors();
for (const fieldValue of validatorFillDetails[formField]) {
await surveyForm.fill({
fieldLabel: formField,
value: fieldValue.fillValue,
type: fieldValue.type,
skipNavigation: true,
});
await fielConfigError.verify({ hasError: !!fieldValue.errors.length });
for (const error of fieldValue.errors) {
await fielConfigError.verify({ hasErrorMsg: error });
}
}
if (formField !== 'Url') {
await surveyForm.nextButton.click();
}
}
await surveyForm.confirmAndSubmit();
// validate post submit data
await surveyForm.validateSuccessMessage({
message: 'Thank you for submitting the form',
});
});
test('Form builder field validation: limit to range', async () => {
await createTable({ tableName: 'FormFieldLimitToRange', type: 'limitToRange' });
const url = dashboard.rootPage.url();
await form.configureHeader({
title: 'Limit to range validation',
subtitle: 'Test form field validation',
});
await form.verifyHeader({
title: 'Limit to range validation',
subtitle: 'Test form field validation',
});
const limitToRageData = [
{
type: UITypes.Date,
title: 'Date',
min: '2023-07-17',
max: '2025-07-17',
},
{
type: UITypes.Time,
title: 'Time',
min: '01:20',
max: '12:30',
},
{
type: UITypes.Year,
title: 'Year',
min: '2000',
max: '2024',
},
{
type: UITypes.MultiSelect,
title: 'MultiSelect',
min: '2',
max: '3',
},
{
type: UITypes.Number,
title: 'Number',
min: '1',
max: '6',
},
{
type: UITypes.Decimal,
title: 'Decimal',
min: '1.5',
max: '10.58',
},
{
type: UITypes.Currency,
title: 'Currency',
min: '100',
max: '200',
},
{
type: UITypes.Percent,
title: 'Percent',
uidt: 'Percent',
min: '99',
max: '120',
},
{
type: UITypes.Duration,
title: 'Duration',
min: '1:5',
max: '10:58',
},
];
for (const limit of limitToRageData) {
await form.selectVisibleField({ title: limit.title });
const validateRange = await form.getFormFieldsValidateLimitToRange({ type: limit.type as UITypes });
await validateRange.click({ enable: true, min: limit.min, max: limit.max });
}
const limitToRageFillValue = {
Date: [
{
type: UITypes.Date,
fillValue: '2023-07-12',
errors: [/Select a date on or after 2023-07-17/i],
},
{
type: UITypes.Date,
fillValue: '2026-07-17',
errors: [/Select a date on or before 2025-07-17/i],
},
{
type: UITypes.Date,
fillValue: '2024-07-17',
errors: [],
},
],
Time: [
{
type: UITypes.Time,
fillValue: '01:10',
errors: [/Input a time equal to or later than 01:20/i],
},
{
type: UITypes.Time,
fillValue: '14:30',
errors: [/Input a time equal to or earlier than 12:30/i],
},
{
type: UITypes.Time,
fillValue: '12:30',
errors: [],
},
],
Year: [
{
type: UITypes.Year,
fillValue: '1999',
errors: [/Input a year equal to or later than 2000/i],
},
{
type: UITypes.Year,
fillValue: '2025',
errors: [/Input a year equal to or earlier than 2024/i],
},
{
type: UITypes.Year,
fillValue: '2000',
errors: [],
},
],
Number: [
{
type: UITypes.Number,
fillValue: '0',
errors: [/Input a number equal to or greater than 1/i],
},
{
type: UITypes.Number,
fillValue: '7',
errors: [/Input a number equal to or less than 6/i],
},
{
type: UITypes.Number,
fillValue: '6',
errors: [],
},
],
Decimal: [
{
type: UITypes.Decimal,
fillValue: '1.00',
errors: [/Input a number equal to or greater than 1.5/i],
},
{
type: UITypes.Decimal,
fillValue: '11.29',
errors: [/Input a number equal to or less than 10.58/i],
},
{
type: UITypes.Decimal,
fillValue: '10.2',
errors: [],
},
],
Currency: [
{
type: UITypes.Currency,
fillValue: '88',
errors: [/Input a number equal to or greater than 100/i],
},
{
type: UITypes.Currency,
fillValue: '2012',
errors: [/Input a number equal to or less than 200/i],
},
{
type: UITypes.Currency,
fillValue: '150',
errors: [],
},
],
Percent: [
{
type: UITypes.Percent,
fillValue: '88',
errors: [/Input a number equal to or greater than 99/i],
},
{
type: UITypes.Percent,
fillValue: '2012',
errors: [/Input a number equal to or less than 120/i],
},
{
type: UITypes.Percent,
fillValue: '110',
errors: [],
},
],
Duration: [
{
type: UITypes.Duration,
fillValue: '1:00',
errors: [/Input a duration equal to or later than 01:05/i],
},
{
type: UITypes.Duration,
fillValue: '11:29',
errors: [/Input a duration equal to or earlier than 10:58/i],
},
{
type: UITypes.Duration,
fillValue: '10:2',
errors: [],
},
],
};
for (const formField in limitToRageFillValue) {
const fielConfigError = await form.getFormFieldErrors({ title: formField });
for (const fieldValue of limitToRageFillValue[formField]) {
await form.fillForm([{ field: formField, value: fieldValue.fillValue, type: fieldValue.type }]);
await fielConfigError.verify({ hasError: !!fieldValue.errors.length });
for (const error of fieldValue.errors) {
await fielConfigError.verify({ hasErrorMsg: error });
}
}
}
await form.submitForm();
await form.verifyStatePostSubmit({
message: 'Successfully submitted form data',
});
await dashboard.rootPage.reload();
const formLink = await dashboard.form.topbar.getSharedViewUrl();
await dashboard.rootPage.goto(formLink);
// fix me! kludge@hub; page wasn't getting loaded from previous step
await dashboard.rootPage.reload();
const sharedForm = new SharedFormPage(dashboard.rootPage);
for (const formField in limitToRageFillValue) {
const fielConfigError = await sharedForm.getFormFieldErrors({ title: formField });
for (const fieldValue of limitToRageFillValue[formField]) {
await sharedForm.cell.fillText({
columnHeader: formField,
text: fieldValue.fillValue,
type: fieldValue.type,
});
await sharedForm.fieldLabel({ title: formField }).click();
await dashboard.rootPage.waitForTimeout(100);
await fielConfigError.verify({ hasError: !!fieldValue.errors.length });
for (const error of fieldValue.errors) {
await fielConfigError.verify({ hasErrorMsg: error });
}
}
}
const multiSelectFieldError = await sharedForm.getFormFieldErrors({ title: 'MultiSelect' });
// Click on multi select options
const multiSelectParams = {
index: -1,
columnHeader: 'MultiSelect',
option: 'jan',
multiSelect: true,
ignoreDblClick: true,
};
await sharedForm.cell.selectOption.select({ ...multiSelectParams, option: 'jan' });
await multiSelectFieldError.verify({ hasErrorMsg: /Please select at least 2 options/ });
await sharedForm.cell.selectOption.select({ ...multiSelectParams, option: 'feb' });
await sharedForm.cell.selectOption.select({ ...multiSelectParams, option: 'mar' });
await multiSelectFieldError.verify({ hasError: false });
await sharedForm.submit();
await sharedForm.verifySuccessMessage();
await dashboard.rootPage.goto(url);
// kludge- reload
await dashboard.rootPage.reload();
await dashboard.form.topbar.clickShare();
await dashboard.form.topbar.share.clickShareViewPublicAccess();
await dashboard.form.topbar.share.closeModal();
const surveyLink = await dashboard.form.topbar.getSharedViewUrl(true);
await dashboard.rootPage.reload();
await dashboard.form.configureSubmitMessage({
message: 'Thank you for submitting the form',
});
await form.removeField({ field: 'SingleSelect', mode: 'hideField' });
await form.removeField({ field: 'MultiSelect', mode: 'hideField' });
await dashboard.rootPage.goto(surveyLink);
// fix me! kludge@hub; page wasn't getting loaded from previous step
await dashboard.rootPage.reload();
await dashboard.rootPage.waitForTimeout(2000);
const surveyForm = new SurveyFormPage(dashboard.rootPage);
await surveyForm.clickFillForm();
for (const formField in limitToRageFillValue) {
const fielConfigError = await surveyForm.getFormFieldErrors();
for (const fieldValue of limitToRageFillValue[formField]) {
await surveyForm.fill({
fieldLabel: formField,
value: fieldValue.fillValue,
type: fieldValue.type,
skipNavigation: true,
});
await fielConfigError.verify({ hasError: !!fieldValue.errors.length });
for (const error of fieldValue.errors) {
await fielConfigError.verify({ hasErrorMsg: error });
}
}
if (formField !== 'Duration') {
await surveyForm.nextButton.click();
}
}
await surveyForm.confirmAndSubmit();
// validate post submit data
await surveyForm.validateSuccessMessage({
message: 'Thank you for submitting the form',
});
});
test('Form builder field validation: attachment', async () => {
await createTable({ tableName: 'FormFieldAttachment', type: 'attachment' });
const url = dashboard.rootPage.url();
await form.configureHeader({
title: 'Attachment validation',
subtitle: 'Test form field validation',
});
await form.verifyHeader({
title: 'Attachment validation',
subtitle: 'Test form field validation',
});
await form.selectVisibleField({ title: 'Attachment' });
const validateAttType = await form.getFormFieldsValidateAttFileType();
await validateAttType.click({ enable: true, fillValue: '.jpg' });
await validateAttType.verify({ hasError: true });
await validateAttType.click({ enable: true, fillValue: 'image/png' });
await validateAttType.verify({ hasError: false });
const validateAttCount = await form.getFormFieldsValidateAttFileCount();
await validateAttCount.click({ enable: true, fillValue: '1a' });
await validateAttCount.verify({ hasError: true });
await validateAttCount.click({ enable: true, fillValue: '1' });
await validateAttCount.verify({ hasError: false });
const validateAttSize = await form.getFormFieldsValidateAttFileSize();
await validateAttSize.click({ enable: true, fillValue: '2000', unit: 'KB' });
const formLink = await dashboard.form.topbar.getSharedViewUrl();
await dashboard.rootPage.goto(formLink);
// fix me! kludge@hub; page wasn't getting loaded from previous step
await dashboard.rootPage.reload();
const sharedForm = new SharedFormPage(dashboard.rootPage);
await sharedForm.cell.attachment.addFile({
columnHeader: 'Attachment',
filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`],
});
const attError = await sharedForm.getFormFieldErrors({ title: 'Attachment' });
await attError.verify({ hasErrorMsg: /Only following file types allowed to upload 'image\/png'/ });
await attError.verify({ hasErrorMsg: /The file size must not exceed 2000 KB/ });
await sharedForm.cell.attachment.removeFile({
columnHeader: 'Attachment',
attIndex: 0,
});
await sharedForm.cell.attachment.addFile({
columnHeader: 'Attachment',
filePath: [`${process.cwd()}/fixtures/sampleFiles/Image/2.png`],
});
await attError.verify({ hasError: false });
await sharedForm.submit();
await sharedForm.verifySuccessMessage();
await dashboard.rootPage.goto(url);
// kludge- reload
await dashboard.rootPage.reload();
await dashboard.form.topbar.clickShare();
await dashboard.form.topbar.share.clickShareViewPublicAccess();
await dashboard.form.topbar.share.closeModal();
const surveyLink = await dashboard.form.topbar.getSharedViewUrl(true);
await dashboard.rootPage.waitForTimeout(2000);
await dashboard.form.configureSubmitMessage({
message: 'Thank you for submitting the form',
});
await dashboard.rootPage.goto(surveyLink);
// fix me! kludge@hub; page wasn't getting loaded from previous step
await dashboard.rootPage.reload();
await dashboard.rootPage.waitForTimeout(2000);
const surveyForm = new SurveyFormPage(dashboard.rootPage);
await surveyForm.clickFillForm();
await surveyForm.cell.attachment.addFile({
columnHeader: 'Attachment',
filePath: [`${process.cwd()}/fixtures/sampleFiles/sampleImage.jpeg`],
});
const surveryAttError = await surveyForm.getFormFieldErrors();
await surveryAttError.verify({ hasErrorMsg: /Only following file types allowed to upload 'image\/png'/ });
await surveryAttError.verify({ hasErrorMsg: /The file size must not exceed 2000 KB/ });
await surveyForm.cell.attachment.removeFile({
columnHeader: 'Attachment',
attIndex: 0,
});
await surveyForm.cell.attachment.addFile({
columnHeader: 'Attachment',
filePath: [`${process.cwd()}/fixtures/sampleFiles/Image/2.png`],
});
await surveryAttError.verify({ hasError: false });
await surveyForm.confirmAndSubmit();
// validate post submit data
await surveyForm.validateSuccessMessage({
message: 'Thank you for submitting the form',
});
});
});

Loading…
Cancel
Save