import { expect, Locator } from '@playwright/test'; import { UITypes } from 'nocodb-sdk'; import { GridPage } from '../../Grid'; import BasePage from '../../../Base'; import { AttachmentCellPageObject } from './AttachmentCell'; import { SelectOptionCellPageObject } from './SelectOptionCell'; import { SharedFormPage } from '../../../SharedForm'; import { CheckboxCellPageObject } from './CheckboxCell'; import { RatingCellPageObject } from './RatingCell'; import { DateCellPageObject } from './DateCell'; import { DateTimeCellPageObject } from './DateTimeCell'; import { GeoDataCellPageObject } from './GeoDataCell'; import { getTextExcludeIconText } from '../../../../tests/utils/general'; import { YearCellPageObject } from './YearCell'; import { TimeCellPageObject } from './TimeCell'; import { GroupPageObject } from '../../Grid/Group'; import { UserOptionCellPageObject } from './UserOptionCell'; import { SurveyFormPage } from '../../SurveyForm'; export interface CellProps { indexMap?: Array; index?: number; columnHeader: string; } export class CellPageObject extends BasePage { readonly parent: GridPage | SharedFormPage | SurveyFormPage | GroupPageObject; readonly selectOption: SelectOptionCellPageObject; readonly attachment: AttachmentCellPageObject; readonly checkbox: CheckboxCellPageObject; readonly rating: RatingCellPageObject; readonly year: YearCellPageObject; readonly time: TimeCellPageObject; readonly geoData: GeoDataCellPageObject; readonly date: DateCellPageObject; readonly dateTime: DateTimeCellPageObject; readonly userOption: UserOptionCellPageObject; constructor(parent: GridPage | SharedFormPage | SurveyFormPage | GroupPageObject) { super(parent.rootPage); this.parent = parent; this.selectOption = new SelectOptionCellPageObject(this); 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); this.userOption = new UserOptionCellPageObject(this); } get({ indexMap, index, columnHeader }: CellProps): Locator { if (this.parent instanceof SharedFormPage) { return this.parent.get().locator(`[data-testid="nc-form-input-cell-${columnHeader}"]`).first(); } else if (this.parent instanceof SurveyFormPage) { return this.parent .get() .locator(`[data-testid="nc-survey-form__input-${columnHeader.replace(' ', '')}"]`) .first(); } else if (this.parent instanceof GridPage) { return this.parent.get().locator(`td[data-testid="cell-${columnHeader}-${index}"]`).first(); } else { return this.parent.get({ indexMap }).locator(`td[data-testid="cell-${columnHeader}-${index}"]`).first(); } } async click({ index, columnHeader }: CellProps, ...options: Parameters) { await this.get({ index, columnHeader }).click(...options); await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable'); } async dblclick({ index, columnHeader }: CellProps) { return await this.get({ index, columnHeader }).dblclick(); } async fillText({ index, columnHeader, text, type }: CellProps & { text: string; type?: UITypes }) { await this.dblclick({ index, columnHeader, }); const isInputBox = async () => (await this.get({ index, columnHeader }).locator('input').count()) > 0; for (let i = 0; i < 10; i++) { if (await isInputBox()) { break; } await this.rootPage.waitForTimeout(200); } if (await isInputBox()) { await this.get({ index, columnHeader }).locator('input').fill(text); if (type && [UITypes.Date, UITypes.Time, UITypes.Year, UITypes.DateTime].includes(type)) { await this.rootPage.keyboard.press('Enter'); } } else { await this.get({ index, columnHeader }).locator('textarea').fill(text); } } async inCellExpand({ index, columnHeader }: CellProps) { // await this.get({ index, columnHeader }).hover(); await this.waitForResponse({ uiAction: () => this.get({ index, columnHeader }).locator('.nc-datatype-link').click(), requestUrlPathToMatch: '/api/v1/db/data/noco', httpMethodsToMatch: ['GET'], }); } async inCellAdd({ index, columnHeader }: CellProps) { await this.get({ index, columnHeader }).hover(); await this.get({ index, columnHeader }).locator('.nc-action-icon.nc-plus').click(); } async verifyCellActiveSelected({ index, columnHeader }: CellProps) { await expect(this.get({ index, columnHeader })).toHaveClass(/active/); } async verifyCellEditable({ index, columnHeader }: CellProps) { await this.get({ index, columnHeader }).isEditable(); } async verify({ index, columnHeader, value }: CellProps & { value: string | string[] }) { const _verify = async text => { // await expect // .poll(async () => { // const innerTexts = await this.get({ // index, // columnHeader, // }).allInnerTexts(); // return typeof innerTexts === 'string' ? [innerTexts] : innerTexts; // }) // .toContain(text); // retrieve text from cell // loop for 5 seconds // if text is found, return // if text is not found, throw error let count = 0; if (!(this.parent instanceof SharedFormPage)) { await this.rootPage.locator(`td[data-testid="cell-${columnHeader}-${index}"]`).waitFor({ state: 'visible' }); } await this.get({ index, columnHeader, }).scrollIntoViewIfNeeded(); while (count < 5) { const innerTexts = await getTextExcludeIconText(this.get({ index, columnHeader })); const cellText = typeof innerTexts === 'string' ? [innerTexts] : innerTexts; if (cellText) { if (cellText?.includes(text) || cellText[0]?.includes(text)) { return; } } await this.rootPage.waitForTimeout(1000); count++; if (count === 5) { console.log('cellText', cellText); console.log('text', text); throw new Error(`Cell text "${text}" not found`); } } }; if (Array.isArray(value)) { for (const text of value) { await _verify(text); } } else { await _verify(value); } } async verifyGeoDataCell({ index, columnHeader, lat, long, }: { index: number; columnHeader: string; lat: string; long: string; }) { const _verify = async expectedValue => { await expect .poll(async () => { const cell = await this.get({ index, columnHeader, }).locator(`[data-testid="nc-geo-data-lat-long-set"]`); return await cell.textContent(); //.getAttribute('title'); }) .toEqual(expectedValue); }; const value = `${lat}; ${long}`; await _verify(value); } async verifyDateCell({ index, columnHeader, value }: { index: number; columnHeader: string; value: string }) { const _verify = async expectedValue => { await expect .poll(async () => { const cell = await this.get({ index, columnHeader, }).locator('.nc-date-picker'); return await cell.getAttribute('title'); }) .toEqual(expectedValue); }; await _verify(value); } async verifyQrCodeCell({ index, columnHeader, expectedSrcValue, }: CellProps & { expectedSrcValue: string; }) { const _verify = async expectedQrCodeImgSrc => { await expect .poll(async () => { const qrCell = await this.get({ index, columnHeader, }); const qrImg = await qrCell.getByRole('img'); const qrImgSrc = await qrImg.getAttribute('src'); return qrImgSrc; }) .toEqual(expectedQrCodeImgSrc); }; await _verify(expectedSrcValue); } async verifyBarcodeCellShowsInvalidInputMessage({ index, columnHeader }: { index: number; columnHeader: string }) { const _verify = async expectedInvalidInputMessage => { await expect .poll(async () => { const barcodeCell = await this.get({ index, columnHeader, }); const barcodeInvalidInputMessage = await barcodeCell.getByTestId('barcode-invalid-input-message'); return await barcodeInvalidInputMessage.textContent(); }) .toEqual(expectedInvalidInputMessage); }; await _verify('Barcode error - please check compatibility between input and barcode type'); } async verifyBarcodeCell({ index, columnHeader, expectedSvgValue, }: { index: number; columnHeader: string; expectedSvgValue: string; }) { const _verify = async (expectedBarcodeSvg: unknown) => { await expect .poll(async () => { const barcodeCell = this.get({ index, columnHeader }); const barcodeSvg = barcodeCell.getByTestId('barcode'); return await barcodeSvg.innerHTML(); }) .toEqual(expectedBarcodeSvg); }; await _verify(expectedSvgValue); } // todo: Improve param names (i.e value => values) // verifyVirtualCell // : virtual relational cell- HM, BT, MM // : verify link count & cell value // async verifyVirtualCell({ index, columnHeader, type, count, value, verifyChildList = false, options, }: CellProps & { count?: number; type?: string; value?: string[]; verifyChildList?: boolean; options?: { singular?: string; plural?: string }; }) { const cell = this.get({ index, columnHeader }); const linkText = cell.locator('.nc-datatype-link'); await cell.scrollIntoViewIfNeeded(); // lazy load - give enough time for cell to load await this.rootPage.waitForTimeout(1000); if (type === 'bt') { const chips = cell.locator('.chips > .chip'); expect(await chips.count()).toBe(count); for (let i = 0; i < value.length; ++i) { await chips.nth(i).locator('.name').waitFor({ state: 'visible' }); await chips.nth(i).locator('.name').scrollIntoViewIfNeeded(); await expect(chips.nth(i).locator('.name')).toHaveText(value[i]); } return; } // verify chip count & contents if (count) { const expectedText = `${count} ${count === 1 ? options.singular : options.plural}`; let retryCount = 0; while (retryCount < 5) { const receivedText = await linkText.innerText(); if (receivedText.includes(expectedText)) { break; } retryCount++; // add delay of 100ms await this.rootPage.waitForTimeout(100 * retryCount); } expect(await cell.innerText()).toContain(expectedText); } if (verifyChildList) { // open child list await this.get({ index, columnHeader }).hover(); // arrow expand doesn't exist for bt columns if (await linkText.count()) { await this.waitForResponse({ uiAction: () => linkText.click(), requestUrlPathToMatch: '/api/v1', httpMethodsToMatch: ['GET'], }); // wait for child list to open await this.rootPage.waitForSelector('.nc-modal-child-list:visible'); // verify child list count & contents await expect.poll(() => this.rootPage.locator('.ant-card:visible').count()).toBe(count); // close child list // await this.rootPage.locator('.nc-modal-child-list').locator('.nc-close-btn').last().click(); await this.rootPage.locator('.nc-modal-child-list').getByTestId('nc-link-count-info').click(); await this.rootPage.keyboard.press('Escape'); } } } async unlinkVirtualCell({ index, columnHeader }: CellProps) { const cell = this.get({ index, columnHeader }); const isLink = await cell.locator('.nc-datatype-link').count(); // Count will be 0 for BT columns if (!isLink) { await cell.click(); await cell.locator('.nc-icon.unlink-icon').click(); // await cell.click(); } // For HM/MM columns else { await cell.locator('.nc-datatype-link').click(); await this.rootPage .locator(`[data-testid="nc-child-list-item"]`) .last() .waitFor({ state: 'visible', timeout: 3000 }); await this.waitForResponse({ uiAction: () => this.rootPage .locator(`[data-testid="nc-child-list-item"]`) .last() .locator('button.nc-list-item-link-unlink-btn') .click({ force: true, timeout: 3000 }), requestUrlPathToMatch: '/api/v1/db/data/noco', httpMethodsToMatch: ['GET'], }); await this.rootPage.keyboard.press('Escape'); await this.rootPage.keyboard.press('Escape'); } } async verifyRoleAccess(param: { role: string }) { const role = param.role.toLowerCase(); const isEditAccess = role === 'creator' || role === 'editor' || role === 'owner'; // normal text cell const cell = this.get({ index: 0, columnHeader: 'Country' }); // editable cell await cell.dblclick(); await expect(cell.locator(`input`)).toHaveCount(isEditAccess ? 1 : 0); // press escape to close the input await cell.press('Escape'); await cell.press('Escape'); await cell.click({ button: 'right', clickCount: 1 }); await expect(this.rootPage.locator(`.nc-dropdown-grid-context-menu:visible`)).toHaveCount(1); // virtual cell const vCell = this.get({ index: 0, columnHeader: 'Cities' }); await vCell.hover(); // in-cell add await expect(vCell.locator('.nc-action-icon.nc-plus:visible')).toHaveCount(isEditAccess ? 1 : 0); // virtual cell link text const linkText = await getTextExcludeIconText(vCell); expect(linkText).toContain('1 City'); } async copyCellToClipboard({ index, columnHeader }: CellProps, ...clickOptions: Parameters) { if (this.parent instanceof GridPage) await this.parent.renderColumn(columnHeader); await this.get({ index, columnHeader }).scrollIntoViewIfNeeded(); await this.get({ index, columnHeader }).click(...clickOptions); await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable'); await this.get({ index, columnHeader }).press((await this.isMacOs()) ? 'Meta+C' : 'Control+C'); await this.verifyToast({ message: 'Copied to clipboard' }); } async pasteFromClipboard({ index, columnHeader }: CellProps, ...clickOptions: Parameters) { await this.get({ index, columnHeader }).scrollIntoViewIfNeeded(); await this.get({ index, columnHeader }).click(...clickOptions); await (await this.get({ index, columnHeader }).elementHandle()).waitForElementState('stable'); await this.get({ index, columnHeader }).press((await this.isMacOs()) ? 'Meta+V' : 'Control+V'); // kludge: wait for paste to complete await this.rootPage.waitForTimeout(1000); } }