diff --git a/client-app/src/desktop/tabs/examples/ExamplesTab.ts b/client-app/src/desktop/tabs/examples/ExamplesTab.ts index bb5b654b3..5f46c94bf 100644 --- a/client-app/src/desktop/tabs/examples/ExamplesTab.ts +++ b/client-app/src/desktop/tabs/examples/ExamplesTab.ts @@ -73,6 +73,7 @@ const appTile = hoistCmp.factory(({app, model}) => { title: app.title, icon: app.icon, compactHeader: true, + testId: app.title, items: div({ className: 'tb-examples__app-tile__contents', items: app.text diff --git a/client-app/src/examples/todo/TaskDialog.ts b/client-app/src/examples/todo/TaskDialog.ts index db4614682..0db14d392 100644 --- a/client-app/src/examples/todo/TaskDialog.ts +++ b/client-app/src/examples/todo/TaskDialog.ts @@ -1,4 +1,4 @@ -import {uses, hoistCmp} from '@xh/hoist/core'; +import {hoistCmp, uses} from '@xh/hoist/core'; import {Icon} from '@xh/hoist/icon'; import {filler, hbox, vbox} from '@xh/hoist/cmp/layout'; import {panel} from '@xh/hoist/desktop/cmp/panel'; @@ -30,12 +30,13 @@ export const taskDialog = hoistCmp.factory({ const formPanel = hoistCmp.factory(() => panel({ - item: form( - vbox({ + item: form({ + testId: 'task-dialog', + item: vbox({ items: [description(), dueDate()], className: 'todo-form' }) - ), + }), bbar: bbar() }) ); @@ -68,10 +69,12 @@ const bbar = hoistCmp.factory(({model}) => toolbar( filler(), button({ + testId: 'cancel-task-edit-button', text: 'Cancel', onClick: () => model.close() }), button({ + testId: 'save-task-button', text: 'OK', icon: Icon.check(), disabled: !model.formModel.isValid, diff --git a/client-app/src/examples/todo/TaskDialogModel.ts b/client-app/src/examples/todo/TaskDialogModel.ts index 16aeff02d..9f22fbad9 100644 --- a/client-app/src/examples/todo/TaskDialogModel.ts +++ b/client-app/src/examples/todo/TaskDialogModel.ts @@ -1,8 +1,8 @@ import {HoistModel, managed} from '@xh/hoist/core'; import {FormModel} from '@xh/hoist/cmp/form'; -import {required, lengthIs} from '@xh/hoist/data'; +import {lengthIs, required} from '@xh/hoist/data'; import {LocalDate} from '@xh/hoist/utils/datetime'; -import {observable, action, makeObservable} from '@xh/hoist/mobx'; +import {action, makeObservable, observable} from '@xh/hoist/mobx'; import {TodoPanelModel} from './TodoPanelModel'; export class TaskDialogModel extends HoistModel { diff --git a/client-app/src/examples/todo/TodoPanel.ts b/client-app/src/examples/todo/TodoPanel.ts index d6c8fead3..acb4d94b4 100644 --- a/client-app/src/examples/todo/TodoPanel.ts +++ b/client-app/src/examples/todo/TodoPanel.ts @@ -18,7 +18,13 @@ export const todoPanel = hoistCmp.factory({ ref: model.panelRef, mask: 'onLoad', tbar: tbar(), - items: [grid({agOptions: {suppressMakeColumnVisibleAfterUnGroup: true}}), taskDialog()], + items: [ + grid({ + testId: 'todo-grid', + agOptions: {suppressMakeColumnVisibleAfterUnGroup: true} + }), + taskDialog() + ], bbar: bbar() }); } @@ -49,11 +55,13 @@ const bbar = hoistCmp.factory(({model}) => }), '-', switchInput({ + testId: 'show-completed-switch', bind: 'showCompleted', label: 'Show Completed' }), filler(), button({ + testId: 'reset-button', icon: Icon.reset(), intent: 'danger', tooltip: 'Reset to example defaults.', diff --git a/client-app/src/examples/todo/TodoPanelModel.ts b/client-app/src/examples/todo/TodoPanelModel.ts index 10f0b5580..cbfca9987 100644 --- a/client-app/src/examples/todo/TodoPanelModel.ts +++ b/client-app/src/examples/todo/TodoPanelModel.ts @@ -35,7 +35,8 @@ export class TodoPanelModel extends HoistModel { icon: Icon.add(), text: 'New', intent: 'success', - actionFn: () => this.taskDialogModel.openAddForm() + actionFn: () => this.taskDialogModel.openAddForm(), + testId: 'add-button' }); editAction = new RecordAction({ @@ -43,7 +44,8 @@ export class TodoPanelModel extends HoistModel { text: 'Edit', intent: 'primary', recordsRequired: 1, - actionFn: () => this.taskDialogModel.openEditForm(this.selectedTasks[0]) + actionFn: () => this.taskDialogModel.openEditForm(this.selectedTasks[0]), + testId: 'edit-button' }); deleteAction = new RecordAction({ @@ -51,7 +53,8 @@ export class TodoPanelModel extends HoistModel { text: 'Remove', intent: 'danger', recordsRequired: true, - actionFn: () => this.deleteTasksAsync(this.selectedTasks) + actionFn: () => this.deleteTasksAsync(this.selectedTasks), + testId: 'remove-button' }); toggleCompleteAction = new RecordAction({ @@ -129,7 +132,8 @@ export class TodoPanelModel extends HoistModel { message = count === 1 ? `'${description}?'` : `${count} tasks?`, confirmed = await XH.confirm({ title: 'Confirm', - message: `Are you sure you want to permanently remove ${message}` + message: `Are you sure you want to permanently remove ${message}`, + confirmProps: {testId: 'confirm-delete-button'} }); if (!confirmed) return; @@ -230,7 +234,8 @@ export class TodoPanelModel extends HoistModel { }, actionFn: ({record}) => { this.toggleCompleteAsync([record.data]); - } + }, + testId: 'toggle-complete-action' } ] }, diff --git a/playwright/hoist/GridHelper.ts b/playwright/hoist/GridHelper.ts new file mode 100644 index 000000000..21ee158bc --- /dev/null +++ b/playwright/hoist/GridHelper.ts @@ -0,0 +1,120 @@ +import {Page} from '@playwright/test'; +import {HoistPage} from './HoistPage'; +import {PlainObject} from '@xh/hoist/core'; + +export class GridHelper { + readonly hoistPage: HoistPage; + readonly testId: string; + + constructor(hoistPage: HoistPage, testId: string) { + this.hoistPage = hoistPage; + this.testId = testId; + } + + get page(): Page { + return this.hoistPage.page; + } + + async getRecordCount() { + return this.page.evaluate(testId => { + return window.XH.getActiveModelByTestId(testId).store.count; + }, this.testId); + } + + async ensureCount(count: number) { + const gridCount = await this.getRecordCount(); + if (gridCount !== count) + throw new Error(`Found ${gridCount} records when ${count} is expected`); + } + + async getRowData(recordIdQuery: RecordIdQuery) { + if ('id' in recordIdQuery) { + return this.page.evaluate( + ([testId, id]) => window.XH.getActiveModelByTestId(testId).store.getById(id).data, + [this.testId, recordIdQuery.id] + ); + } else { + return this.page.evaluate( + ([testId, agId]) => + window.XH.getActiveModelByTestId(testId).store.allRecords.find( + it => it.agId === agId + ).id, + [this.testId, recordIdQuery.agId] + ); + } + } + + async getRowAgId(query: RecordQuery) { + if ('agId' in query) return query.agId; + return 'id' in query + ? this.page.evaluate( + ([testId, id]) => window.XH.getActiveModelByTestId(testId).getById(id).agId, + [this.testId, query.id] + ) + : this.page.evaluate( + ([testId, recordData]) => + window.XH.getActiveModelByTestId(testId).store.allRecords.find(({data}) => + _.isMatch(data, recordData) + ).agId, + [this.testId, query.recordData] as const + ); + } + + async getRowId(query: RecordQuery) { + if ('id' in query) return query.id; + return 'agId' in query + ? this.page.evaluate( + ([testId, agId]) => + window.XH.getActiveModelByTestId(testId).store.allRecords.find( + it => it.agId === agId + ).id, + [this.testId, query.agId] + ) + : this.page.evaluate( + ([testId, recordData]) => + window.XH.getActiveModelByTestId(testId).store.allRecords.find(({data}) => + _.isMatch(data, recordData) + ).id, + [this.testId, query.recordData] as const + ); + } + + // select row with GridModel + async selectRow(query: RecordQuery) { + const id = await this.getRowId(query); + this.page.evaluate( + ([testId, id]) => { + const gridModel = window.XH.getActiveModelByTestId(testId), + record = gridModel.store.getById(id); + gridModel.selectAsync(record); + }, + [this.testId, id] + ); + } + + // Functions to click / double click on grid row + async clickRow(query: RecordQuery) { + const agId = await this.getRowAgId(query); + await this.page.getByTestId(this.testId).locator(`div[row-id="${agId}"]`).click(); + } + + async dblClickRow(query: RecordQuery) { + const agId = await this.getRowAgId(query); + await this.page.getByTestId(this.testId).locator(`div[row-id="${agId}"]`).dblclick(); + } +} + +type RecordQuery = IdQuery | AgIdQuery | RecordDataQuery; +type RecordIdQuery = IdQuery | AgIdQuery; + +interface IdQuery { + id: string | number; +} + +interface AgIdQuery { + agId: string; +} + +interface RecordDataQuery { + recordData: PlainObject; +} diff --git a/playwright/hoist/HoistPage.ts b/playwright/hoist/HoistPage.ts index 82752ad2f..d5ce753ed 100644 --- a/playwright/hoist/HoistPage.ts +++ b/playwright/hoist/HoistPage.ts @@ -3,6 +3,7 @@ import {AppModel} from '../../client-app/src/desktop/AppModel'; import {XHApi} from '@xh/hoist/core/XH'; import {StoreRecordId} from '@xh/hoist/data/StoreRecord'; import {PlainObject} from '@xh/hoist/core'; +import { GridHelper } from './GridHelper'; export interface FilterSelectQuery { testId: string; @@ -33,16 +34,20 @@ export class HoistPage { await this.waitForAppToBeRunning(); } + getGridHelper(testId: string): GridHelper{ + return new GridHelper(this, testId) + } + get(testId: string) { return this.page.getByTestId(testId); } - async click(testId: string) { - await this.get(testId).click(); + getByText(text: string) { + return this.page.getByText(text) } - async expectText(testId: string, text: string) { - await expect(this.get(testId)).toHaveText(text); + async click(testId: string) { + await this.get(testId).click(); } async fill(testId: string, value: string) { @@ -98,20 +103,6 @@ export class HoistPage { await this.page.locator('label', {has: this.page.getByTestId(testId)}).uncheck(); } - async getGridRowByRecordId(testId: string, id: StoreRecordId) { - return this.page.evaluate(() => - window.XH.getActiveModelByTestId(testId).gridModel.store.getById(id) - ); - } - - async getGridRowByCellContents(testId: string, spec: PlainObject) { - return this.page.evaluate(() => { - window.XH.getActiveModelByTestId(testId).gridModel.store.allRecords.find(({data}) => - _.isMatch(data, spec) - ); - }); - } - onConsoleError(msg: ConsoleMessage) { throw new Error(msg.text()); } @@ -133,6 +124,16 @@ export class HoistPage { await expect(this.maskLocator).toHaveCount(0, {timeout: 10000}); } + //Expect + async expectText(testId: string, text: string) { + await expect(this.get(testId)).toHaveText(text); + } + + async expectTextVisible(text: string) { + await expect(this.getByText(text)).toBeVisible({timeout: 10000}) + } + + //------------------------ // Implementation //------------------------ diff --git a/playwright/tests/todoExampleTest.spec.ts b/playwright/tests/todoExampleTest.spec.ts new file mode 100644 index 000000000..baf379fc9 --- /dev/null +++ b/playwright/tests/todoExampleTest.spec.ts @@ -0,0 +1,98 @@ +import { expect } from '@playwright/test'; +import {test} from '../Toolbox'; + + + +test('Test app load & grid reset', async ({page, tb}) => { + await page.goto(`${tb.baseURL.replace('app', 'todo')}`) + await tb.waitForMaskToClear() + + const grid = tb.getGridHelper('todo-grid') + + await tb.click('reset-button') + await grid.ensureCount(2) +}); + +test('Test show completed', async ({page, tb}) => { + await page.goto(`${tb.baseURL.replace('app', 'todo')}`) + await tb.waitForMaskToClear() + const grid = tb.getGridHelper('todo-grid') + await tb.click('reset-button') + + await grid.ensureCount(2) + await tb.check('show-completed-switch') + await grid.ensureCount(3) +}); + +test('Test mark task complete', async ({page, tb}) => { + await page.goto(`${tb.baseURL.replace('app', 'todo')}`) + await tb.waitForMaskToClear() + const grid = tb.getGridHelper('todo-grid') + await tb.click('reset-button') + + await grid.ensureCount(2) + await tb.click('toggle-complete-action-1') // Mark task with record id of 1 as completed + await tb.expectTextVisible('Update JS dependencies for the legacy webapp') + await grid.ensureCount(1) + await tb.check('show-completed-switch') + await grid.ensureCount(3) + +}); + +test('Test task add/edit/update/delete', async ({page, tb}) => { + await page.goto(`${tb.baseURL.replace('app', 'todo')}`) + await tb.waitForMaskToClear() + const grid = tb.getGridHelper('todo-grid') + await tb.click('reset-button') + + // add new task + await tb.click('add-button') + await tb.fill('task-dialog-description','This is a new task') + await tb.click('save-task-button') + + // check task is added + await grid.ensureCount(3) + + // edit the task + await grid.dblClickRow({recordData:{description: 'This is a new task'}}) // check to see if double click opend edit dialog + await tb.expectText('task-dialog-description-input', 'This is a new task') + await tb.click('cancel-task-edit-button') + + await grid.selectRow({recordData:{description: 'This is a new task'}}) // select grid and check edit buton will open edit dialog + await tb.click('edit-button') + await tb.expectText('task-dialog-description-input', 'This is a new task') + + // update discription + await tb.fill('task-dialog-description', 'Updated task description') + await tb.click('save-task-button') + + // check task is updated + await grid.dblClickRow({recordData: {complete: false, description: 'Updated task description'}}) + await tb.expectText('task-dialog-description-input', 'Updated task description') + await tb.click('cancel-task-edit-button') + + await tb.click('remove-button') + await tb.expectTextVisible(`Are you sure you want to permanently remove 'Updated task description?'`) + await tb.click('confirm-delete-button') + await grid.ensureCount(2) + await tb.check('show-completed-switch') + await grid.ensureCount(3) +}); + +test('Test all task can be completed and placeholder message', async ({page, tb}) => { + await page.goto(`${tb.baseURL.replace('app', 'todo')}`) + await tb.waitForMaskToClear() + const grid = tb.getGridHelper('todo-grid') + await tb.click('reset-button') + + await tb.click('toggle-complete-action-1') // Mark task with record id of 1 as completed + const signUpRecordId = await grid.getRowId({recordData: {description: 'Sign-up for stress management workshop'}}) + console.log(signUpRecordId); + + + await tb.click(`toggle-complete-action-${signUpRecordId}`) + await grid.ensureCount(0) + await tb.expectTextVisible('Congratulations. You did it! All of it!') +}); + +