Browse Source

Merge pull request #5928 from nocodb/feat/ui-bulk-update

feat: bulk update records using UI
pull/5980/head
Raju Udava 2 years ago committed by GitHub
parent
commit
75f5136289
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 508
      packages/nc-gui/components/dlg/BulkUpdate.vue
  2. 42
      packages/nc-gui/components/smartsheet/Grid.vue
  3. 18
      packages/nc-gui/composables/useViewData.ts
  4. 2
      packages/nocodb-sdk/src/lib/Api.ts
  5. 36
      packages/nocodb/src/db/BaseModelSqlv2.ts
  6. 7
      packages/nocodb/src/schema/swagger.json
  7. 192
      tests/playwright/pages/Dashboard/BulkUpdate/index.ts
  8. 13
      tests/playwright/pages/Dashboard/Grid/index.ts
  9. 2
      tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts
  10. 23
      tests/playwright/pages/Dashboard/common/Cell/TimeCell.ts
  11. 23
      tests/playwright/pages/Dashboard/common/Cell/YearCell.ts
  12. 6
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  13. 3
      tests/playwright/pages/Dashboard/index.ts
  14. 30
      tests/playwright/setup/demoTable.ts
  15. 365
      tests/playwright/tests/db/bulkUpdate.spec.ts
  16. 4
      tests/playwright/tests/db/projectOperations.spec.ts

508
packages/nc-gui/components/dlg/BulkUpdate.vue

@ -0,0 +1,508 @@
<script setup lang="ts">
import type { TableType, ViewType } from 'nocodb-sdk'
import { RelationTypes, UITypes, isSystemColumn, isVirtualCol } from 'nocodb-sdk'
import Draggable from 'vuedraggable'
import {
CellClickHookInj,
IsExpandedFormOpenInj,
IsFormInj,
MetaInj,
PaginationDataInj,
provide,
ref,
toRef,
useVModel,
} from '#imports'
import type { Row } from '~/lib'
interface Props {
modelValue: boolean
meta: TableType
view?: ViewType
bulkUpdateRows?: Function
bulkUpdateView?: Function
selectedAllRecords?: boolean
rows?: Row[]
}
const props = defineProps<Props>()
const emits = defineEmits(['update:modelValue', 'cancel'])
enum BulkUpdateMode {
ALL = 0,
SELECTED = 1,
}
const meta = toRef(props, 'meta')
const isExpanded = useVModel(props, 'modelValue', emits, {
defaultValue: false,
})
// override cell click hook to avoid unexpected behavior at form fields
provide(CellClickHookInj, null)
provide(MetaInj, meta)
provide(IsFormInj, ref(true))
provide(IsExpandedFormOpenInj, isExpanded)
const formState: Record<string, any> = reactive({})
const updateMode = ref(BulkUpdateMode.ALL)
const moved = ref(false)
const drag = ref(false)
const editColumns = ref<Record<string, any>[]>([])
const tempRow = ref<Row>({
row: {},
oldRow: {},
rowMeta: {},
})
useProvideSmartsheetRowStore(meta, tempRow)
const fields = computed(() => {
return (meta.value.columns ?? []).filter(
(col) =>
!isSystemColumn(col) &&
!isVirtualCol(col) &&
!col.pk &&
!col.unique &&
editColumns.value.find((c) => c.id === col.id) === undefined,
)
})
const paginatedData = inject(PaginationDataInj)!
const editCount = computed(() => {
if (updateMode.value === BulkUpdateMode.SELECTED) {
return props.rows!.length
} else {
return paginatedData.value?.totalRows ?? Infinity
}
})
function isRequired(_columnObj: Record<string, any>, required = false) {
let columnObj = _columnObj
if (
columnObj.uidt === UITypes.LinkToAnotherRecord &&
columnObj.colOptions &&
columnObj.colOptions.type === RelationTypes.BELONGS_TO
) {
columnObj = (meta?.value?.columns || []).find(
(c: Record<string, any>) => c.id === columnObj.colOptions.fk_child_column_id,
) as Record<string, any>
}
return required || (columnObj && columnObj.rqd && !columnObj.cdf)
}
function onMove(event: any) {
const { element } = event.added || event.moved || event.removed
if (event.added) {
if (editColumns.value.find((c) => c.id === element.id)) {
return
}
editColumns.value.push(element)
formState[element.title] = null
}
if (event.removed) {
delete formState[element.title]
}
}
function handleMouseUp(col: Record<string, any>) {
if (!moved.value) {
if (editColumns.value.find((c) => c.id === col.id)) {
return
}
editColumns.value.push(col)
formState[col.title] = null
}
}
function handleRemove(col: Record<string, any>) {
const index = editColumns.value.findIndex((c) => c.id === col.id)
if (index > -1) {
editColumns.value.splice(index, 1)
delete formState[col.title]
}
}
const save = () => {
Modal.confirm({
title:
updateMode.value === BulkUpdateMode.SELECTED
? `Do you want to update selected ${editCount.value} records?`
: h('div', {}, [
`Do you want to update all ${editCount.value} records in current view?`,
h('br'),
h('div', { class: 'text-gray-500 text-xs mt-2' }, `Note: Undo on bulk update ALL is not supported`),
]),
type: 'warn',
onOk: async () => {
if (updateMode.value === BulkUpdateMode.SELECTED) {
if (props.rows && props.bulkUpdateRows) {
const propsToUpdate = Object.keys(formState)
for (const row of props.rows) {
for (const prop of Object.keys(row.row)) {
if (propsToUpdate.includes(prop)) {
row.row[prop] = formState[prop]
row.rowMeta.selected = false
}
}
}
await props.bulkUpdateRows(props.rows, propsToUpdate)
}
} else {
if (props.bulkUpdateView) {
await props.bulkUpdateView(formState)
}
}
isExpanded.value = false
},
})
}
const addAllColumns = () => {
for (const col of fields.value) {
if (editColumns.value.find((c) => c.id === col.id)) {
continue
}
if (!col || !col.title) continue
editColumns.value.push(col)
formState[col.title] = null
}
}
const removeAllColumns = () => {
for (const col of editColumns.value) {
delete formState[col.title]
}
editColumns.value = []
}
onMounted(() => {
if (!props.selectedAllRecords && !props.rows) {
isExpanded.value = false
return
}
if (props.selectedAllRecords && props.selectedAllRecords === true) {
updateMode.value = BulkUpdateMode.ALL
} else {
if (props.rows && props.rows.length) {
updateMode.value = BulkUpdateMode.SELECTED
}
}
})
</script>
<template>
<a-drawer
v-model:visible="isExpanded"
:footer="null"
width="min(90vw,900px)"
:body-style="{ 'padding': 0, 'display': 'flex', 'flex-direction': 'column' }"
:closable="false"
class="nc-drawer-bulk-update"
:class="{ active: isExpanded }"
>
<div class="flex p-2 items-center gap-2 p-4 nc-bulk-update-header">
<h5 class="text-lg font-weight-medium flex items-center gap-1 mb-0 min-w-0 overflow-x-hidden truncate">
<GeneralTableIcon :style="{ color: iconColor }" :meta="meta" class="mx-2" />
<template v-if="meta">
{{ meta.title }}
</template>
<!-- TODO i18n -->
<div>: Bulk Update ({{ editCount }} records)</div>
</h5>
<div class="flex-1" />
<a-button
v-if="updateMode === BulkUpdateMode.ALL"
class="nc-bulk-update-save-btn"
type="primary"
:disabled="!editColumns.length"
@click="save"
>
<div class="flex items-center">
<component :is="iconMap.contentSaveExit" class="mr-1" />
<!-- TODO i18n -->
Bulk Update All
</div>
</a-button>
<a-button
v-else-if="updateMode === BulkUpdateMode.SELECTED"
class="nc-bulk-update-save-btn"
type="primary"
:disabled="!editColumns.length"
@click="save"
>
<div class="flex items-center">
<component :is="iconMap.contentSaveStay" class="mr-1" />
<!-- TODO i18n -->
Bulk Update Selected
</div>
</a-button>
<a-dropdown>
<component :is="iconMap.threeDotVertical" class="nc-icon-transition" />
<template #overlay>
<a-menu>
<a-menu-item @click="isExpanded = false">
<div v-e="['c:row-expand:delete']" class="py-2 flex gap-2 items-center">
<component
:is="iconMap.closeCircle"
class="nc-icon-transition cursor-pointer select-none nc-delete-row text-gray-500 mx-1 min-w-4"
/>
{{ $t('general.close') }}
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<div class="flex w-full !bg-gray-100 flex-1">
<div class="form w-2/3 p-4">
<Draggable
ref="draggableRef"
:list="editColumns"
item-key="fk_column_id"
draggable=".item"
group="form-inputs"
class="h-full"
:move="onMoveCallback"
@change="onMove($event)"
@start="drag = true"
@end="drag = false"
>
<template #item="{ element }">
<div
class="color-transition nc-editable item cursor-pointer hover:(bg-primary bg-opacity-10 ring-1 ring-accent ring-opacity-100) px-4 lg:px-12 py-4 relative"
:class="[`nc-bulk-update-drag-${element.title.replaceAll(' ', '')}`]"
data-testid="nc-bulk-update-fields"
>
<div class="text-gray group absolute top-4 right-12">
<component
:is="iconMap.eyeSlash"
class="opacity-0 nc-field-remove-icon group-hover:text-red-500 cursor-pointer !text-xl"
data-testid="nc-bulk-update-fields-remove-icon"
@click="handleRemove(element)"
/>
</div>
<div>
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(element)"
:column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)"
:hide-menu="true"
data-testid="nc-bulk-update-input-label"
/>
<LazySmartsheetHeaderCell
v-else
:column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)"
:hide-menu="true"
data-testid="nc-bulk-update-input-label"
/>
</div>
<a-form-item
v-if="isVirtualCol(element)"
:name="element.title"
class="!mb-0 nc-input-required-error"
:rules="[
{
required: isRequired(element, element.required),
message: `${element.label || element.title} is required`,
},
]"
>
<LazySmartsheetVirtualCell
v-model="formState[element.title]"
class="nc-input"
:class="`nc-bulk-update-input-${element.title.replaceAll(' ', '')}`"
:data-testid="`nc-bulk-update-input-${element.title.replaceAll(' ', '')}`"
:column="element"
/>
</a-form-item>
<a-form-item
v-else
:name="element.title"
class="!mb-0 nc-input-required-error"
:rules="[
{
required: isRequired(element, element.required),
message: `${element.label || element.title} is required`,
},
]"
>
<LazySmartsheetDivDataCell class="!bg-white rounded px-1 min-h-[35px] flex items-center mt-2 relative">
<LazySmartsheetCell
v-model="formState[element.title]"
:data-testid="`nc-bulk-update-input-${element.title.replaceAll(' ', '')}`"
:column="element"
:edit-enabled="true"
:active="true"
/>
</LazySmartsheetDivDataCell>
</a-form-item>
<div class="nc-bulk-update-help-text text-gray-500 text-xs" data-testid="nc-bulk-update-input-help-text-label">
{{ element.description }}
</div>
</div>
</template>
<template #footer>
<div v-if="!editColumns.length" class="mt-4 border-dashed border-2 border-gray-400 py-3 text-gray-400 text-center">
<!-- TODO i18n -->
Drag and drop fields here to edit
</div>
</template>
</Draggable>
</div>
<div class="nc-columns-drawer w-1/3 p-3 flex flex-col bg-[#eceff1]" :class="{ active: columnsDrawer }">
<div class="text-bold uppercase text-gray-500 font-weight-bold !mb-2">
<!-- TODO i18n -->
Select columns to Edit
</div>
<div class="flex flex-wrap gap-2 mb-4">
<button
v-if="fields.length > editColumns.length"
type="button"
class="nc-bulk-update-add-all color-transition bg-white transform hover:(text-primary ring-1 ring-primary ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded"
data-testid="nc-bulk-update-add-all"
tabindex="-1"
@click="addAllColumns"
>
<!-- Add all -->
{{ $t('general.addAll') }}
</button>
<button
v-if="editColumns.length"
type="button"
class="nc-bulk-update-remove-all color-transition bg-white transform hover:(text-primary ring-1 ring-primary ring-opacity-100) active:translate-y-[1px] px-2 py-1 shadow-md rounded"
data-testid="nc-bulk-update-remove-all"
tabindex="-1"
@click="removeAllColumns"
>
<!-- Remove all -->
{{ $t('general.removeAll') }}
</button>
</div>
<Draggable
:list="fields"
item-key="id"
draggable=".item"
group="form-inputs"
class="flex flex-col gap-2 flex-1"
@start="drag = true"
@end="drag = false"
>
<template #item="{ element }">
<a-card
size="small"
class="!border-0 color-transition cursor-pointer item hover:(bg-primary ring-1 ring-accent ring-opacity-100) bg-opacity-10 !rounded !shadow-lg"
:data-testid="`nc-bulk-update-hidden-column-${element.label || element.title}`"
@mousedown="moved = false"
@mousemove="moved = false"
@mouseup="handleMouseUp(element)"
>
<div class="flex">
<div class="flex flex-1">
<LazySmartsheetHeaderVirtualCell
v-if="isVirtualCol(element)"
:column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)"
:hide-menu="true"
/>
<LazySmartsheetHeaderCell
v-else
:column="{ ...element, title: element.label || element.title }"
:required="isRequired(element, element.required)"
:hide-menu="true"
/>
</div>
</div>
</a-card>
</template>
</Draggable>
</div>
</div>
</a-drawer>
</template>
<style scoped lang="scss">
:deep(input, select, textarea) {
@apply !bg-white;
}
.nc-bulk-update-wrapper {
max-height: max(calc(100vh - 65px), 600px);
height: max-content !important;
}
.nc-editable:hover {
:deep(.nc-field-remove-icon) {
@apply opacity-100;
}
}
.nc-input {
@apply appearance-none w-full !bg-white rounded px-2 py-2 my-2 border-solid border-1 border-primary border-opacity-50;
:deep(input) {
@apply !px-1;
}
}
.form-meta-input::placeholder {
@apply text-[#3d3d3d] italic;
}
.nc-bulk-update-input-label,
.nc-bulk-update-input-help-text {
&::placeholder {
@apply !text-gray-500 !text-xs;
}
}
.nc-bulk-update-help-text,
.nc-input-required-error {
max-width: 100%;
word-break: break-all;
white-space: pre-line;
}
:deep(.nc-cell-attachment) {
@apply p-0;
.nc-attachment-cell {
@apply px-4 min-h-[75px] w-full h-full;
.nc-attachment {
@apply md: (w-[50px] h-[50px]) lg:(w-[75px] h-[75px]) min-h-[50px] min-w-[50px];
}
.nc-attachment-cell-dropzone {
@apply rounded bg-gray-400/75;
}
}
}
</style>

42
packages/nc-gui/components/smartsheet/Grid.vue

@ -99,6 +99,8 @@ const contextMenu = computed({
})
const contextMenuClosing = ref(false)
const bulkUpdateDlg = ref(false)
const routeQuery = $computed(() => route.query as Record<string, string>)
const contextMenuTarget = ref<{ row: number; col: number } | null>(null)
const expandedFormDlg = ref(false)
@ -128,6 +130,7 @@ const {
getExpandedRowIndex,
deleteRangeOfRows,
bulkUpdateRows,
bulkUpdateView,
} = useViewData(meta, view, xWhere)
const { getMeta } = useMetas()
@ -977,7 +980,12 @@ function addEmptyRow(row?: number) {
:style="{ height: rowHeight ? `${rowHeight * 1.8}rem` : `1.8rem` }"
:data-testid="`grid-row-${rowIndex}`"
>
<td key="row-index" class="caption nc-grid-cell pl-5 pr-1" :data-testid="`cell-Id-${rowIndex}`">
<td
key="row-index"
class="caption nc-grid-cell pl-5 pr-1"
:data-testid="`cell-Id-${rowIndex}`"
@contextmenu="contextMenuTarget = null"
>
<div class="items-center flex gap-1 min-w-[60px]">
<div
v-if="!readOnly || !isLocked"
@ -1120,8 +1128,23 @@ function addEmptyRow(row?: number) {
</div>
</a-menu-item>
<a-menu-item v-if="!contextMenuClosing && data.some((r) => r.rowMeta.selected)" @click="deleteSelectedRows">
<div v-e="['a:row:delete-bulk']" class="nc-project-menu-item">
<a-menu-item
v-if="!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected)"
@click="bulkUpdateDlg = true"
>
<div v-e="['a:row:update-bulk']" class="nc-project-menu-item">
<component :is="iconMap.edit" />
<!-- TODO i18n -->
Update Selected Rows
</div>
</a-menu-item>
<a-menu-item
v-if="!contextMenuClosing && !contextMenuTarget && data.some((r) => r.rowMeta.selected)"
@click="deleteSelectedRows"
>
<div v-e="['a:row:delete-bulk']" class="nc-project-menu-item text-red-500">
<component :is="iconMap.delete" />
<!-- Delete Selected Rows -->
{{ $t('activity.deleteSelectedRow') }}
</div>
@ -1208,6 +1231,19 @@ function addEmptyRow(row?: number) {
@prev="navigateToSiblingRow(NavigateDir.PREV)"
/>
</Suspense>
<Suspense>
<LazyDlgBulkUpdate
v-if="bulkUpdateDlg"
v-model="bulkUpdateDlg"
:meta="meta"
:view="view"
:bulk-update-rows="bulkUpdateRows"
:bulk-update-view="bulkUpdateView"
:selected-all-records="selectedAllRecords"
:rows="data.filter((r) => r.rowMeta.selected)"
/>
</Suspense>
</div>
</template>

18
packages/nc-gui/composables/useViewData.ts

@ -492,6 +492,8 @@ export function useViewData(
updateArray.push({ ...updateData, ...pk })
}
await $api.dbTableRow.bulkUpdate(NOCO, metaValue?.project_id as string, metaValue?.id as string, updateArray)
if (!undo) {
addUndo({
redo: {
@ -548,8 +550,6 @@ export function useViewData(
})
}
await $api.dbTableRow.bulkUpdate(NOCO, metaValue?.project_id as string, metaValue?.id as string, updateArray)
for (const row of rows) {
if (!undo) {
/** update row data(to sync formula and other related columns)
@ -577,6 +577,19 @@ export function useViewData(
}
}
async function bulkUpdateView(
data: Record<string, any>[],
{ metaValue = meta.value, viewMetaValue = viewMeta.value }: { metaValue?: TableType; viewMetaValue?: ViewType } = {},
) {
if (!viewMetaValue) return
await $api.dbTableRow.bulkUpdateAll(NOCO, metaValue?.project_id as string, metaValue?.id as string, data, {
viewId: viewMetaValue.id,
})
await loadData()
}
async function changePage(page: number) {
paginationData.value.page = page
await loadData({
@ -995,6 +1008,7 @@ export function useViewData(
deleteRangeOfRows,
updateOrSaveRow,
bulkUpdateRows,
bulkUpdateView,
selectedAllRecords,
syncCount,
syncPagination,

2
packages/nocodb-sdk/src/lib/Api.ts

@ -7249,6 +7249,7 @@ export class Api<
data: object,
query?: {
where?: string;
viewId?: string;
},
params: RequestParams = {}
) =>
@ -7289,6 +7290,7 @@ export class Api<
data: object,
query?: {
where?: string;
viewId?: string;
},
params: RequestParams = {}
) =>

36
packages/nocodb/src/db/BaseModelSqlv2.ts

@ -2493,7 +2493,7 @@ class BaseModelSqlv2 {
}
async bulkUpdateAll(
args: { where?: string; filterArr?: Filter[] } = {},
args: { where?: string; filterArr?: Filter[]; viewId?: string } = {},
data,
{ cookie }: { cookie?: any } = {},
) {
@ -2515,22 +2515,30 @@ class BaseModelSqlv2 {
const aliasColObjMap = await this.model.getAliasColObjMap();
const filterObj = extractFilterFromXwhere(where, aliasColObjMap);
await conditionV2(
[
new Filter({
children: args.filterArr || [],
is_group: true,
logical_op: 'and',
}),
const conditionObj = [
new Filter({
children: args.filterArr || [],
is_group: true,
logical_op: 'and',
}),
new Filter({
children: filterObj,
is_group: true,
logical_op: 'and',
}),
];
if (args.viewId) {
conditionObj.push(
new Filter({
children: filterObj,
children:
(await Filter.rootFilterList({ viewId: args.viewId })) || [],
is_group: true,
logical_op: 'and',
}),
],
qb,
this.dbDriver,
);
);
}
await conditionV2(conditionObj, qb, this.dbDriver);
qb.update(updateData);

7
packages/nocodb/src/schema/swagger.json

@ -10059,6 +10059,13 @@
},
"in": "query",
"name": "where"
},
{
"schema": {
"type": "string"
},
"in": "query",
"name": "viewId"
}
],
"patch": {

192
tests/playwright/pages/Dashboard/BulkUpdate/index.ts

@ -0,0 +1,192 @@
import { expect, Locator } from '@playwright/test';
import BasePage from '../../Base';
import { DashboardPage } from '..';
import { DateTimeCellPageObject } from '../common/Cell/DateTimeCell';
import { getTextExcludeIconText } from '../../../tests/utils/general';
export class BulkUpdatePage extends BasePage {
readonly dashboard: DashboardPage;
readonly bulkUpdateButton: Locator;
readonly formHeader: Locator;
readonly columnsDrawer: Locator;
readonly form: Locator;
constructor(dashboard: DashboardPage) {
super(dashboard.rootPage);
this.dashboard = dashboard;
this.bulkUpdateButton = this.dashboard.get().locator('.nc-bulk-update-save-btn');
this.formHeader = this.dashboard.get().locator('.nc-bulk-update-bulk-update-header');
this.columnsDrawer = this.dashboard.get().locator('.nc-columns-drawer');
this.form = this.dashboard.get().locator('div.form');
}
get() {
return this.dashboard.get().locator(`.nc-drawer-bulk-update`);
}
async close() {
return this.dashboard.rootPage.keyboard.press('Escape');
}
async getInactiveColumn(index: number) {
const inactiveColumns = await this.columnsDrawer.locator('.ant-card');
return inactiveColumns.nth(index);
}
async getActiveColumn(index: number) {
const activeColumns = await this.form.locator('[data-testid="nc-bulk-update-fields"]');
return activeColumns.nth(index);
}
async getInactiveColumns() {
const inactiveColumns = await this.columnsDrawer.locator('.ant-card');
const inactiveColumnsCount = await inactiveColumns.count();
const inactiveColumnsTitles = [];
// get title for each inactive column
for (let i = 0; i < inactiveColumnsCount; i++) {
const title = await getTextExcludeIconText(inactiveColumns.nth(i).locator('.ant-card-body'));
inactiveColumnsTitles.push(title);
}
return inactiveColumnsTitles;
}
async getActiveColumns() {
const activeColumns = await this.form.locator('[data-testid="nc-bulk-update-fields"]');
const activeColumnsCount = await activeColumns.count();
const activeColumnsTitles = [];
// get title for each active column
for (let i = 0; i < activeColumnsCount; i++) {
const title = await getTextExcludeIconText(activeColumns.nth(i).locator('[data-testid="nc-bulk-update-input-label"]'));
activeColumnsTitles.push(title);
}
return activeColumnsTitles;
}
async removeField(index: number) {
const removeFieldButton = await this.form.locator('[data-testid="nc-bulk-update-fields"]');
const removeFieldButtonCount = await removeFieldButton.count();
await removeFieldButton.nth(index).locator('[data-testid="nc-bulk-update-fields-remove-icon"]').click();
const newRemoveFieldButtonCount = await removeFieldButton.count();
expect(newRemoveFieldButtonCount).toBe(removeFieldButtonCount - 1);
}
async addField(index: number) {
const addFieldButton = await this.columnsDrawer.locator('.ant-card');
const addFieldButtonCount = await addFieldButton.count();
await addFieldButton.nth(index).click();
const newAddFieldButtonCount = await addFieldButton.count();
expect(newAddFieldButtonCount).toBe(addFieldButtonCount - 1);
}
//////////////////////////////////////////////////////////////////////////////
async fillField({ columnTitle, value, type = 'text' }: { columnTitle: string; value: string; type?: string }) {
let picker = null;
const field = this.form.locator(`[data-testid="nc-bulk-update-input-${columnTitle}"]`);
await field.scrollIntoViewIfNeeded();
await field.hover();
if (type !== 'checkbox' && type !== 'attachment') {
await field.click();
}
switch (type) {
case 'text':
await field.locator('input').waitFor();
await field.locator('input').fill(value);
break;
case 'longText':
await field.locator('textarea').waitFor();
await field.locator('textarea').fill(value);
break;
case 'rating':
await field
.locator('.ant-rate-star')
.nth(Number(value) - 1)
.click();
break;
case 'year':
picker = this.rootPage.locator('.ant-picker-dropdown.active');
await picker.waitFor();
await picker.locator(`td[title="${value}"]`).click();
break;
case 'time':
picker = this.rootPage.locator('.ant-picker-dropdown.active');
await picker.waitFor();
// eslint-disable-next-line no-case-declarations
const time = value.split(':');
// eslint-disable-next-line no-case-declarations
const timePanel = picker.locator('.ant-picker-time-panel-column');
await timePanel.nth(0).locator('li').nth(+time[0]).click();
await timePanel.nth(1).locator('li').nth(+time[1]).click();
await picker.locator('.ant-picker-ok').click();
break;
case 'singleSelect':
picker = this.rootPage.locator('.ant-select-dropdown.active');
await picker.waitFor();
await picker.locator(`.nc-select-option-SingleSelect-${value}`).click();
break;
case 'multiSelect':
picker = this.rootPage.locator('.ant-select-dropdown.active');
await picker.waitFor();
for (const val of value.split(',')) {
await picker.locator(`.nc-select-option-MultiSelect-${val}`).click();
}
break;
case 'checkbox':
if (value === 'true') {
await field.click();
}
break;
case 'attachment':
// eslint-disable-next-line no-case-declarations
const attachFileAction = field.locator('[data-testid="attachment-cell-file-picker-button"]').click();
await this.attachFile({ filePickUIAction: attachFileAction, filePath: value });
break;
case 'date':
{
const values = value.split('-');
const { year, month, day } = { year: values[0], month: values[1], day: values[2] };
picker = this.rootPage.locator('.ant-picker-dropdown.active');
const monthBtn = picker.locator('.ant-picker-month-btn');
const yearBtn = picker.locator('.ant-picker-year-btn');
await yearBtn.click();
await picker.waitFor();
await picker.locator(`td[title="${year}"]`).click();
await monthBtn.click();
await picker.waitFor();
await picker.locator(`td[title="${year}-${month}"]`).click();
await picker.waitFor();
await picker.locator(`td[title="${year}-${month}-${day}"]`).click();
}
break;
}
}
async save({
awaitResponse = true,
}: {
awaitResponse?: boolean;
} = {}) {
await this.bulkUpdateButton.click();
const confirmModal = await this.rootPage.locator('.ant-modal-confirm');
const saveRowAction = () => confirmModal.locator('.ant-btn-primary').click();
if (!awaitResponse) {
await saveRowAction();
} else {
await this.waitForResponse({
uiAction: saveRowAction,
requestUrlPathToMatch: 'api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
responseJsonMatcher: json => json['pageInfo'],
});
}
await this.get().waitFor({ state: 'hidden' });
await this.rootPage.locator('[data-testid="grid-load-spinner"]').waitFor({ state: 'hidden' });
}
}

13
tests/playwright/pages/Dashboard/Grid/index.ts

@ -220,6 +220,19 @@ export class GridPage extends BasePage {
await this.deleteSelectedRows();
}
async updateSelectedRows() {
await this.get().locator('[data-testid="nc-check-all"]').nth(0).click({
button: 'right',
});
await this.rootPage.locator('text=Update Selected Rows').click();
await this.dashboard.waitForLoaderToDisappear();
}
async updateAll() {
await this.selectAll();
await this.updateSelectedRows();
}
async verifyTotalRowCount({ count }: { count: number }) {
// wait for 100 ms and try again : 5 times
let i = 0;

2
tests/playwright/pages/Dashboard/common/Cell/SelectOptionCell.ts

@ -126,7 +126,7 @@ export class SelectOptionCellPageObject extends BasePage {
await expect(this.rootPage.locator(`div.ant-select-item-option`).nth(counter)).toHaveText(option);
counter++;
}
await this.get({ index, columnHeader }).click();
await this.rootPage.keyboard.press('Escape');
await this.rootPage.locator(`.nc-dropdown-single-select-cell`).nth(index).waitFor({ state: 'hidden' });
}

23
tests/playwright/pages/Dashboard/common/Cell/TimeCell.ts

@ -0,0 +1,23 @@
import { CellPageObject } from '.';
import BasePage from '../../../Base';
import { expect } from '@playwright/test';
export class TimeCellPageObject extends BasePage {
readonly cell: CellPageObject;
constructor(cell: CellPageObject) {
super(cell.rootPage);
this.cell = cell;
}
get({ index, columnHeader }: { index?: number; columnHeader: string }) {
return this.cell.get({ index, columnHeader });
}
async verify({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) {
const cell = await this.get({ index, columnHeader });
await cell.scrollIntoViewIfNeeded();
await cell.locator(`input[title="${value}"]`).waitFor({ state: 'visible' });
await expect(cell.locator(`[title="${value}"]`)).toBeVisible();
}
}

23
tests/playwright/pages/Dashboard/common/Cell/YearCell.ts

@ -0,0 +1,23 @@
import { CellPageObject } from '.';
import BasePage from '../../../Base';
import { expect } from '@playwright/test';
export class YearCellPageObject extends BasePage {
readonly cell: CellPageObject;
constructor(cell: CellPageObject) {
super(cell.rootPage);
this.cell = cell;
}
get({ index, columnHeader }: { index?: number; columnHeader: string }) {
return this.cell.get({ index, columnHeader });
}
async verify({ index, columnHeader, value }: { index: number; columnHeader: string; value: number }) {
const cell = await this.get({ index, columnHeader });
await cell.scrollIntoViewIfNeeded();
await cell.locator(`input[title="${value}"]`).waitFor({ state: 'visible' });
await expect(cell.locator(`[title="${value}"]`)).toBeVisible();
}
}

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

@ -9,6 +9,8 @@ import { RatingCellPageObject } from './RatingCell';
import { DateCellPageObject } from './DateCell';
import { DateTimeCellPageObject } from './DateTimeCell';
import { GeoDataCellPageObject } from './GeoDataCell';
import { YearCellPageObject } from './YearCell';
import { TimeCellPageObject } from './TimeCell';
export interface CellProps {
index?: number;
@ -21,6 +23,8 @@ export class CellPageObject extends BasePage {
readonly attachment: AttachmentCellPageObject;
readonly checkbox: CheckboxCellPageObject;
readonly rating: RatingCellPageObject;
readonly year: YearCellPageObject;
readonly time: TimeCellPageObject;
readonly geoData: GeoDataCellPageObject;
readonly date: DateCellPageObject;
readonly dateTime: DateTimeCellPageObject;
@ -32,6 +36,8 @@ export class CellPageObject extends BasePage {
this.attachment = new AttachmentCellPageObject(this);
this.checkbox = new CheckboxCellPageObject(this);
this.rating = new RatingCellPageObject(this);
this.year = new YearCellPageObject(this);
this.time = new TimeCellPageObject(this);
this.geoData = new GeoDataCellPageObject(this);
this.date = new DateCellPageObject(this);
this.dateTime = new DateTimeCellPageObject(this);

3
tests/playwright/pages/Dashboard/index.ts

@ -3,6 +3,7 @@ import BasePage from '../Base';
import { GridPage } from './Grid';
import { FormPage } from './Form';
import { ExpandedFormPage } from './ExpandedForm';
import { BulkUpdatePage } from './BulkUpdate';
import { ChildList } from './Grid/Column/LTAR/ChildList';
import { LinkRecord } from './Grid/Column/LTAR/LinkRecord';
import { TreeViewPage } from './TreeView';
@ -29,6 +30,7 @@ export class DashboardPage extends BasePage {
readonly kanban: KanbanPage;
readonly map: MapPage;
readonly expandedForm: ExpandedFormPage;
readonly bulkUpdateForm: BulkUpdatePage;
readonly webhookForm: WebhookFormPage;
readonly findRowByScanOverlay: FindRowByScanOverlay;
readonly childList: ChildList;
@ -51,6 +53,7 @@ export class DashboardPage extends BasePage {
this.kanban = new KanbanPage(this);
this.map = new MapPage(this);
this.expandedForm = new ExpandedFormPage(this);
this.bulkUpdateForm = new BulkUpdatePage(this);
this.webhookForm = new WebhookFormPage(this);
this.findRowByScanOverlay = new FindRowByScanOverlay(this);
this.childList = new ChildList(this);

30
tests/playwright/setup/demoTable.ts

@ -116,6 +116,24 @@ const columns = {
uidt: UITypes.Time,
},
],
miscellaneous: [
{
column_name: 'Id',
title: 'Id',
uidt: UITypes.ID,
},
{
column_name: 'Checkbox',
title: 'Checkbox',
uidt: UITypes.Checkbox,
},
{
column_name: 'Attachment',
title: 'Attachment',
uidt: UITypes.Attachment,
},
],
};
async function createDemoTable({
@ -198,6 +216,18 @@ async function createDemoTable({
console.error(e);
}
break;
case 'miscellaneous':
try {
for (let i = 0; i < recordCnt; i++) {
const row = {
Checkbox: rowMixedValue(columns.miscellaneous[1], i),
};
rowAttributes.push(row);
}
} catch (e) {
console.error(e);
}
break;
}
await api.dbTableRow.bulkCreate('noco', context.project.id, table.id, rowAttributes);

365
tests/playwright/tests/db/bulkUpdate.spec.ts

@ -0,0 +1,365 @@
import { expect, test } from '@playwright/test';
import setup from '../../setup';
import { DashboardPage } from '../../pages/Dashboard';
import { Api } from 'nocodb-sdk';
import { createDemoTable } from '../../setup/demoTable';
import { BulkUpdatePage } from '../../pages/Dashboard/BulkUpdate';
let bulkUpdateForm: BulkUpdatePage;
async function updateBulkFields(fields) {
// move all fields to active
for (let i = 0; i < fields.length; i++) {
await bulkUpdateForm.addField(0);
}
// fill all fields
for (let i = 0; i < fields.length; i++) {
await bulkUpdateForm.fillField({ columnTitle: fields[i].title, value: fields[i].value, type: fields[i].type });
}
// save form
await bulkUpdateForm.save({ awaitResponse: true });
}
test.describe('Bulk update', () => {
let dashboard: DashboardPage;
let context: any;
let api: Api<any>;
let table;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
bulkUpdateForm = dashboard.bulkUpdateForm;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
table = await createDemoTable({ context, type: 'textBased', recordCnt: 50 });
await page.reload();
await dashboard.treeView.openTable({ title: 'textBased' });
// Open bulk update form
await dashboard.grid.updateAll();
});
test('General- Click to add & remove', async () => {
let inactiveColumns = await bulkUpdateForm.getInactiveColumns();
expect(inactiveColumns).toEqual(['SingleLineText', 'MultiLineText', 'Email', 'PhoneNumber', 'URL']);
let activeColumns = await bulkUpdateForm.getActiveColumns();
expect(activeColumns).toEqual([]);
await bulkUpdateForm.addField(0);
await bulkUpdateForm.addField(0);
inactiveColumns = await bulkUpdateForm.getInactiveColumns();
expect(inactiveColumns).toEqual(['Email', 'PhoneNumber', 'URL']);
activeColumns = await bulkUpdateForm.getActiveColumns();
expect(activeColumns).toEqual(['SingleLineText', 'MultiLineText']);
});
test('General- Drag drop', async () => {
const src = await bulkUpdateForm.getInactiveColumn(0);
const dst = await bulkUpdateForm.form;
await src.dragTo(dst);
expect(await bulkUpdateForm.getActiveColumns()).toEqual(['SingleLineText']);
expect(await bulkUpdateForm.getInactiveColumns()).toEqual(['MultiLineText', 'Email', 'PhoneNumber', 'URL']);
const src2 = await bulkUpdateForm.getActiveColumn(0);
const dst2 = await bulkUpdateForm.columnsDrawer;
await src2.dragTo(dst2);
expect(await bulkUpdateForm.getActiveColumns()).toEqual([]);
expect(await bulkUpdateForm.getInactiveColumns()).toEqual([
'SingleLineText',
'MultiLineText',
'Email',
'PhoneNumber',
'URL',
]);
});
test('Text based', async () => {
const fields = [
{ title: 'SingleLineText', value: 'SingleLineText', type: 'text' },
{ title: 'Email', value: 'a@b.com', type: 'text' },
{ title: 'PhoneNumber', value: '987654321', type: 'text' },
{ title: 'URL', value: 'https://www.google.com', type: 'text' },
{
title: 'MultiLineText',
value: 'Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. Long text. ',
type: 'longText',
},
];
await updateBulkFields(fields);
// verify data on grid
for (let i = 0; i < fields.length; i++) {
await dashboard.grid.cell.verify({ index: 5, columnHeader: fields[i].title, value: fields[i].value });
}
// verify api response
const updatedRecords = (await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 50 })).list;
for (let i = 0; i < updatedRecords.length; i++) {
for (let j = 0; j < fields.length; j++) {
expect(updatedRecords[i][fields[j].title]).toEqual(fields[j].value);
}
}
});
});
test.describe('Bulk update', () => {
let dashboard: DashboardPage;
let context: any;
let api: Api<any>;
let table;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
bulkUpdateForm = dashboard.bulkUpdateForm;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
table = await createDemoTable({ context, type: 'numberBased', recordCnt: 50 });
await page.reload();
await dashboard.treeView.openTable({ title: 'numberBased' });
// Open bulk update form
await dashboard.grid.updateAll();
});
test('Number based', async () => {
const fields = [
{ title: 'Number', value: '1', type: 'text' },
{ title: 'Decimal', value: '1.1', type: 'text' },
{ title: 'Currency', value: '1.1', type: 'text' },
{ title: 'Percent', value: '10', type: 'text' },
{ title: 'Duration', value: '16:40', type: 'text' },
{ title: 'Rating', value: '3', type: 'rating' },
{ title: 'Year', value: '2024', type: 'year' },
{ title: 'Time', value: '10:10', type: 'time' },
];
await updateBulkFields(fields);
// verify data on grid
for (let i = 0; i < fields.length; i++) {
if (fields[i].type === 'rating') {
await dashboard.grid.cell.rating.verify({ index: 5, columnHeader: fields[i].title, rating: +fields[i].value });
} else if (fields[i].type === 'year') {
await dashboard.grid.cell.year.verify({ index: 5, columnHeader: fields[i].title, value: +fields[i].value });
} else if (fields[i].type === 'time') {
await dashboard.grid.cell.time.verify({ index: 5, columnHeader: fields[i].title, value: fields[i].value });
} else {
await dashboard.grid.cell.verify({ index: 5, columnHeader: fields[i].title, value: fields[i].value });
}
}
// verify api response
// duration in seconds
const APIResponse = [1, 1.1, 1.1, 10, 60000, 3, 2024, '10:10:00'];
const updatedRecords = (await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 50 })).list;
for (let i = 0; i < updatedRecords.length; i++) {
for (let j = 0; j < fields.length; j++) {
if (fields[j].title === 'Time') {
expect(updatedRecords[i][fields[j].title]).toContain(APIResponse[j]);
} else {
expect(+updatedRecords[i][fields[j].title]).toEqual(APIResponse[j]);
}
}
}
});
});
test.describe('Bulk update', () => {
let dashboard: DashboardPage;
let context: any;
let api: Api<any>;
let table;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
bulkUpdateForm = dashboard.bulkUpdateForm;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
table = await createDemoTable({ context, type: 'selectBased', recordCnt: 50 });
await page.reload();
await dashboard.treeView.openTable({ title: 'selectBased' });
// Open bulk update form
await dashboard.grid.updateAll();
});
test('Select based', async () => {
const fields = [
{ title: 'SingleSelect', value: 'jan', type: 'singleSelect' },
{ title: 'MultiSelect', value: 'jan,feb,mar', type: 'multiSelect' },
];
await updateBulkFields(fields);
// verify data on grid
const displayOptions = ['jan', 'feb', 'mar'];
for (let i = 0; i < fields.length; i++) {
if (fields[i].type === 'singleSelect') {
await dashboard.grid.cell.selectOption.verify({
index: 5,
columnHeader: fields[i].title,
option: fields[i].value,
});
} else {
await dashboard.grid.cell.selectOption.verifyOptions({
index: 5,
columnHeader: fields[i].title,
options: displayOptions,
});
}
}
// verify api response
const updatedRecords = (await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 50 })).list;
for (let i = 0; i < updatedRecords.length; i++) {
for (let j = 0; j < fields.length; j++) {
expect(updatedRecords[i][fields[j].title]).toContain(fields[j].value);
}
}
});
});
test.describe('Bulk update', () => {
let dashboard: DashboardPage;
let context: any;
let api: Api<any>;
let table;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
bulkUpdateForm = dashboard.bulkUpdateForm;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
table = await createDemoTable({ context, type: 'miscellaneous', recordCnt: 50 });
await page.reload();
await dashboard.treeView.openTable({ title: 'miscellaneous' });
// Open bulk update form
await dashboard.grid.updateAll();
});
test('Miscellaneous (Checkbox, attachment)', async () => {
const fields = [
{ title: 'Checkbox', value: 'true', type: 'checkbox' },
{ title: 'Attachment', value: `${process.cwd()}/fixtures/sampleFiles/1.json`, type: 'attachment' },
];
await updateBulkFields(fields);
// verify data on grid
for (let i = 0; i < fields.length; i++) {
if (fields[i].type === 'checkbox') {
await dashboard.grid.cell.checkbox.verifyChecked({
index: 5,
columnHeader: fields[i].title,
});
} else {
await dashboard.grid.cell.attachment.verifyFileCount({
index: 5,
columnHeader: fields[i].title,
count: 1,
});
}
}
// verify api response
const updatedRecords = (await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 50 })).list;
for (let i = 0; i < updatedRecords.length; i++) {
for (let j = 0; j < fields.length; j++) {
expect(+updatedRecords[i]['Checkbox']).toBe(1);
expect(updatedRecords[i]['Attachment'][0].title).toBe('1.json');
expect(updatedRecords[i]['Attachment'][0].mimetype).toBe('application/json');
}
}
});
});
test.describe('Bulk update', () => {
let dashboard: DashboardPage;
let context: any;
let api: Api<any>;
let table;
test.beforeEach(async ({ page }) => {
context = await setup({ page, isEmptyProject: true });
dashboard = new DashboardPage(page, context.project);
bulkUpdateForm = dashboard.bulkUpdateForm;
api = new Api({
baseURL: `http://localhost:8080/`,
headers: {
'xc-auth': context.token,
},
});
table = await createDemoTable({ context, type: 'dateTimeBased', recordCnt: 50 });
await page.reload();
await dashboard.treeView.openTable({ title: 'dateTimeBased' });
// Open bulk update form
await dashboard.grid.updateAll();
});
test('Date Time Based', async () => {
const fields = [{ title: 'Date', value: '2024-08-04', type: 'date' }];
await updateBulkFields(fields);
// verify data on grid
for (let i = 0; i < fields.length; i++) {
await dashboard.grid.cell.date.verify({
index: 5,
columnHeader: fields[i].title,
date: fields[i].value,
});
}
// verify api response
const updatedRecords = (await api.dbTableRow.list('noco', context.project.id, table.id, { limit: 50 })).list;
for (let i = 0; i < updatedRecords.length; i++) {
for (let j = 0; j < fields.length; j++) {
expect(updatedRecords[i]['Date']).toBe(fields[j].value);
}
}
});
});

4
tests/playwright/tests/db/projectOperations.spec.ts

@ -107,9 +107,9 @@ test.describe('Project operations', () => {
const testProjectId = await projectList.list.find((p: any) => p.title === testProjectName);
const dupeProjectId = await projectList.list.find((p: any) => p.title === dupeProjectName);
const projectInfoOp: ProjectInfoApiUtil = new ProjectInfoApiUtil(context.token);
const orginal: Promise<ProjectInfo> = projectInfoOp.extractProjectInfo(testProjectId.id);
const original: Promise<ProjectInfo> = projectInfoOp.extractProjectInfo(testProjectId.id);
const duplicate: Promise<ProjectInfo> = projectInfoOp.extractProjectInfo(dupeProjectId.id);
await Promise.all([orginal, duplicate]).then(arr => {
await Promise.all([original, duplicate]).then(arr => {
const ignoredFields: Set<string> = new Set([
'id',
'prefix',

Loading…
Cancel
Save