mirror of https://github.com/nocodb/nocodb
16 changed files with 1252 additions and 22 deletions
@ -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> |
@ -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' }); |
||||
} |
||||
} |
@ -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(); |
||||
} |
||||
} |
@ -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(); |
||||
} |
||||
} |
@ -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); |
||||
} |
||||
} |
||||
}); |
||||
}); |
Loading…
Reference in new issue