mirror of https://github.com/nocodb/nocodb
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
545 lines
18 KiB
545 lines
18 KiB
import { expect, Locator } from '@playwright/test'; |
|
import { DashboardPage } from '..'; |
|
import BasePage from '../../Base'; |
|
import { CellPageObject, CellProps } from '../common/Cell'; |
|
import { ColumnPageObject } from './Column'; |
|
import { TopbarPage } from '../common/Topbar'; |
|
import { ToolbarPage } from '../common/Toolbar'; |
|
import { FootbarPage } from '../common/Footbar'; |
|
import { ProjectMenuObject } from '../common/ProjectMenu'; |
|
import { QrCodeOverlay } from '../QrCodeOverlay'; |
|
import { BarcodeOverlay } from '../BarcodeOverlay'; |
|
import { RowPageObject } from './Row'; |
|
import { WorkspaceMenuObject } from '../common/WorkspaceMenu'; |
|
import { GroupPageObject } from './Group'; |
|
import { ColumnHeaderPageObject } from './columnHeader'; |
|
|
|
export class GridPage extends BasePage { |
|
readonly dashboard: DashboardPage; |
|
readonly addNewTableButton: Locator; |
|
readonly dashboardPage: DashboardPage; |
|
readonly qrCodeOverlay: QrCodeOverlay; |
|
readonly barcodeOverlay: BarcodeOverlay; |
|
readonly columnHeader: ColumnHeaderPageObject; |
|
readonly column: ColumnPageObject; |
|
readonly cell: CellPageObject; |
|
readonly topbar: TopbarPage; |
|
readonly toolbar: ToolbarPage; |
|
readonly footbar: FootbarPage; |
|
readonly baseMenu: ProjectMenuObject; |
|
readonly workspaceMenu: WorkspaceMenuObject; |
|
readonly rowPage: RowPageObject; |
|
readonly groupPage: GroupPageObject; |
|
|
|
readonly btn_addNewRow: Locator; |
|
|
|
constructor(dashboardPage: DashboardPage) { |
|
super(dashboardPage.rootPage); |
|
this.dashboard = dashboardPage; |
|
this.addNewTableButton = dashboardPage.get().locator('.nc-add-new-table'); |
|
this.qrCodeOverlay = new QrCodeOverlay(this); |
|
this.barcodeOverlay = new BarcodeOverlay(this); |
|
this.column = new ColumnPageObject(this); |
|
this.columnHeader = new ColumnHeaderPageObject(this); |
|
this.cell = new CellPageObject(this); |
|
this.topbar = new TopbarPage(this); |
|
this.toolbar = new ToolbarPage(this); |
|
this.footbar = new FootbarPage(this); |
|
this.baseMenu = new ProjectMenuObject(this); |
|
this.workspaceMenu = new WorkspaceMenuObject(this); |
|
this.rowPage = new RowPageObject(this); |
|
this.groupPage = new GroupPageObject(this); |
|
|
|
this.btn_addNewRow = this.get().locator('.nc-grid-add-new-cell'); |
|
} |
|
|
|
async verifyLockMode() { |
|
// add new row button |
|
expect(await this.btn_addNewRow.count()).toBe(1); |
|
|
|
await this.toolbar.verifyLockMode(); |
|
await this.footbar.verifyLockMode(); |
|
await this.columnHeader.verifyLockMode(); |
|
} |
|
|
|
async verifyCollaborativeMode() { |
|
// add new row button |
|
await expect(this.btn_addNewRow).toHaveCount(1); |
|
|
|
await this.toolbar.verifyCollaborativeMode(); |
|
await this.footbar.verifyCollaborativeMode(); |
|
await this.columnHeader.verifyCollaborativeMode(); |
|
} |
|
|
|
get() { |
|
return this.dashboard.get().locator('[data-testid="nc-grid-wrapper"]'); |
|
} |
|
|
|
row(index: number) { |
|
return this.get().locator(`tr[data-testid="grid-row-${index}"]`); |
|
} |
|
|
|
async renderColumn(columnHeader: string) { |
|
// we have virtual grid, so we need to make sure the column is rendered |
|
const headerRow = this.get().locator('.nc-grid-header').first(); |
|
let column = headerRow.locator(`[data-title="${columnHeader}"]`); |
|
let lastScrolledColumn: Locator = null; |
|
let direction = 'right'; |
|
|
|
while (headerRow) { |
|
try { |
|
await column.elementHandle({ timeout: 1000 }); |
|
break; |
|
} catch {} |
|
|
|
const lastColumn = |
|
direction === 'right' |
|
? headerRow.locator('th.nc-grid-column-header').last() |
|
: headerRow.locator('th.nc-grid-column-header').nth(1); |
|
|
|
if (lastScrolledColumn) { |
|
if ((await lastScrolledColumn.innerText()) === (await lastColumn.innerText())) { |
|
if (direction === 'right') { |
|
direction = 'left'; |
|
lastScrolledColumn = null; |
|
} else { |
|
throw new Error(`Column with header "${columnHeader}" not found`); |
|
} |
|
} |
|
} |
|
|
|
await lastColumn.scrollIntoViewIfNeeded(); |
|
|
|
lastScrolledColumn = lastColumn; |
|
|
|
column = headerRow.locator(`[data-title="${columnHeader}"]`); |
|
} |
|
} |
|
|
|
async rowCount() { |
|
return await this.get().locator('.nc-grid-row').count(); |
|
} |
|
|
|
async verifyRowCount({ count }: { count: number }) { |
|
return await expect(this.get().locator('.nc-grid-row')).toHaveCount(count); |
|
} |
|
|
|
private async _fillRow({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) { |
|
const cell = this.cell.get({ index, columnHeader }); |
|
await expect(cell).toBeVisible(); |
|
await this.rootPage.waitForTimeout(500); |
|
await this.cell.dblclick({ |
|
index, |
|
columnHeader, |
|
}); |
|
await this.rootPage.waitForTimeout(500); |
|
|
|
await cell.locator('input').fill(value); |
|
} |
|
|
|
async addNewRow({ |
|
index = 0, |
|
columnHeader = 'Title', |
|
value, |
|
networkValidation = true, |
|
}: { |
|
index?: number; |
|
columnHeader?: string; |
|
value?: string; |
|
networkValidation?: boolean; |
|
} = {}) { |
|
const rowValue = value ?? `Row ${index}`; |
|
// wait for render to complete before count |
|
if (index !== 0) await this.get().locator('.nc-grid-row').nth(0).waitFor({ state: 'attached' }); |
|
|
|
await (await this.get().locator('.nc-grid-add-new-cell').elementHandle())?.waitForElementState('stable'); |
|
|
|
await this.rootPage.waitForTimeout(200); |
|
await this.rootPage.waitForLoadState('networkidle'); |
|
await this.rootPage.waitForTimeout(200); |
|
await this.rootPage.waitForLoadState('domcontentloaded'); |
|
|
|
await this.get().locator('.nc-grid-add-new-cell').click(); |
|
|
|
const rowCount = index + 1; |
|
|
|
const isRowSaving = this.rootPage.getByTestId(`row-save-spinner-${rowCount}`); |
|
// if required field is present then isRowSaving will be hidden (not present in DOM) |
|
await isRowSaving?.waitFor({ state: 'hidden' }); |
|
|
|
// fallback |
|
await this.rootPage.waitForTimeout(400); |
|
|
|
await expect(this.get().locator(`[data-testid="grid-row-${rowCount - 1}"]`)).toHaveCount(1); |
|
|
|
await this.rootPage.waitForLoadState('networkidle'); |
|
|
|
await this._fillRow({ index, columnHeader, value: rowValue }); |
|
|
|
const clickOnColumnHeaderToSave = () => |
|
this.get().locator(`[data-title="${columnHeader}"]`).locator(`span[data-test-id="${columnHeader}"]`).click(); |
|
|
|
if (networkValidation) { |
|
await this.waitForResponse({ |
|
uiAction: clickOnColumnHeaderToSave, |
|
requestUrlPathToMatch: 'api/v1/db/data/noco', |
|
httpMethodsToMatch: [ |
|
// if the row does not contain the required cell, editing the row cell will emit a PATCH request; otherwise, it will emit a POST request. |
|
'PATCH', |
|
'POST', |
|
], |
|
// numerical types are returned in number format from the server |
|
responseJsonMatcher: resJson => String(resJson?.[columnHeader]) === String(value), |
|
}); |
|
} else { |
|
await clickOnColumnHeaderToSave(); |
|
await this.rootPage.waitForTimeout(300); |
|
} |
|
|
|
await this.rootPage.keyboard.press('Escape'); |
|
await this.rootPage.waitForTimeout(300); |
|
|
|
await this.dashboard.waitForLoaderToDisappear(); |
|
} |
|
|
|
async editRow({ |
|
index = 0, |
|
columnHeader = 'Title', |
|
value, |
|
networkValidation = true, |
|
}: { |
|
index?: number; |
|
columnHeader?: string; |
|
value: string; |
|
networkValidation?: boolean; |
|
}) { |
|
await this._fillRow({ index, columnHeader, value }); |
|
|
|
const clickOnColumnHeaderToSave = () => |
|
this.get().locator(`[data-title="${columnHeader}"]`).locator(`span[data-test-id="${columnHeader}"]`).click(); |
|
|
|
if (networkValidation) { |
|
await this.waitForResponse({ |
|
uiAction: clickOnColumnHeaderToSave, |
|
requestUrlPathToMatch: 'api/v1/db/data/noco', |
|
httpMethodsToMatch: [ |
|
'PATCH', |
|
// since edit row on an empty row will emit POST request |
|
'POST', |
|
], |
|
// numerical types are returned in number format from the server |
|
responseJsonMatcher: resJson => String(resJson?.[columnHeader]) === String(value), |
|
}); |
|
} else { |
|
await clickOnColumnHeaderToSave(); |
|
await this.rootPage.waitForTimeout(300); |
|
} |
|
|
|
await this.rootPage.keyboard.press('Escape'); |
|
await this.rootPage.waitForTimeout(300); |
|
|
|
await this.dashboard.waitForLoaderToDisappear(); |
|
} |
|
|
|
async verifyRow({ index }: { index: number }) { |
|
await this.get().locator(`td[data-testid="cell-Title-${index}"]`).waitFor({ state: 'visible' }); |
|
await expect(this.get().locator(`td[data-testid="cell-Title-${index}"]`)).toHaveCount(1); |
|
} |
|
|
|
async verifyRowDoesNotExist({ index }: { index: number }) { |
|
await this.get().locator(`td[data-testid="cell-Title-${index}"]`).waitFor({ state: 'hidden' }); |
|
return await expect(this.get().locator(`td[data-testid="cell-Title-${index}"]`)).toHaveCount(0); |
|
} |
|
|
|
async clickRow(index: number, title = 'Title') { |
|
await this.get().getByTestId(`cell-${title}-${index}`).click(); |
|
await this.rootPage.waitForTimeout(300); |
|
} |
|
|
|
async deleteRow(index: number, title = 'Title') { |
|
await this.get().getByTestId(`cell-${title}-${index}`).click({ |
|
button: 'right', |
|
}); |
|
|
|
// Click text=Delete Row |
|
await this.rootPage.locator('.ant-dropdown-menu-item:has-text("Delete record")').click(); |
|
|
|
// todo: improve selector |
|
await this.rootPage |
|
.locator('span.ant-dropdown-menu-title-content > nc-base-menu-item') |
|
.waitFor({ state: 'hidden' }); |
|
|
|
await this.rootPage.waitForTimeout(300); |
|
await this.dashboard.waitForLoaderToDisappear(); |
|
} |
|
|
|
async addRowRightClickMenu(index: number, columnHeader = 'Title') { |
|
const rowCount = await this.get().locator('.nc-grid-row').count(); |
|
|
|
const cell = this.get().locator(`td[data-testid="cell-${columnHeader}-${index}"]`).last(); |
|
await cell.click(); |
|
await cell.click({ button: 'right' }); |
|
|
|
// Click text=Insert New Row |
|
await this.rootPage.locator('.insert-row').click(); |
|
await expect(this.get().locator('.nc-grid-row')).toHaveCount(rowCount + 1); |
|
} |
|
|
|
async openExpandedRow({ index }: { index: number }) { |
|
await this.row(index).locator(`td[data-testid="cell-Id-${index}"]`).hover(); |
|
await this.row(index).locator(`div[data-testid="nc-expand-${index}"]`).click(); |
|
} |
|
|
|
async selectRow(index: number) { |
|
const cell: Locator = this.get().locator(`td[data-testid="cell-Id-${index}"]`); |
|
await cell.hover(); |
|
await cell.locator('input[type="checkbox"]').check({ force: true }); |
|
} |
|
|
|
async selectAll() { |
|
await this.get().locator('[data-testid="nc-check-all"]').hover(); |
|
|
|
await this.get().locator('[data-testid="nc-check-all"]').locator('input[type="checkbox"]').check({ |
|
force: true, |
|
}); |
|
|
|
const rowCount = await this.rowCount(); |
|
for (let i = 0; i < rowCount; i++) { |
|
await expect( |
|
this.row(i).locator(`[data-testid="cell-Id-${i}"]`).locator('span.ant-checkbox-checked') |
|
).toHaveCount(1); |
|
} |
|
await this.rootPage.waitForTimeout(300); |
|
} |
|
|
|
async openAllRowContextMenu() { |
|
await this.get().locator('[data-testid="nc-check-all"]').nth(0).click({ |
|
button: 'right', |
|
}); |
|
} |
|
|
|
async deleteSelectedRows() { |
|
await this.get().locator('[data-testid="nc-check-all"]').nth(0).click({ |
|
button: 'right', |
|
}); |
|
await this.rootPage.locator('[data-testid="nc-delete-row"]').click(); |
|
await this.dashboard.waitForLoaderToDisappear(); |
|
} |
|
|
|
async deleteAll() { |
|
await this.selectAll(); |
|
await this.deleteSelectedRows(); |
|
} |
|
|
|
async updateSelectedRows() { |
|
await this.get().locator('[data-testid="nc-check-all"]').nth(0).click({ |
|
button: 'right', |
|
}); |
|
await this.rootPage.locator('.nc-menu-item:has-text("Update Selected Records")').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; |
|
await this.get().locator(`.nc-pagination-skeleton`).waitFor({ state: 'hidden' }); |
|
let records = await this.get().locator(`[data-testid="grid-pagination"]`).allInnerTexts(); |
|
let recordCnt = records[0].split(' ')[0]; |
|
|
|
while (parseInt(recordCnt) !== count && i < 5) { |
|
await this.get().locator(`.nc-pagination-skeleton`).waitFor({ state: 'hidden' }); |
|
records = await this.get().locator(`[data-testid="grid-pagination"]`).allInnerTexts(); |
|
recordCnt = records[0].split(' ')[0]; |
|
|
|
// to ensure page loading is complete |
|
i++; |
|
await this.rootPage.waitForTimeout(100 * i); |
|
} |
|
expect(parseInt(recordCnt)).toEqual(count); |
|
} |
|
|
|
async verifyPaginationCount({ count }: { count: string }) { |
|
if (await this.get().locator('.nc-pagination').isHidden()) { |
|
expect(1).toBe(+count); |
|
return; |
|
} |
|
|
|
await expect(this.get().locator(`.nc-pagination .total`)).toHaveText(count); |
|
} |
|
|
|
async clickPagination({ |
|
type, |
|
skipWait = false, |
|
}: { |
|
type: 'first-page' | 'last-page' | 'next-page' | 'prev-page'; |
|
skipWait?: boolean; |
|
}) { |
|
if (await this.get().locator('.nc-pagination').isHidden()) return; |
|
|
|
if (!skipWait) { |
|
await this.get().locator(`.nc-pagination .${type}`).click(); |
|
await this.waitLoading(); |
|
} else { |
|
await this.waitForResponse({ |
|
uiAction: async () => (await this.get().locator(`.nc-pagination .${type}`)).click(), |
|
httpMethodsToMatch: ['GET'], |
|
requestUrlPathToMatch: '/views/', |
|
responseJsonMatcher: resJson => resJson?.pageInfo, |
|
}); |
|
|
|
await this.waitLoading(); |
|
} |
|
} |
|
|
|
async verifyActivePage({ pageNumber }: { pageNumber: string }) { |
|
if (await this.get().locator('.nc-pagination').isHidden()) { |
|
expect(1).toBe(+pageNumber); |
|
return; |
|
} |
|
|
|
await expect(this.get().locator(`.nc-pagination .ant-select-selection-item`).first()).toHaveText(pageNumber); |
|
} |
|
|
|
async waitLoading() { |
|
await this.dashboard.get().locator('[data-testid="grid-load-spinner"]').waitFor({ state: 'hidden' }); |
|
} |
|
|
|
async verifyEditDisabled({ columnHeader = 'Title' }: { columnHeader?: string } = {}) { |
|
// double click to toggle to edit mode |
|
const cell = this.cell.get({ index: 0, columnHeader: columnHeader }); |
|
await this.cell.dblclick({ |
|
index: 0, |
|
columnHeader: columnHeader, |
|
}); |
|
await expect(cell.locator('input')).not.toBeVisible(); |
|
|
|
// right click menu |
|
await this.get().locator(`td[data-testid="cell-${columnHeader}-0"]`).click({ |
|
button: 'right', |
|
}); |
|
// await expect(this.rootPage.locator('text=Insert New Row')).not.toBeVisible(); |
|
|
|
// in cell-add |
|
await this.cell.get({ index: 0, columnHeader: 'Cities' }).hover(); |
|
await expect( |
|
this.cell.get({ index: 0, columnHeader: 'Cities' }).locator('.nc-action-icon.nc-plus') |
|
).not.toBeVisible(); |
|
|
|
// expand row |
|
await this.cell.get({ index: 0, columnHeader: 'Cities' }).hover(); |
|
await expect( |
|
this.cell.get({ index: 0, columnHeader: 'Cities' }).locator('.nc-action-icon >> nth=0') |
|
).not.toBeVisible(); |
|
} |
|
|
|
async verifyEditEnabled({ columnHeader = 'Title' }: { columnHeader?: string } = {}) { |
|
// double click to toggle to edit mode |
|
const cell = this.cell.get({ index: 0, columnHeader: columnHeader }); |
|
await this.cell.dblclick({ |
|
index: 0, |
|
columnHeader: columnHeader, |
|
}); |
|
await expect(cell.locator('input')).toBeVisible(); |
|
|
|
// press escape to exit edit mode |
|
await cell.press('Escape'); |
|
|
|
// right click menu |
|
await this.get().locator(`td[data-testid="cell-${columnHeader}-0"]`).click({ |
|
button: 'right', |
|
}); |
|
// await expect(this.rootPage.locator('text=Insert New Row')).toBeVisible(); |
|
|
|
// in cell-add |
|
await this.cell.get({ index: 0, columnHeader: 'Cities' }).hover(); |
|
await expect(this.cell.get({ index: 0, columnHeader: 'Cities' }).locator('.nc-action-icon.nc-plus')).toBeVisible(); |
|
} |
|
|
|
async verifyRoleAccess(param: { role: string }) { |
|
await this.column.verifyRoleAccess(param); |
|
await this.cell.verifyRoleAccess(param); |
|
await this.toolbar.verifyRoleAccess(param); |
|
await this.footbar.verifyRoleAccess(param); |
|
} |
|
|
|
async selectRange({ start, end }: { start: CellProps; end: CellProps }) { |
|
const startCell = this.cell.get({ index: start.index, columnHeader: start.columnHeader }); |
|
const endCell = this.cell.get({ index: end.index, columnHeader: end.columnHeader }); |
|
const page = this.dashboard.get().page(); |
|
await startCell.hover(); |
|
await page.mouse.down(); |
|
await endCell.hover(); |
|
await page.mouse.up(); |
|
} |
|
|
|
async selectedCount() { |
|
return this.get().locator('.cell.active').count(); |
|
} |
|
|
|
async getActiveCell() { |
|
return this.get().locator('.cell.active'); |
|
} |
|
|
|
async verifySelectedCellCount({ count }: { count: number }) { |
|
await expect(this.get().locator('.cell.active')).toHaveCount(count); |
|
} |
|
|
|
async copyWithKeyboard() { |
|
// retry to avoid flakiness, until text is copied to clipboard |
|
// |
|
let text = ''; |
|
let retryCount = 5; |
|
while (text === '') { |
|
await this.get().press((await this.isMacOs()) ? 'Meta+C' : 'Control+C'); |
|
await this.verifyToast({ message: 'Copied to clipboard' }); |
|
text = await this.getClipboardText(); |
|
|
|
// retry if text is empty till count is reached |
|
retryCount--; |
|
if (0 === retryCount) { |
|
break; |
|
} |
|
} |
|
return text; |
|
} |
|
|
|
async copyWithMouse({ index, columnHeader }: CellProps) { |
|
// retry to avoid flakiness, until text is copied to clipboard |
|
// |
|
let text = ''; |
|
let retryCount = 5; |
|
while (text === '') { |
|
await this.cell.get({ index, columnHeader }).click({ button: 'right' }); |
|
await this.get().page().getByTestId('context-menu-item-copy').click(); |
|
await this.verifyToast({ message: 'Copied to clipboard' }); |
|
text = await this.getClipboardText(); |
|
|
|
// retry if text is empty till count is reached |
|
retryCount--; |
|
if (0 === retryCount) { |
|
break; |
|
} |
|
} |
|
return text; |
|
} |
|
|
|
async pasteWithMouse({ index, columnHeader }: CellProps) { |
|
await this.cell.get({ index, columnHeader }).scrollIntoViewIfNeeded(); |
|
await this.cell.get({ index, columnHeader }).click({ button: 'right' }); |
|
|
|
await this.get().page().getByTestId('context-menu-item-paste').click(); |
|
|
|
// kludge: wait for paste to complete |
|
await this.rootPage.waitForTimeout(1000); |
|
} |
|
|
|
async clearWithMouse({ index, columnHeader }: CellProps) { |
|
await this.cell.get({ index, columnHeader }).click({ button: 'right' }); |
|
await this.get().page().getByTestId('context-menu-item-clear').click(); |
|
} |
|
}
|
|
|