From b7f6cad119a6c34a5892ba69a7e45a90a68e6893 Mon Sep 17 00:00:00 2001 From: Thomas Beaudry Date: Wed, 30 Sep 2026 11:28:13 -0400 Subject: [PATCH 1/3] feat(web): brand the demo login dialog with the Open Data Capture logo Show the logo and product name at the top of the demo information dialog, matching the site title on opendatacapture.org. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/__tests__/demo-banner.test.tsx | 7 +++++++ apps/web/src/components/DemoBanner/DemoBanner.tsx | 7 +++++++ testing/src/pages/auth/login.page.ts | 2 ++ testing/src/specs/auth.spec.ts | 6 ++++++ 4 files changed, 22 insertions(+) diff --git a/apps/web/src/__tests__/demo-banner.test.tsx b/apps/web/src/__tests__/demo-banner.test.tsx index 5bbf34e6d..45feb3f4d 100644 --- a/apps/web/src/__tests__/demo-banner.test.tsx +++ b/apps/web/src/__tests__/demo-banner.test.tsx @@ -21,6 +21,13 @@ describe('DemoBanner', () => { expect(screen.getByRole('dialog', { name: 'Demo Information' })).toBeTruthy(); }); + it('should brand the demo information with the Open Data Capture logo and name, as on the public site', () => { + render(); + const branding = within(screen.getByRole('dialog')).getByTestId('demo-dialog-branding'); + expect(branding.querySelector('svg')).toBeTruthy(); + expect(branding.textContent).toBe('Open Data Capture'); + }); + it('should close the demo information when dismissed, so the login form is reachable', () => { render(); screen.getByRole('dialog'); diff --git a/apps/web/src/components/DemoBanner/DemoBanner.tsx b/apps/web/src/components/DemoBanner/DemoBanner.tsx index 4228fdc38..8626d1757 100644 --- a/apps/web/src/components/DemoBanner/DemoBanner.tsx +++ b/apps/web/src/components/DemoBanner/DemoBanner.tsx @@ -2,6 +2,7 @@ import { snakeToCamelCase } from '@douglasneuroinformatics/libjs'; import { Card, Dialog, Table, Tooltip } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { DEMO_USERS } from '@opendatacapture/demo'; +import { Logo } from '@opendatacapture/react-core'; import type { $LoginCredentials } from '@opendatacapture/schemas/auth'; import { InfoIcon, LogInIcon } from 'lucide-react'; @@ -38,6 +39,12 @@ export const DemoBanner = ({ onLogin }: DemoBannerProps) => { event.preventDefault(); }} > +
+ + + Open Data Capture + +
{t('auth.demo.info')} diff --git a/testing/src/pages/auth/login.page.ts b/testing/src/pages/auth/login.page.ts index 652e5a534..3a7d95253 100644 --- a/testing/src/pages/auth/login.page.ts +++ b/testing/src/pages/auth/login.page.ts @@ -7,6 +7,7 @@ import { RootPage } from '../__root.page'; export class LoginPage extends RootPage { readonly _requiresAuth = false; readonly demoDialog: Locator; + readonly demoDialogBranding: Locator; /** One row per demo account, each with a button that logs straight in as it. */ readonly demoUserRows: Locator; readonly loginForm: Locator; @@ -14,6 +15,7 @@ export class LoginPage extends RootPage { constructor(page: Page) { super(page); this.demoDialog = page.getByTestId('demo-dialog'); + this.demoDialogBranding = this.demoDialog.getByTestId('demo-dialog-branding'); this.demoUserRows = this.demoDialog.getByRole('row').filter({ has: page.getByRole('cell') }); this.loginForm = page.getByTestId('login-form'); } diff --git a/testing/src/specs/auth.spec.ts b/testing/src/specs/auth.spec.ts index 7c74aeb40..e95fc1f5f 100644 --- a/testing/src/specs/auth.spec.ts +++ b/testing/src/specs/auth.spec.ts @@ -21,6 +21,12 @@ test.describe('authentication', () => { await expect(loginPage.demoDialog).toBeVisible(); }); + test('should brand the demo information with the Open Data Capture logo and name', async ({ getPageModel }) => { + const loginPage = await getPageModel('/auth/login'); + await expect(loginPage.demoDialogBranding.locator('svg')).toBeVisible(); + await expect(loginPage.demoDialogBranding).toHaveText('Open Data Capture'); + }); + test('should reveal the login form once the demo information is dismissed', async ({ getPageModel }) => { const loginPage = await getPageModel('/auth/login'); await expect(loginPage.demoDialog).toBeVisible(); From 2bd627a3eb1f8c0a3375f070cf7de1882941492c Mon Sep 17 00:00:00 2001 From: Thomas Beaudry Date: Wed, 30 Sep 2026 12:16:05 -0400 Subject: [PATCH 2/3] feat(web): log in from a click anywhere on a demo user's row The login icon was the only target in each row of the demo credentials dialog, so picking an account meant aiming at a 36px button rather than at the account being read. The row now carries the handler and the icon keeps none of its own, so its activation bubbles to the row: one path into `onLogin` whether it is reached by pointer or by keyboard. The header row cancels the hover highlight `Table.Row` applies by default, since only a body row is clickable. Co-Authored-By: Claude Opus 5 --- apps/web/src/__tests__/demo-banner.test.tsx | 18 +++++++++++++++ .../src/components/DemoBanner/DemoBanner.tsx | 19 ++++++++-------- testing/src/pages/auth/login.page.ts | 9 +++++++- testing/src/specs/auth.spec.ts | 22 +++++++++++++++++++ 4 files changed, 58 insertions(+), 10 deletions(-) diff --git a/apps/web/src/__tests__/demo-banner.test.tsx b/apps/web/src/__tests__/demo-banner.test.tsx index 45feb3f4d..acbb035de 100644 --- a/apps/web/src/__tests__/demo-banner.test.tsx +++ b/apps/web/src/__tests__/demo-banner.test.tsx @@ -50,4 +50,22 @@ describe('DemoBanner', () => { } expect(onLogin.mock.calls).toEqual(DEMO_USERS.map(({ password, username }) => [{ password, username }])); }); + + it('should log in from a click anywhere in the row, so the whole row is the target and not just its button', () => { + const onLogin = vi.fn(); + render(); + for (const { username } of DEMO_USERS) { + const cells = within(screen.getByRole('row', { name: new RegExp(username) })).getAllByRole('cell'); + fireEvent.click(cells[0]!); + } + expect(onLogin.mock.calls).toEqual(DEMO_USERS.map(({ password, username }) => [{ password, username }])); + }); + + it('should log in once for a click on the row button, so the row and the button do not both fire', () => { + const onLogin = vi.fn(); + render(); + const { password, username } = DEMO_USERS[0]!; + fireEvent.click(within(screen.getByRole('row', { name: new RegExp(username) })).getByRole('button')); + expect(onLogin).toHaveBeenCalledExactlyOnceWith({ password, username }); + }); }); diff --git a/apps/web/src/components/DemoBanner/DemoBanner.tsx b/apps/web/src/components/DemoBanner/DemoBanner.tsx index 8626d1757..e8c613d37 100644 --- a/apps/web/src/components/DemoBanner/DemoBanner.tsx +++ b/apps/web/src/components/DemoBanner/DemoBanner.tsx @@ -54,7 +54,8 @@ export const DemoBanner = ({ onLogin }: DemoBannerProps) => { - + {/* Only a body row is clickable, so cancel the hover highlight `Table.Row` applies by default. */} + {t('auth.demo.username')} @@ -69,7 +70,11 @@ export const DemoBanner = ({ onLogin }: DemoBannerProps) => { {DEMO_USERS.map((user) => ( - + onLogin({ password: user.password, username: user.username })} + > {user.username} {user.groupNames.map((name) => ( @@ -83,13 +88,9 @@ export const DemoBanner = ({ onLogin }: DemoBannerProps) => { - onLogin({ password: user.password, username: user.username })} - > + {/* No handler of its own: the click bubbles to the row, so the button stays the + keyboard-reachable control without the row and it holding separate logic. */} + diff --git a/testing/src/pages/auth/login.page.ts b/testing/src/pages/auth/login.page.ts index 3a7d95253..a1d139b16 100644 --- a/testing/src/pages/auth/login.page.ts +++ b/testing/src/pages/auth/login.page.ts @@ -8,8 +8,9 @@ export class LoginPage extends RootPage { readonly _requiresAuth = false; readonly demoDialog: Locator; readonly demoDialogBranding: Locator; - /** One row per demo account, each with a button that logs straight in as it. */ + /** One row per demo account. A click anywhere in the row logs straight in as it. */ readonly demoUserRows: Locator; + readonly demoUserTableHeaderRow: Locator; readonly loginForm: Locator; constructor(page: Page) { @@ -17,9 +18,15 @@ export class LoginPage extends RootPage { this.demoDialog = page.getByTestId('demo-dialog'); this.demoDialogBranding = this.demoDialog.getByTestId('demo-dialog-branding'); this.demoUserRows = this.demoDialog.getByRole('row').filter({ has: page.getByRole('cell') }); + this.demoUserTableHeaderRow = this.demoDialog.getByRole('row').filter({ has: page.getByRole('columnheader') }); this.loginForm = page.getByTestId('login-form'); } + async backgroundColorWhileHovering(row: Locator) { + await row.hover(); + return row.evaluate((element) => getComputedStyle(element).backgroundColor); + } + async dismissDemoDialog() { await this.$ref.keyboard.press('Escape'); await expect(this.demoDialog).toBeHidden(); diff --git a/testing/src/specs/auth.spec.ts b/testing/src/specs/auth.spec.ts index e95fc1f5f..1fee37616 100644 --- a/testing/src/specs/auth.spec.ts +++ b/testing/src/specs/auth.spec.ts @@ -51,6 +51,28 @@ test.describe('authentication', () => { await loginPage.expect.toHaveURL('/dashboard'); await expect(page.getByTestId('user-dropup-trigger')).toHaveText(username); }); + + test('should log in from a click on the row itself, not only on its button', async ({ getPageModel, page }) => { + const loginPage = await getPageModel('/auth/login'); + const demoUserRow = loginPage.demoUserRows.filter({ hasText: 'Group Manager' }).last(); + const usernameCell = demoUserRow.getByRole('cell').first(); + const username = await usernameCell.innerText(); + await usernameCell.click(); + + await loginPage.expect.toHaveURL('/dashboard'); + await expect(page.getByTestId('user-dropup-trigger')).toHaveText(username); + }); + + test('should highlight only the rows that log in, so the header does not look clickable', async ({ + getPageModel + }) => { + const loginPage = await getPageModel('/auth/login'); + const headerRow = loginPage.demoUserTableHeaderRow; + const restingColor = await headerRow.evaluate((element) => getComputedStyle(element).backgroundColor); + + expect(await loginPage.backgroundColorWhileHovering(headerRow)).toBe(restingColor); + expect(await loginPage.backgroundColorWhileHovering(loginPage.demoUserRows.first())).not.toBe(restingColor); + }); }); test.describe('invalid credentials', () => { From 8aca8f9b51318f7ed1ef9b2651a395f8de39709c Mon Sep 17 00:00:00 2001 From: Thomas Beaudry Date: Wed, 30 Sep 2026 12:25:28 -0400 Subject: [PATCH 3/3] refactor(testing): read the demo row hover colour in the spec that asserts it `backgroundColorWhileHovering` took any locator and never touched `this`, so it was a free function sitting among the page object's own interactions. The spec that needs it now hovers and reads the colour itself, and the header row's locator is named for the table rather than for the users. Also moves the trigger comment in `DemoBanner` out of the Radix tooltip root, where it was a child expression, and states the invariant it is there for. Co-Authored-By: Claude Opus 5 --- apps/web/src/components/DemoBanner/DemoBanner.tsx | 4 ++-- testing/src/pages/auth/login.page.ts | 14 ++++++-------- testing/src/specs/auth.spec.ts | 13 +++++++++---- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/DemoBanner/DemoBanner.tsx b/apps/web/src/components/DemoBanner/DemoBanner.tsx index e8c613d37..f32ad6346 100644 --- a/apps/web/src/components/DemoBanner/DemoBanner.tsx +++ b/apps/web/src/components/DemoBanner/DemoBanner.tsx @@ -87,9 +87,9 @@ export const DemoBanner = ({ onLogin }: DemoBannerProps) => { {t(`common.${snakeToCamelCase(user.basePermissionLevel!)}`)} + {/* The trigger carries no handler: its click bubbles to the row, so pointer and + keyboard reach `onLogin` by the one path. */} - {/* No handler of its own: the click bubbles to the row, so the button stays the - keyboard-reachable control without the row and it holding separate logic. */} diff --git a/testing/src/pages/auth/login.page.ts b/testing/src/pages/auth/login.page.ts index a1d139b16..68733dc22 100644 --- a/testing/src/pages/auth/login.page.ts +++ b/testing/src/pages/auth/login.page.ts @@ -8,25 +8,23 @@ export class LoginPage extends RootPage { readonly _requiresAuth = false; readonly demoDialog: Locator; readonly demoDialogBranding: Locator; - /** One row per demo account. A click anywhere in the row logs straight in as it. */ + readonly demoTableHeaderRow: Locator; + /** + * One row per demo account. A click anywhere in the row logs straight in as it; the row itself is + * not focusable, so the button inside it is the keyboard route to the same action. + */ readonly demoUserRows: Locator; - readonly demoUserTableHeaderRow: Locator; readonly loginForm: Locator; constructor(page: Page) { super(page); this.demoDialog = page.getByTestId('demo-dialog'); this.demoDialogBranding = this.demoDialog.getByTestId('demo-dialog-branding'); + this.demoTableHeaderRow = this.demoDialog.getByRole('row').filter({ has: page.getByRole('columnheader') }); this.demoUserRows = this.demoDialog.getByRole('row').filter({ has: page.getByRole('cell') }); - this.demoUserTableHeaderRow = this.demoDialog.getByRole('row').filter({ has: page.getByRole('columnheader') }); this.loginForm = page.getByTestId('login-form'); } - async backgroundColorWhileHovering(row: Locator) { - await row.hover(); - return row.evaluate((element) => getComputedStyle(element).backgroundColor); - } - async dismissDemoDialog() { await this.$ref.keyboard.press('Escape'); await expect(this.demoDialog).toBeHidden(); diff --git a/testing/src/specs/auth.spec.ts b/testing/src/specs/auth.spec.ts index 1fee37616..f9ef1aacd 100644 --- a/testing/src/specs/auth.spec.ts +++ b/testing/src/specs/auth.spec.ts @@ -1,3 +1,5 @@ +import type { Locator } from '@playwright/test'; + import { expect, test } from '../support/fixtures'; test.describe('authentication', () => { @@ -67,11 +69,14 @@ test.describe('authentication', () => { getPageModel }) => { const loginPage = await getPageModel('/auth/login'); - const headerRow = loginPage.demoUserTableHeaderRow; - const restingColor = await headerRow.evaluate((element) => getComputedStyle(element).backgroundColor); + const backgroundColor = (row: Locator) => row.evaluate((element) => getComputedStyle(element).backgroundColor); + const restingColor = await backgroundColor(loginPage.demoTableHeaderRow); + + await loginPage.demoTableHeaderRow.hover(); + expect(await backgroundColor(loginPage.demoTableHeaderRow)).toBe(restingColor); - expect(await loginPage.backgroundColorWhileHovering(headerRow)).toBe(restingColor); - expect(await loginPage.backgroundColorWhileHovering(loginPage.demoUserRows.first())).not.toBe(restingColor); + await loginPage.demoUserRows.first().hover(); + expect(await backgroundColor(loginPage.demoUserRows.first())).not.toBe(restingColor); }); });