diff --git a/frontend/src/components/filter-builder/controls/boolean-filter.test.tsx b/frontend/src/components/filter-builder/controls/boolean-filter.test.tsx new file mode 100644 index 00000000..34cc88c2 --- /dev/null +++ b/frontend/src/components/filter-builder/controls/boolean-filter.test.tsx @@ -0,0 +1,23 @@ +import { expect, test } from 'vitest' +import { page } from 'vitest/browser' +import { render } from 'vitest-browser-react' + +import type { Filter } from '../types' +import { Operators } from '../utils/operators' +import { BooleanFilter } from './boolean-filter' + +const config = { propertyKey: 'isBlocked', propertyLabel: 'Is Blocked', controlType: 'boolean' } as const + +function filterOf(operator: Filter['operator'], value: boolean): Filter { + return { id: '1', ...config, operator, value } +} + +test.each([ + { operator: Operators.EQUALS, value: true, current: 'True', other: 'False' }, + { operator: Operators.NOT_EQUALS, value: true, current: 'False', other: 'True' }, +])('shows $current as the current value of a $operator $value filter', async ({ operator, value, current, other }) => { + await render( {}} />) + + await expect.element(page.getByRole('button', { name: current })).toHaveAttribute('aria-pressed', 'true') + await expect.element(page.getByRole('button', { name: other })).toHaveAttribute('aria-pressed', 'false') +}) diff --git a/frontend/src/components/filter-builder/controls/boolean-filter.tsx b/frontend/src/components/filter-builder/controls/boolean-filter.tsx index ff158904..bc373527 100644 --- a/frontend/src/components/filter-builder/controls/boolean-filter.tsx +++ b/frontend/src/components/filter-builder/controls/boolean-filter.tsx @@ -3,11 +3,15 @@ import { Button } from '@/components/ui/button' import type { Filter, FilterControlProps } from '../types' import { Operators } from '../utils/operators' -// A click on a value submits, so the control does not read initialFilter. -type BooleanFilterProps = Pick +type BooleanFilterProps = Pick -export function BooleanFilter({ config, onSubmit }: BooleanFilterProps) { +/** A click on a value submits it. */ +export function BooleanFilter({ config, initialFilter, onSubmit }: BooleanFilterProps) { const { propertyKey, propertyLabel } = config + // Only the edit dialog has a current value. In the Add Filter menu the buttons are plain buttons, not toggles. + // A "Not Equals True" filter matches the rows that are false, so False is its current value. + const currentValue = initialFilter?.operator === Operators.NOT_EQUALS ? !initialFilter.value : initialFilter?.value + const isPressed = (value: boolean) => (initialFilter ? currentValue === value : undefined) const labelId = useId() const handleSubmit = (selectedValue: boolean) => { onSubmit({ @@ -24,14 +28,16 @@ export function BooleanFilter({ config, onSubmit }: BooleanFilterProps) {
{propertyLabel}
) } diff --git a/frontend/src/routes/index.tsx b/frontend/src/routes/index.tsx index 665e4b20..76392d38 100644 --- a/frontend/src/routes/index.tsx +++ b/frontend/src/routes/index.tsx @@ -65,7 +65,7 @@ function Index() { return (
-
+
{/* Header */}
@@ -214,7 +214,7 @@ function Index() {
)} -
+
) } diff --git a/frontend/tests/features/app-shell/app-shell.locators.ts b/frontend/tests/features/app-shell/app-shell.locators.ts index f2e195d4..77391500 100644 --- a/frontend/tests/features/app-shell/app-shell.locators.ts +++ b/frontend/tests/features/app-shell/app-shell.locators.ts @@ -1,8 +1,10 @@ import type { Page } from '@playwright/test' export const appShell = (page: Page) => ({ + aboutHeading: page.getByRole('heading', { level: 1, name: 'About' }), aboutText: page.getByText('This is a demo application built with .NET Aspire and React'), currentPage: page.getByRole('navigation', { name: 'breadcrumb' }).getByRole('link'), + mainLandmarks: page.getByRole('main'), notFoundHeading: page.getByRole('heading', { name: 'Page not found' }), homeLink: page.getByRole('link', { name: 'Go to the home page' }), weatherHeading: page.getByRole('heading', { name: 'Weather Forecast' }), diff --git a/frontend/tests/features/app-shell/app-shell.spec.ts b/frontend/tests/features/app-shell/app-shell.spec.ts index 6be41163..5959056b 100644 --- a/frontend/tests/features/app-shell/app-shell.spec.ts +++ b/frontend/tests/features/app-shell/app-shell.spec.ts @@ -33,6 +33,25 @@ test.describe('the home page on a desktop', () => { }) }) +test.describe('the landmarks of a page', () => { + test('the home page has one main landmark', async ({ page, api }) => { + api.mock(weatherApi.listReturns(aForecast())) + + await weatherForecastPage(page).open() + + expect(await appShellPage(page).at.mainLandmarks.count()).toBe(1) + }) + + test('the about page has one main landmark and a level 1 heading', async ({ page }) => { + const shell = appShellPage(page) + + await shell.openAbout() + + expect(await shell.at.mainLandmarks.count()).toBe(1) + await expect(shell.at.aboutHeading).toBeVisible() + }) +}) + test.describe('an unknown address', () => { test('shows a page not found message in the app layout', async ({ page }) => { const shell = appShellPage(page) diff --git a/frontend/tests/features/filter-demo/filter-demo.locators.ts b/frontend/tests/features/filter-demo/filter-demo.locators.ts index 425720df..3414f5d7 100644 --- a/frontend/tests/features/filter-demo/filter-demo.locators.ts +++ b/frontend/tests/features/filter-demo/filter-demo.locators.ts @@ -13,9 +13,11 @@ export const filterDemo = (page: Page) => { valueGroup: (propertyLabel: string) => control(propertyLabel).getByRole('group', { name: propertyLabel, exact: true }), controlLabel: (propertyLabel: string) => control(propertyLabel).getByText(propertyLabel, { exact: true }), button: (propertyLabel: string, name: string) => control(propertyLabel).getByRole('button', { name, exact: true }), + pressedButtons: (propertyLabel: string) => control(propertyLabel).getByRole('button', { pressed: true }), dateField: (propertyLabel: string, name: string) => control(propertyLabel).getByRole('group', { name, exact: true }), calendar: (propertyLabel: string) => control(propertyLabel).getByRole('grid'), addFilter: (propertyLabel: string) => control(propertyLabel).getByRole('button', { name: 'Add Filter', exact: true }), + editFilter: (propertyLabel: string) => page.getByRole('button', { name: new RegExp(`^${propertyLabel} \\|`) }), removeFilter: (propertyLabel: string) => page.getByRole('button', { name: new RegExp(`^Remove filter: ${propertyLabel}`) }), } } diff --git a/frontend/tests/features/filter-demo/filter-demo.spec.ts b/frontend/tests/features/filter-demo/filter-demo.spec.ts index 2628569f..72b715a9 100644 --- a/frontend/tests/features/filter-demo/filter-demo.spec.ts +++ b/frontend/tests/features/filter-demo/filter-demo.spec.ts @@ -31,6 +31,15 @@ test.describe('the Add Filter menu on a phone', () => { await expect(demo.at.valueGroup('Is Blocked')).toBeVisible() }) + test('gives the close button of the sheet a 32 px touch target', async ({ page }) => { + const demo = filterDemoPage(page) + await demo.open() + + await demo.chooseProperty('Is Blocked') + + expect(await demo.at.button('Is Blocked', 'Close').boundingBox()).toMatchObject({ width: 32, height: 32 }) + }) + test('adds a date filter from its control', async ({ page }) => { const demo = filterDemoPage(page) await demo.open() @@ -121,6 +130,26 @@ for (const screen of [ test.describe('the Add Filter menu on a desktop', () => { test.use({ viewport: { width: 1280, height: 800 } }) + test('keeps the 28 px close button in the filter dialog', async ({ page }) => { + const demo = filterDemoPage(page) + await demo.open() + await demo.addBooleanFilter('Is Blocked', 'True') + + await demo.editFilter('Is Blocked') + + expect(await demo.at.button('Is Blocked', 'Close').boundingBox()).toMatchObject({ width: 28, height: 28 }) + }) + + test('shows the True and False buttons of the menu as plain buttons', async ({ page }) => { + const demo = filterDemoPage(page) + await demo.open() + + await demo.chooseProperty('Is Blocked') + + await expect(demo.at.button('Is Blocked', 'True')).not.toHaveAttribute('aria-pressed') + await expect(demo.at.button('Is Blocked', 'False')).not.toHaveAttribute('aria-pressed') + }) + test('names the Is Blocked control in the menu', async ({ page }) => { const demo = filterDemoPage(page) await demo.open() @@ -142,6 +171,21 @@ test.describe('the Add Filter menu on a desktop', () => { }) }) +test.describe('the edit dialog of a boolean filter', () => { + for (const [value, other] of [['True', 'False'], ['False', 'True']] as const) { + test(`shows ${value} as the current value`, async ({ page }) => { + const demo = filterDemoPage(page) + await demo.open() + await demo.addBooleanFilter('Is Blocked', value) + + await demo.editFilter('Is Blocked') + + await expect(demo.at.pressedButtons('Is Blocked')).toHaveText([value]) + await expect(demo.at.button('Is Blocked', other)).toHaveAttribute('aria-pressed', 'false') + }) + } +}) + const shortWindow = { width: 1280, height: 512 } test.describe('the Add Filter menu on a short desktop window', () => { diff --git a/frontend/tests/features/filter-demo/filter-demo.tasks.ts b/frontend/tests/features/filter-demo/filter-demo.tasks.ts index 7d41bac1..dc457bcd 100644 --- a/frontend/tests/features/filter-demo/filter-demo.tasks.ts +++ b/frontend/tests/features/filter-demo/filter-demo.tasks.ts @@ -30,6 +30,18 @@ export function filterDemoPage(page: Page) { await expect(at.dateField(propertyLabel, 'Start date')).toBeVisible() }, + async addBooleanFilter(propertyLabel: string, value: 'True' | 'False') { + await at.addFilterMenu.click() + await at.property(propertyLabel).click() + await at.button(propertyLabel, value).click() + await expect(at.control(propertyLabel)).toBeHidden() + }, + + async editFilter(propertyLabel: string) { + await at.editFilter(propertyLabel).click() + await expect(at.control(propertyLabel)).toBeVisible() + }, + async addTodayFilter(propertyLabel: string) { await at.button(propertyLabel, 'Today').click() await at.addFilter(propertyLabel).click()