Browse Source

Merge pull request #7408 from nocodb/nc-test/multi-field-editor

Nc test/multi field editor
pull/7423/head
Raju Udava 8 months ago committed by GitHub
parent
commit
a257cb4896
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 146
      packages/nc-gui/components/smartsheet/details/Fields.vue
  2. 30
      packages/nc-gui/lang/en.json
  3. 318
      tests/playwright/pages/Dashboard/Details/FieldsPage.ts
  4. 7
      tests/playwright/pages/Dashboard/Details/index.ts
  5. 380
      tests/playwright/tests/db/features/multiFieldEditor.spec.ts

146
packages/nc-gui/components/smartsheet/details/Fields.vue

@ -33,6 +33,7 @@ interface moveOp {
index: number
order: number
}
const { t } = useI18n()
const { $api } = useNuxtApp()
@ -228,7 +229,7 @@ const duplicateField = async (field: TableExplorerColumn) => {
case UITypes.Lookup:
case UITypes.Rollup:
case UITypes.Formula:
return message.info('Not available at the moment')
return message.info(t('msg.info.notAvailableAtTheMoment'))
case UITypes.SingleSelect:
case UITypes.MultiSelect:
fieldPayload = {
@ -270,7 +271,7 @@ const onFieldUpdate = (state: TableExplorerColumn) => {
const col = fields.value.find((col) => compareCols(col, state))
if (!col) return
const diffs = diff(col, state)
const diffs = diff(col, state) as Partial<TableExplorerColumn>
// hack to prevent update status `Updated Field` when clicking on field first time
let isUpdated = true
@ -389,13 +390,13 @@ const onMove = (_event: { moved: { newIndex: number; oldIndex: number } }) => {
if (op?.op === 'update') {
const diffs = diff(op.column, field)
if (!(Object.keys(diffs).length === 1 && 'column_order' in diffs)) {
message.warning('You cannot move field that is being edited. Either save or discard changes first')
message.warning(t('msg.warning.multiField.moveEditedField'))
return
}
}
if (op?.op === 'delete') {
message.warning('You cannot move field that is deleted. Either save or discard changes first')
message.warning(t('msg.warning.multiField.moveDeletedField'))
return
}
@ -563,7 +564,7 @@ const isColumnsValid = computed(() => fields.value.every((f) => isColumnValid(f)
const saveChanges = async () => {
if (!isColumnsValid.value) {
message.error('Please complete the configuration of all fields before saving')
message.error(t('msg.error.multiFieldSaveValidation'))
return
} else if (!loading.value && ops.value.length < 1 && moveOps.value.length < 1 && visibilityOps.value.length < 1) {
return
@ -640,7 +641,7 @@ const saveChanges = async () => {
visibilityOps.value = []
} catch (e) {
message.error('Something went wrong')
message.error(t('msg.error.somethingWentWrong'))
} finally {
loading.value = false
}
@ -648,7 +649,7 @@ const saveChanges = async () => {
const toggleVisibility = async (checked: boolean, field: Field) => {
if (field.fk_column_id && fieldStatuses.value[field.fk_column_id]) {
message.warning('You cannot change visibility of a field that is being edited. Please save or discard changes first.')
message.warning(t('msg.warning.multiField.fieldVisibility'))
return
}
if (visibilityOps.value.find((op) => op.column.fk_column_id === field.fk_column_id)) {
@ -742,7 +743,7 @@ onKeyDown('Backspace', () => {
onKeyDown('ArrowRight', () => {
if (document.activeElement?.tagName === 'TEXTAREA') return
if (activeField.value) {
const input = document.querySelector('.nc-fields-input')
const input = document.querySelector('.nc-fields-input') as HTMLInputElement
if (input) {
input.focus()
}
@ -805,7 +806,7 @@ watch(
</script>
<template>
<div class="w-full p-4">
<div class="nc-fields-wrapper w-full p-4">
<div class="max-w-250 h-full w-full mx-auto">
<div v-if="isViewColumnsLoading" class="flex flex-row justify-between mt-2">
<a-skeleton-input class="!h-8 !w-68 !rounded !overflow-hidden" active size="small" />
@ -817,7 +818,12 @@ watch(
</div>
<template v-else>
<div class="flex w-full justify-between py-2">
<a-input v-model:value="searchQuery" class="!h-8 !px-1 !rounded-lg !w-72" placeholder="Search field">
<a-input
data-testid="nc-field-search-input"
v-model:value="searchQuery"
class="!h-8 !px-1 !rounded-lg !w-72"
:placeholder="$t('placeholder.searchFields')"
>
<template #prefix>
<GeneralIcon icon="search" class="mx-1 h-3.5 w-3.5 text-gray-500 group-hover:text-black" />
</template>
@ -827,31 +833,41 @@ watch(
icon="close"
class="mx-1 h-3.5 w-3.5 text-gray-500 group-hover:text-black"
@click="searchQuery = ''"
data-testid="nc-field-clear-search"
/>
</template>
</a-input>
<div class="flex gap-2">
<NcTooltip :disabled="isLocked">
<template #title> {{ `${renderAltOrOptlKey()} + C` }} </template>
<NcButton type="secondary" size="small" class="mr-1" :disabled="loading || isLocked" @click="addField()">
<NcButton
data-testid="nc-field-add-new"
type="secondary"
size="small"
class="mr-1"
:disabled="loading || isLocked"
@click="addField()"
>
<div class="flex items-center gap-2">
<GeneralIcon icon="plus" class="w-3" />
New Field
{{ $t('labels.multiField.newField') }}
</div>
</NcButton>
</NcTooltip>
<NcButton
data-testid="nc-field-reset"
type="secondary"
size="small"
:disabled="(!loading && ops.length < 1 && moveOps.length < 1 && visibilityOps.length < 1) || isLocked"
@click="clearChanges()"
>
Reset
{{ $t('general.reset') }}
</NcButton>
<NcTooltip :disabled="isLocked">
<template #title> {{ `${renderCmdOrCtrlKey()} + S` }} </template>
<NcButton
data-testid="nc-field-save-changes"
type="primary"
size="small"
:loading="loading"
@ -861,20 +877,27 @@ watch(
"
@click="saveChanges()"
>
Save changes
{{ $t('labels.multiField.saveChanges') }}
</NcButton>
</NcTooltip>
</div>
</div>
<div class="flex flex-row rounded-lg border-1 overflow-clip border-gray-200">
<div ref="fieldsListWrapperDomRef" class="nc-scrollbar-md !overflow-auto flex-1 flex-grow-1 nc-fields-height">
<Draggable :model-value="fields" :disabled="isLocked" item-key="id" @change="onMove($event)">
<Draggable
:model-value="fields"
:disabled="isLocked"
item-key="id"
@change="onMove($event)"
data-testid="nc-field-list-wrapper"
>
<template #item="{ element: field }">
<div
v-if="field.title.toLowerCase().includes(searchQuery.toLowerCase()) && !field.pv"
class="flex px-2 hover:bg-gray-100 first:rounded-t-lg border-b-1 last:rounded-b-none border-gray-200 pl-5 group"
:class="` ${compareCols(field, activeField) ? 'selected' : ''}`"
@click="changeField(field, $event)"
:data-testid="`nc-field-item-${fieldState(field)?.title || field.title}`"
>
<div class="flex items-center flex-1 py-2.5 gap-1 w-2/6">
<component
@ -895,6 +918,7 @@ watch(
toggleVisibility(event.target.checked, viewFieldsMap[field.id])
}
"
data-testid="nc-field-visibility-checkbox"
/>
<NcCheckbox v-else :disabled="true" class="opacity-0" :checked="true" />
<SmartsheetHeaderVirtualCellIcon
@ -919,23 +943,30 @@ watch(
show-on-truncate-only
>
<template #title> {{ fieldState(field)?.title || field.title }} </template>
<span>
<span data-testid="nc-field-title">
{{ fieldState(field)?.title || field.title }}
</span>
</NcTooltip>
</div>
<div class="flex items-center justify-end gap-1">
<div class="flex items-center">
<NcBadge v-if="fieldStatus(field) === 'delete'" color="red" :border="false" class="bg-red-50 text-red-700">
Deleted field
<div class="nc-field-status-wrapper flex items-center">
<NcBadge
v-if="fieldStatus(field) === 'delete'"
color="red"
:border="false"
class="bg-red-50 text-red-700"
data-testid="nc-field-status-deleted-field"
>
{{ $t('labels.multiField.deletedField') }}
</NcBadge>
<NcBadge
v-else-if="fieldStatus(field) === 'add'"
color="orange"
:border="false"
class="bg-green-50 text-green-700"
data-testid="nc-field-status-new-field"
>
New field
{{ $t('labels.multiField.newField') }}
</NcBadge>
<NcBadge
@ -943,16 +974,18 @@ watch(
color="orange"
:border="false"
class="bg-orange-50 text-orange-700"
data-testid="nc-field-status-updated-field"
>
Updated field
{{ $t('labels.multiField.updatedField') }}
</NcBadge>
<NcBadge
v-if="!isColumnValid(field)"
color="yellow"
:border="false"
class="ml-1 bg-yellow-50 text-yellow-700"
data-testid="nc-field-status-incomplete-configuration"
>
Incomplete configuration
{{ $t('labels.multiField.incompleteConfiguration') }}
</NcBadge>
</div>
<NcButton
@ -962,16 +995,17 @@ watch(
class="no-action mr-2"
:disabled="loading"
@click="recoverField(field)"
data-testid="nc-field-restore-changes"
>
<div class="flex items-center text-xs gap-1">
<GeneralIcon icon="reload" />
Restore
{{ $t('general.restore') }}
</div>
</NcButton>
<NcDropdown
v-else
:trigger="['click']"
overlay-class-name="nc-dropdown-table-explorer"
overlay-class-name="nc-field-item-action-dropdown nc-dropdown-table-explorer"
@update:visible="onFieldOptionUpdate"
@click.stop
>
@ -983,6 +1017,7 @@ watch(
'!hover:(text-brand-700 bg-brand-100) !group-hover:(text-brand-500)': compareCols(field, activeField),
'!hover:(text-gray-700 bg-gray-200) !group-hover:(text-gray-500)': !compareCols(field, activeField),
}"
data-testid="nc-field-item-action-button"
>
<GeneralIcon icon="threeDotVertical" class="no-action text-inherit" />
</NcButton>
@ -996,10 +1031,11 @@ watch(
<div
class="flex flex-row px-3 py-2 w-46 justify-between items-center group hover:bg-gray-100 cursor-pointer"
@click="onClickCopyFieldUrl(field)"
data-testid="nc-field-item-action-copy-id"
>
<div class="flex flex-row items-baseline gap-x-1 font-bold text-xs">
<div class="text-gray-600">{{ $t('labels.idColon') }}</div>
<div class="flex flex-row text-gray-600 text-xs">
<div class="flex flex-row text-gray-600 text-xs" data-testid="nc-field-item-id">
{{ field.id }}
</div>
</div>
@ -1013,22 +1049,44 @@ watch(
</template>
<template v-if="!isLocked">
<NcMenuItem key="table-explorer-duplicate" @click="duplicateField(field)">
<Icon class="iconify text-gray-800" icon="lucide:copy" /><span>Duplicate</span>
<NcMenuItem
key="table-explorer-duplicate"
@click="duplicateField(field)"
data-testid="nc-field-item-action-duplicate"
>
<Icon class="iconify text-gray-800" icon="lucide:copy" /><span>{{ $t('general.duplicate') }}</span>
</NcMenuItem>
<NcMenuItem v-if="!field.pv" key="table-explorer-insert-above" @click="addField(field, true)">
<Icon class="iconify text-gray-800" icon="lucide:arrow-up" /><span>Insert above</span>
<NcMenuItem
v-if="!field.pv"
key="table-explorer-insert-above"
@click="addField(field, true)"
data-testid="nc-field-item-action-insert-above"
>
<Icon class="iconify text-gray-800" icon="lucide:arrow-up" /><span>{{
$t('general.insertAbove')
}}</span>
</NcMenuItem>
<NcMenuItem key="table-explorer-insert-below" @click="addField(field)">
<Icon class="iconify text-gray-800" icon="lucide:arrow-down" /><span>Insert below</span>
<NcMenuItem
key="table-explorer-insert-below"
@click="addField(field)"
data-testid="nc-field-item-action-insert-below"
>
<Icon class="iconify text-gray-800" icon="lucide:arrow-down" /><span>{{
$t('general.insertBelow')
}}</span>
</NcMenuItem>
<a-menu-divider class="my-1.5" />
<NcMenuItem key="table-explorer-delete" class="!hover:bg-red-50" @click="onFieldDelete(field)">
<NcMenuItem
key="table-explorer-delete"
class="!hover:bg-red-50"
@click="onFieldDelete(field)"
data-testid="nc-field-item-action-delete"
>
<div class="text-red-500">
<GeneralIcon icon="delete" class="group-hover:text-accent -ml-0.25 -mt-0.75 mr-0.5" />
Delete
{{ $t('general.delete') }}
</div>
</NcMenuItem>
</template>
@ -1054,6 +1112,7 @@ watch(
class="flex px-2 bg-white hover:bg-gray-100 border-b-1 border-gray-200 first:rounded-tl-lg last:border-b-1 pl-5 group"
:class="` ${compareCols(displayColumn, activeField) ? 'selected' : ''}`"
@click="changeField(displayColumn, $event)"
:data-testid="`nc-field-item-${fieldState(displayColumn)?.title || displayColumn.title}`"
>
<div class="flex items-center flex-1 py-2.5 gap-1 w-2/6">
<component
@ -1063,7 +1122,7 @@ watch(
'opacity-0 !cursor-default': isLocked,
}"
/>
<NcCheckbox :disabled="true" :checked="true" />
<NcCheckbox :disabled="true" :checked="true" data-testid="nc-field-visibility-checkbox" />
<SmartsheetHeaderCellIcon
v-if="displayColumn"
:column-meta="fieldState(displayColumn) || displayColumn"
@ -1079,7 +1138,7 @@ watch(
show-on-truncate-only
>
<template #title> {{ fieldState(displayColumn)?.title || displayColumn.title }} </template>
<span>
<span data-testid="nc-field-title">
{{ fieldState(displayColumn)?.title || displayColumn.title }}
</span>
</NcTooltip>
@ -1091,8 +1150,9 @@ watch(
color="red"
:border="false"
class="bg-red-50 text-red-700"
data-testid="nc-field-status-deleted-field"
>
Deleted field
{{ $t('labels.multiField.deletedField') }}
</NcBadge>
<NcBadge
@ -1100,8 +1160,9 @@ watch(
color="orange"
:border="false"
class="bg-orange-50 text-orange-700"
data-testid="nc-field-status-updated-field"
>
Updated field
{{ $t('labels.multiField.updatedField') }}
</NcBadge>
</div>
<NcButton
@ -1111,16 +1172,17 @@ watch(
class="no-action mr-2"
:disabled="loading"
@click="recoverField(displayColumn)"
data-testid="nc-field-restore-changes"
>
<div class="flex items-center text-xs gap-1">
<GeneralIcon icon="reload" />
Restore
{{ $t('general.restore') }}
</div>
</NcButton>
<NcDropdown
v-else
:trigger="['click']"
overlay-class-name="nc-dropdown-table-explorer-display-column"
overlay-class-name="nc-field-item-action-dropdown-display-column nc-dropdown-table-explorer-display-column"
@update:visible="onFieldOptionUpdate"
@click.stop
>
@ -1138,6 +1200,7 @@ watch(
activeField,
),
}"
data-testid="nc-field-item-action-button"
>
<GeneralIcon icon="threeDotVertical" class="no-action text-inherit" />
</NcButton>
@ -1150,6 +1213,7 @@ watch(
<div
class="flex flex-row px-3 py-2 w-46 justify-between items-center group hover:bg-gray-100 cursor-pointer"
@click="onClickCopyFieldUrl(displayColumn)"
data-testid="nc-field-item-action-copy-id"
>
<div class="flex flex-row items-baseline gap-x-1 font-bold text-xs">
<div class="text-gray-600">{{ $t('labels.idColon') }}</div>
@ -1193,9 +1257,9 @@ watch(
/>
<div v-else class="w-[25rem] flex flex-col justify-center p-4 items-center">
<img src="~assets/img/fieldPlaceholder.svg" class="!w-[18rem]" />
<div class="text-2xl text-gray-600 font-bold text-center pt-6">Select a field</div>
<div class="text-2xl text-gray-600 font-bold text-center pt-6">{{ $t('labels.multiField.selectField') }}</div>
<div class="text-center text-sm px-2 text-gray-500 pt-6">
Make changes to field properties by selecting a field from the list
{{ $t('labels.multiField.selectFieldLabel') }}
</div>
</div>
</div>

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

@ -157,6 +157,8 @@
"groupingField": "Grouping Field",
"insertAfter": "Insert After",
"insertBefore": "Insert Before",
"insertAbove": "Insert above",
"insertBelow": "Insert below",
"hideField": "Hide Field",
"sortAsc": "Sort Ascending",
"sortDesc": "Sort Descending",
@ -189,7 +191,8 @@
"shift": "Shift",
"enter": "Enter",
"seconds": "Seconds",
"paste": "Paste"
"paste": "Paste",
"restore": "Restore"
},
"objects": {
"workspace": "Workspace",
@ -642,7 +645,16 @@
"changeWsName": "Change Workspace Name",
"pressEnter": "Press Enter",
"newFormLoaded": "New form will be loaded after",
"webhook": "Webhook"
"webhook": "Webhook",
"multiField": {
"newField": "New field",
"saveChanges": "Save changes",
"updatedField": "Updated field",
"deletedField": "Deleted field",
"incompleteConfiguration": "Incomplete configuration",
"selectField": "Select a field",
"selectFieldLabel": "Make changes to field properties by selecting a field from the list"
}
},
"activity": {
"openInANewTab": "Open in a new tab",
@ -1048,7 +1060,12 @@
"barcodeFieldsCannotBeDirectlyChanged": "Warning: Barcode fields cannot be directly changed."
},
"duplicateProject": "Are you sure you want to duplicate the base?",
"duplicateTable": "Are you sure you want to duplicate the table?"
"duplicateTable": "Are you sure you want to duplicate the table?",
"multiField": {
"fieldVisibility": "You cannot change visibility of a field that is being edited. Please save or discard changes first.",
"moveEditedField": "You cannot move field that is being edited. Either save or discard changes first",
"moveDeletedField": "You cannot move field that is deleted. Either save or discard changes first"
}
},
"info": {
"disabledAsViewLocked": "Disabled as View is locked",
@ -1211,7 +1228,8 @@
"goToNext": "Go to next",
"thankYou": "Thank you!",
"submittedFormData": "You have successfully submitted the form data.",
"editingSystemKeyNotSupported": "Editing system key not supported"
"editingSystemKeyNotSupported": "Editing system key not supported",
"notAvailableAtTheMoment": "Not available at the moment"
},
"error": {
"nameRequired": "Name Required",
@ -1289,7 +1307,9 @@
"fieldRequired": "{value} cannot be empty.",
"projectNotAccessible": "Base not accessible",
"copyToClipboardError": "Failed to copy to clipboard",
"pasteFromClipboardError": "Failed to paste from clipboard"
"pasteFromClipboardError": "Failed to paste from clipboard",
"multiFieldSaveValidation": "Please complete the configuration of all fields before saving",
"somethingWentWrong": "Something went wrong"
},
"toast": {
"exportMetadata": "Base metadata exported successfully",

318
tests/playwright/pages/Dashboard/Details/FieldsPage.ts

@ -0,0 +1,318 @@
// Fields
import BasePage from '../../Base';
import { expect, Locator } from '@playwright/test';
import { DetailsPage } from './index';
import { UITypes } from 'nocodb-sdk';
export class FieldsPage extends BasePage {
readonly detailsPage: DetailsPage;
readonly searchFieldInput: Locator;
readonly addNewFieldButton: Locator;
readonly resetFieldChangesButton: Locator;
readonly saveChangesButton: Locator;
readonly addOrEditColumn: Locator;
readonly fieldListWrapper: Locator;
constructor(details: DetailsPage) {
super(details.rootPage);
this.detailsPage = details;
this.searchFieldInput = this.get().getByTestId('nc-field-search-input');
this.addNewFieldButton = this.get().getByTestId('nc-field-add-new');
this.resetFieldChangesButton = this.get().getByTestId('nc-field-reset');
this.saveChangesButton = this.get().getByTestId('nc-field-save-changes');
this.addOrEditColumn = this.get().getByTestId('add-or-edit-column');
this.fieldListWrapper = this.get().getByTestId('nc-field-list-wrapper');
}
get() {
return this.detailsPage.get().locator('.nc-fields-wrapper');
}
async fillSearch({ title }: { title: string }) {
const searchInput = this.get().getByTestId('nc-field-search-input');
await searchInput.click();
await searchInput.fill(title);
}
async clearSearch() {
await this.get().getByTestId('nc-field-clear-search').click();
}
async clickNewField() {
await this.addNewFieldButton.click();
}
async clickRestoreField({ title }: { title: string }) {
await this.getField({ title }).getByTestId('nc-field-restore-changes').click();
}
async createOrUpdate({
title,
type = UITypes.SingleLineText,
isUpdateMode = false,
saveChanges = true,
formula = '',
qrCodeValueColumnTitle = '',
barcodeValueColumnTitle = '',
barcodeFormat = '',
childTable = '',
childColumn = '',
relationType = '',
rollupType = '',
format = '',
dateFormat = 'YYYY-MM-DD',
timeFormat = 'HH:mm',
insertAboveColumnTitle,
insertBelowColumnTitle,
}: {
title: string;
type?: UITypes;
isUpdateMode?: boolean;
saveChanges?: boolean;
formula?: string;
qrCodeValueColumnTitle?: string;
barcodeValueColumnTitle?: string;
barcodeFormat?: string;
childTable?: string;
childColumn?: string;
relationType?: string;
rollupType?: string;
format?: string;
dateFormat?: string;
timeFormat?: string;
insertAboveColumnTitle?: string;
insertBelowColumnTitle?: string;
}) {
if (!isUpdateMode) {
if (insertAboveColumnTitle) {
await this.selectFieldAction({ title: insertAboveColumnTitle, action: 'insert-above' });
} else if (insertBelowColumnTitle) {
await this.selectFieldAction({ title: insertBelowColumnTitle, action: 'insert-below' });
} else {
await this.clickNewField();
}
}
await this.addOrEditColumn.waitFor({ state: 'visible' });
await this.fillTitle({ title });
await this.selectType({ type });
await this.rootPage.waitForTimeout(500);
switch (type) {
case 'SingleSelect':
case 'MultiSelect':
break;
case 'Duration':
if (format) {
await this.addOrEditColumn.locator('.ant-select-single').nth(1).click();
await this.rootPage
.locator(`.ant-select-item`, {
hasText: format,
})
.click();
}
break;
case 'Date':
await this.addOrEditColumn.locator('.nc-date-select').click();
await this.rootPage.locator('.nc-date-select').pressSequentially(dateFormat);
await this.rootPage.locator('.ant-select-item').locator(`text="${dateFormat}"`).click();
break;
case 'DateTime':
// Date Format
await this.addOrEditColumn.locator('.nc-date-select').click();
await this.rootPage.locator('.ant-select-item').locator(`text="${dateFormat}"`).click();
// Time Format
await this.addOrEditColumn.locator('.nc-time-select').click();
await this.rootPage.locator('.ant-select-item').locator(`text="${timeFormat}"`).click();
break;
case 'Formula':
await this.addOrEditColumn.locator('.nc-formula-input').fill(formula);
break;
case 'QrCode':
await this.addOrEditColumn.locator('.ant-select-single').nth(1).click();
await this.rootPage
.locator(`.ant-select-item`)
.locator(`[data-testid="nc-qr-${qrCodeValueColumnTitle}"]`)
.click();
break;
case 'Barcode':
await this.addOrEditColumn.locator('.ant-select-single').nth(1).click();
await this.rootPage
.locator(`.ant-select-item`, {
hasText: new RegExp(`^${barcodeValueColumnTitle}$`),
})
.click();
break;
case 'Lookup':
await this.addOrEditColumn.locator('.ant-select-single').nth(1).click();
await this.rootPage
.locator(`.ant-select-item`, {
hasText: childTable,
})
.click();
await this.addOrEditColumn.locator('.ant-select-single').nth(2).click();
await this.rootPage
.locator(`.ant-select-item`, {
hasText: childColumn,
})
.last()
.click();
break;
case 'Rollup':
await this.addOrEditColumn.locator('.ant-select-single').nth(1).click();
await this.rootPage
.locator(`.ant-select-item`, {
hasText: childTable,
})
.click();
await this.addOrEditColumn.locator('.ant-select-single').nth(2).click();
await this.rootPage
.locator(`.nc-dropdown-relation-column >> .ant-select-item`, {
hasText: childColumn,
})
.click();
await this.addOrEditColumn.locator('.ant-select-single').nth(3).click();
await this.rootPage
.locator(`.nc-dropdown-rollup-function >> .ant-select-item`, {
hasText: rollupType,
})
.nth(0)
.click();
break;
case 'Links':
await this.addOrEditColumn
.locator('.nc-ltar-relation-type >> .ant-radio')
.nth(relationType === 'Has Many' ? 0 : 1)
.click();
await this.addOrEditColumn.locator('.ant-select-single').nth(1).click();
await this.rootPage.locator(`.nc-ltar-child-table >> input[type="search"]`).fill(childTable);
await this.rootPage
.locator(`.nc-dropdown-ltar-child-table >> .ant-select-item`, {
hasText: childTable,
})
.nth(0)
.click();
break;
case 'User':
break;
default:
break;
}
if (saveChanges) {
await this.saveChanges();
const fieldsText = await this.getAllFieldText();
if (insertAboveColumnTitle) {
// verify field inserted above the target field
expect(fieldsText[fieldsText.findIndex(title => title.startsWith(insertAboveColumnTitle)) - 1]).toBe(title);
} else if (insertBelowColumnTitle) {
// verify field inserted below the target field
expect(fieldsText[fieldsText.findIndex(title => title.startsWith(insertBelowColumnTitle)) + 1]).toBe(title);
} else {
// verify field inserted at the end
expect(fieldsText[fieldsText.length - 1]).toBe(title);
}
}
}
async fillTitle({ title }: { title: string }) {
const fieldTitleInput = this.addOrEditColumn.locator('.nc-fields-input');
await fieldTitleInput.click();
await fieldTitleInput.fill(title);
}
async selectType({ type }: { type: string }) {
await this.addOrEditColumn.locator('.ant-select-selector > .ant-select-selection-item').click();
await this.addOrEditColumn.locator('.ant-select-selection-search-input[aria-expanded="true"]').waitFor();
await this.addOrEditColumn.locator('.ant-select-selection-search-input[aria-expanded="true"]').fill(type);
// Select column type
await this.rootPage.locator('.rc-virtual-list-holder-inner > div').locator(`text="${type}"`).click();
}
async saveChanges() {
await this.waitForResponse({
uiAction: async () => await this.saveChangesButton.click(),
requestUrlPathToMatch: 'api/v1/db/meta/views/',
httpMethodsToMatch: ['GET'],
responseJsonMatcher: json => json['list'],
});
await this.rootPage.waitForTimeout(200);
}
getField({ title }: { title: string }) {
return this.fieldListWrapper.getByTestId(`nc-field-item-${title}`);
}
async getFieldVisibilityCheckbox({ title }: { title: string }) {
return this.getField({ title }).getByTestId('nc-field-visibility-checkbox');
}
async selectFieldAction({
title,
action,
isDisplayValueField = false,
}: {
title: string;
action: 'copy-id' | 'duplicate' | 'insert-above' | 'insert-below' | 'delete';
isDisplayValueField?: boolean;
}) {
const field = this.getField({ title });
await field.scrollIntoViewIfNeeded();
await field.hover();
// await field.getByTestId('nc-field-item-action-button').waitFor({ state: 'visible' });
await field.getByTestId('nc-field-item-action-button').click();
const fieldActionDropdown = isDisplayValueField
? this.rootPage.locator('.nc-field-item-action-dropdown-display-column')
: this.rootPage.locator('.nc-field-item-action-dropdown');
await fieldActionDropdown.waitFor({ state: 'visible' });
await fieldActionDropdown.getByTestId(`nc-field-item-action-${action}`).click();
if (action === 'copy-id') {
await field.getByTestId('nc-field-item-action-button').click();
}
await fieldActionDropdown.waitFor({ state: 'hidden' });
}
async getAllFieldText() {
const fieldsText = [];
const locator = this.fieldListWrapper.locator('>div');
const count = await locator.count();
for (let i = 0; i < count; i++) {
await locator.nth(i).scrollIntoViewIfNeeded();
const text = await locator.nth(i).getByTestId('nc-field-title').textContent();
fieldsText.push(text);
}
return fieldsText;
}
async getFieldId({ title, isDisplayValueField = false }: { title: string; isDisplayValueField?: boolean }) {
const field = this.getField({ title });
await field.scrollIntoViewIfNeeded();
await field.hover();
await field.getByTestId('nc-field-item-action-button').waitFor({ state: 'visible' });
await field.getByTestId('nc-field-item-action-button').click();
const fieldActionDropdown = isDisplayValueField
? this.rootPage.locator('.nc-field-item-action-dropdown-display-column')
: this.rootPage.locator('.nc-field-item-action-dropdown');
await fieldActionDropdown.waitFor({ state: 'visible' });
const fieldId = await fieldActionDropdown.getByTestId('nc-field-item-id').textContent();
await field.getByTestId('nc-field-item-action-button').click();
await fieldActionDropdown.waitFor({ state: 'hidden' });
return fieldId;
}
}

7
tests/playwright/pages/Dashboard/Details/index.ts

@ -4,12 +4,14 @@ import { TopbarPage } from '../common/Topbar';
import { Locator } from '@playwright/test';
import { WebhookPage } from './WebhookPage';
import { ErdPage } from './ErdPage';
import { FieldsPage } from './FieldsPage';
export class DetailsPage extends BasePage {
readonly dashboard: DashboardPage;
readonly topbar: TopbarPage;
readonly webhook: WebhookPage;
readonly relations: ErdPage;
readonly fields: FieldsPage;
readonly tab_webhooks: Locator;
readonly tab_apiSnippet: Locator;
@ -24,6 +26,7 @@ export class DetailsPage extends BasePage {
this.topbar = dashboard.grid.topbar;
this.webhook = new WebhookPage(this);
this.relations = new ErdPage(this);
this.fields = new FieldsPage(this);
this.tab_webhooks = this.get().locator(`[data-testid="nc-webhooks-tab"]`);
this.tab_apiSnippet = this.get().locator(`[data-testid="nc-apis-tab"]`);
@ -53,4 +56,8 @@ export class DetailsPage extends BasePage {
async clickRelationsTab() {
await this.tab_relations.click();
}
async clickFieldsTab() {
await this.tab_fields.click();
}
}

380
tests/playwright/tests/db/features/multiFieldEditor.spec.ts

@ -0,0 +1,380 @@
import { expect, test } from '@playwright/test';
import { DashboardPage } from '../../../pages/Dashboard';
import setup, { unsetup } from '../../../setup';
import { FieldsPage } from '../../../pages/Dashboard/Details/FieldsPage';
import { getTextExcludeIconText } from '../../utils/general';
import { UITypes } from 'nocodb-sdk';
const allFieldList = [
{
title: 'Single Line Text',
type: UITypes.SingleLineText,
},
{
title: 'Long Text',
type: UITypes.LongText,
},
{
title: 'Number',
type: UITypes.Number,
},
{
title: 'Decimal',
type: UITypes.Decimal,
},
{
title: 'Attachment',
type: UITypes.Attachment,
},
{
title: 'Checkbox',
type: UITypes.Checkbox,
},
{
title: 'MultiSelect',
type: UITypes.MultiSelect,
},
{
title: 'SingleSelect',
type: UITypes.SingleSelect,
},
{
title: 'Date',
type: UITypes.Date,
},
{
title: 'DateTime',
type: UITypes.DateTime,
},
{
title: 'Year',
type: UITypes.Year,
},
{
title: 'Time',
type: UITypes.Time,
},
{
title: 'PhoneNumber',
type: UITypes.PhoneNumber,
},
{
title: 'Email',
type: UITypes.Email,
},
{
title: 'URL',
type: UITypes.URL,
},
{
title: 'Currency',
type: UITypes.Currency,
},
{
title: 'Percent',
type: UITypes.Percent,
},
{
title: 'Duration',
type: UITypes.Duration,
},
{
title: 'Rating',
type: UITypes.Rating,
},
{
title: 'Formula',
type: UITypes.Formula,
formula: 'LEN({Title})',
},
{
title: 'QrCode',
type: UITypes.QrCode,
qrCodeValueColumnTitle: 'Title',
},
{
title: 'Barcode',
type: UITypes.Barcode,
barcodeValueColumnTitle: 'Title',
},
{
title: 'Geometry',
type: UITypes.Geometry,
},
{
title: 'JSON',
type: UITypes.JSON,
},
{
title: 'User',
type: UITypes.User,
},
{
title: 'Links',
type: UITypes.Links,
relationType: 'Has Many',
childTable: 'Multifield',
},
];
test.describe('Multi Field Editor', () => {
let dashboard: DashboardPage, fields: FieldsPage;
let context: any;
const defaultFieldName = 'Multi Field Editor';
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.base);
fields = dashboard.details.fields;
await dashboard.treeView.createTable({ title: 'Multifield', baseTitle: context.base.title });
await openMultiFieldOfATable();
});
test.afterEach(async () => {
await unsetup(context);
});
async function openMultiFieldOfATable() {
await dashboard.grid.topbar.openDetailedTab();
await dashboard.details.clickFieldsTab();
}
async function toggleShowSystemFieldsFromDataTab() {
await dashboard.grid.topbar.openDataTab();
await dashboard.grid.toolbar.fields.toggleShowSystemFields();
await openMultiFieldOfATable();
}
const verifyGridColumnHeaders = async ({ fields = [] }: { fields: string[] }) => {
await dashboard.grid.topbar.openDataTab();
const locator = dashboard.grid.get().locator(`th`);
const count = await locator.count();
// exclude first checkbox and last add new column
expect(count - 2).toBe(fields.length);
for (let i = 1; i < count - 1; i++) {
const header = locator.nth(i);
const text = await getTextExcludeIconText(header);
expect(text).toBe(fields[i - 1]);
}
await openMultiFieldOfATable();
};
const searchAndVerifyFields = async ({ searchQuery, fieldList }: { searchQuery: string; fieldList: string[] }) => {
await fields.searchFieldInput.fill(searchQuery);
const allFields = await fields.getAllFieldText();
expect(allFields).toEqual(
searchQuery ? fieldList.filter(field => field.toLowerCase().includes(searchQuery.toLowerCase())) : fieldList
);
};
test('Verify system fields are not listed, Add New field, update & Restore, reset', async () => {
//Verify system fields are not listed
await toggleShowSystemFieldsFromDataTab();
let fieldsText = await fields.getAllFieldText();
expect(fieldsText.length).toBe(1);
await toggleShowSystemFieldsFromDataTab();
// Add New Field
await fields.createOrUpdate({ title: 'Name', saveChanges: false });
await expect(fields.getField({ title: 'Name' })).toContainText('New field');
await fields.saveChanges();
// Update Field title
await fields.getField({ title: 'Name' }).click();
await fields.createOrUpdate({ title: 'Updated Name', saveChanges: false, isUpdateMode: true });
await expect(fields.getField({ title: 'Updated Name' })).toContainText('Updated field');
await fields.saveChanges();
// Update and restore field changes
await fields.getField({ title: 'Updated Name' }).click();
await fields.createOrUpdate({ title: 'Updated Name to restore', saveChanges: false, isUpdateMode: true });
await fields.clickRestoreField({ title: 'Updated Name to restore' });
// verify grid column header
fieldsText = await fields.getAllFieldText();
expect(fieldsText).toEqual(['Title', 'Updated Name']);
await verifyGridColumnHeaders({ fields: fieldsText });
// add new fields then reset changes and verify
await fields.createOrUpdate({ title: 'field to reset', saveChanges: false });
await fields.createOrUpdate({ title: 'Random', saveChanges: false });
await fields.resetFieldChangesButton.click();
// verify with old fields
await verifyGridColumnHeaders({ fields: fieldsText });
});
// Todo: remove `skip`, if `optimized dependencies changed. reloading` issue is fixed
test.skip('Add all fields and check status on clicking each field', async () => {
// Add all fields, verify status and save
for (const field of allFieldList) {
await fields.createOrUpdate({ ...field, saveChanges: false });
await expect(fields.getField({ title: field.title })).toContainText('New field');
await fields.saveChanges();
}
let fieldsText = await fields.getAllFieldText();
// verify all newly added field and its order
expect(fieldsText).toEqual(['Title', ...allFieldList.map(field => field.title)]);
// click on each field and check status
fieldsText = await fields.getAllFieldText();
for (const title of fieldsText) {
await fields.getField({ title }).click();
await expect(fields.getField({ title })).not.toContainText(['New field', 'Updated field']);
}
});
test('Field operations: CopyId, Duplicate, InsertAbove, InsertBelow, Delete, Hide', async () => {
// Add New Field
await fields.createOrUpdate({ title: defaultFieldName });
// copy-id and verify
const fieldId = await fields.getFieldId({ title: defaultFieldName });
await fields.selectFieldAction({ title: defaultFieldName, action: 'copy-id' });
expect(fieldId).toBe(await dashboard.getClipboardText());
// duplicate and verify
await fields.selectFieldAction({ title: defaultFieldName, action: 'duplicate' });
await fields.saveChanges();
let fieldsText = await fields.getAllFieldText();
expect(fieldsText[fieldsText.findIndex(field => field === defaultFieldName) + 1]).toBe(`${defaultFieldName}_copy`);
// insert and verify
await fields.createOrUpdate({ title: 'Above Inserted Field', insertAboveColumnTitle: defaultFieldName });
await fields.createOrUpdate({ title: 'Below Inserted Field', insertBelowColumnTitle: defaultFieldName });
// delete and verify
await fields.selectFieldAction({ title: `${defaultFieldName}_copy`, action: 'delete' });
await expect(fields.getField({ title: `${defaultFieldName}_copy` })).toContainText('Deleted field');
await fields.saveChanges();
fieldsText = await fields.getAllFieldText();
expect(!fieldsText.includes(`${defaultFieldName}_copy`)).toBeTruthy();
// verify grid column header
await verifyGridColumnHeaders({ fields: fieldsText });
// Hide field and verify grid column header
await (await fields.getFieldVisibilityCheckbox({ title: defaultFieldName })).click();
await fields.saveChanges();
await verifyGridColumnHeaders({ fields: fieldsText.filter(field => field !== defaultFieldName) });
});
test('Search field and verify', async () => {
const fieldList = ['Single Line Text', 'Long text', 'Rich text', 'Number', 'Percentage'];
for (const field of fieldList) {
await fields.createOrUpdate({ title: field, saveChanges: false });
}
await fields.saveChanges();
let searchQuery = 'text';
await searchAndVerifyFields({
searchQuery,
fieldList,
});
searchQuery = 'Rich text';
await searchAndVerifyFields({
searchQuery,
fieldList: ['Title', ...fieldList],
});
// clear search and verify
await fields.clearSearch();
await searchAndVerifyFields({
searchQuery: '',
fieldList: ['Title', ...fieldList],
});
});
test('Field Reorder and verify', async () => {
// default order: ['Title', 'Single Line Text', 'Long Text', 'Number', 'Percent','Links']
const fieldList = [
{
title: 'Single Line Text',
type: UITypes.SingleLineText,
},
{
title: 'Long Text',
type: UITypes.LongText,
},
{
title: 'Number',
type: UITypes.Number,
},
{
title: 'Percent',
type: UITypes.Percent,
},
{
title: 'Links',
type: UITypes.Links,
relationType: 'Has Many',
childTable: 'Multifield',
},
];
for (const field of fieldList) {
await fields.createOrUpdate({ ...field, saveChanges: false });
}
await fields.saveChanges();
// updated order : ['Title', 'Long Text','Single Line Text', 'Number', 'Percent','Links']
await fields.getField({ title: fieldList[0].title }).dragTo(fields.getField({ title: fieldList[1].title }));
await expect(fields.getField({ title: fieldList[0].title })).toContainText('Updated field');
// updated order : ['Title', 'Long Text','Single Line Text', 'Number','Links', 'Percent']
await fields.getField({ title: fieldList[4].title }).dragTo(fields.getField({ title: fieldList[3].title }));
await expect(fields.getField({ title: fieldList[4].title })).toContainText('Updated field');
await fields.saveChanges();
const fieldsText = await fields.getAllFieldText();
const expectedFieldText = ['Title', 'Long Text', 'Single Line Text', 'Number', 'Links', 'Percent'];
expect(fieldsText).toEqual(expectedFieldText);
await verifyGridColumnHeaders({ fields: expectedFieldText });
});
test('Keyboard shortcuts: add new field, save and delete', async () => {
// add new field
await dashboard.rootPage.keyboard.press('Alt+C');
// verify field is added and has `New field` status
let fieldsText = await fields.getAllFieldText();
await expect(fields.getField({ title: fieldsText[fieldsText.length - 1] })).toContainText('New field');
// update title
await fields.createOrUpdate({ title: defaultFieldName, isUpdateMode: true });
// save the changes
await dashboard.rootPage.keyboard.press((await dashboard.isMacOs()) ? 'Meta+S' : 'Control+S');
await dashboard.rootPage.waitForTimeout(500);
// verify result
fieldsText = await fields.getAllFieldText();
expect(fieldsText).toEqual(['Title', defaultFieldName]);
// delete field
await fields.getField({ title: defaultFieldName }).click();
await dashboard.rootPage.keyboard.press((await dashboard.isMacOs()) ? 'Meta+Delete' : 'Delete');
await expect(fields.getField({ title: defaultFieldName })).toContainText('Deleted field');
// save the changes
await dashboard.rootPage.keyboard.press((await dashboard.isMacOs()) ? 'Meta+S' : 'Control+S');
await dashboard.rootPage.waitForTimeout(500);
fieldsText = await fields.getAllFieldText();
expect(fieldsText).toEqual(['Title']);
});
});
Loading…
Cancel
Save