diff --git a/README.md b/README.md index 39b9ced..6c1ae1c 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ We are building a web application that helps gymnastics coaches plan their gymna ## Status -The first delivery was a specification. The application is not finished: the frontend is still the Vite starter page, but the catalog data has been extracted from the Code of Points and is in [`data/`](data/), and the database schema is in place: the Prisma schema and its migrations create all the tables of the data model in PostgreSQL. The seed script loads `data/` into the database, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). The login and the user data (gymnasts, their lists of elements and routines) work through the API. The connection from the frontend is the next step. +The first delivery was a specification. The application is not finished. The catalog data has been extracted from the Code of Points and is in [`data/`](data/), the database schema and migrations are in place, the seed script loads `data/` into PostgreSQL, and a GraphQL server in `backend/` answers the catalog queries and the search (see [GraphQL server](#graphql-server)). The login and the user data (gymnasts, their lists of elements and routines) work through the API. -The code in `src/` is the Vite starter project with one example test. The tooling is set up: TypeScript, ESLint, Prettier and Vitest. +The frontend is connected to the API: the **Elements page** searches the catalog with filters, sorting and "Load more" (see [The Elements page](#the-elements-page-search-filters-and-sort)), each element has a detail page, and "Your gymnasts" lists and adds gymnasts. The calculator and the About page are still placeholders, and the Google login in the interface is not connected yet. The tooling is set up: TypeScript, ESLint, Prettier and Vitest. ## What to review @@ -144,7 +144,7 @@ An icon without text needs a label for screen readers, for example ` - ))} + )} - + /> -

- {status} -

-

Results

- {error ? ( + {shownError ? (

- {describeError(error).message} + {describeError(shownError).message}

- {describeError(error).retryable && ( + {describeError(shownError).retryable && ( @@ -203,8 +373,10 @@ export default function ElementsPage() { className="m-0 rounded-lg bg-(--surface) p-5 font-medium text-(--text-color) shadow-(--box-shadow)" > {search - ? `No elements match “${search}”.` - : 'There are no elements.'} + ? `No elements match “${search}”${hasFilters(filters) ? ' with these filters' : ''}.` + : hasFilters(filters) + ? 'No elements match these filters.' + : 'There are no elements.'}

) : ( <> diff --git a/src/components/elements/FilterMenu.tsx b/src/components/elements/FilterMenu.tsx new file mode 100644 index 0000000..c170ce0 --- /dev/null +++ b/src/components/elements/FilterMenu.tsx @@ -0,0 +1,203 @@ +import { useId, useState, type ReactNode, type Ref } from 'react' +import { Menu } from '@base-ui/react/menu' +import { Tooltip } from '@base-ui/react/tooltip' +import { Check, ChevronDown } from 'lucide-react' +import { Button } from '@/components/ui/button' + +const ITEM = + 'flex min-h-10 cursor-default items-center gap-2 rounded-md px-3 text-sm font-medium text-(--text-color) outline-none select-none data-[highlighted]:bg-(--button-hover-bg)' + +const POPUP = + 'z-50 max-h-(--available-height) min-w-48 max-w-[min(32rem,90vw)] overflow-y-auto rounded-lg bg-(--surface) p-1 shadow-(--box-shadow) outline-none' + +type Option = { value: string; label: string } + +type Props = { + /** The name of the filter, also the name of the menu. */ + label: string + /** What is chosen, said on the button. Empty when nothing is. */ + summary: string + options: Option[] + /** Lets the page move the focus to this filter, for example after "Clear filters". */ + triggerRef?: Ref + /** Set when the filter cannot be used now: the reason, which makes the button disabled. */ + unavailable?: string + className?: string +} + +const TOOLTIP = + 'z-50 max-w-72 rounded-md bg-(--surface) px-3 py-2 text-sm text-(--text-color) shadow-(--box-shadow)' + +/** + * A filter that cannot be used now. It looks disabled and does nothing, but it uses + * `aria-disabled` and not the `disabled` attribute, so the keyboard can still reach it + * (a disabled button is skipped, and then nobody can find out why). The reason is a + * tooltip that opens on hover and on focus, and it is also the description of the button, + * so a screen reader says it too. This is the pattern that PatternFly, Salt and Vivid + * recommend for a disabled control that has to explain itself. + */ +function Unavailable({ + label, + reason, + className, + triggerRef, +}: { + label: string + reason: string + className?: string + triggerRef?: Ref +}) { + const reasonId = useId() + // A tooltip opens on hover and on keyboard focus, and not on a tap, so a touch user would + // never see the reason. Pressing the button opens it too, and it stays until the user + // leaves it, presses Escape or touches elsewhere. + const [open, setOpen] = useState(false) + return ( + + setOpen(true)} + /> + } + > + {label} + + {/* Always in the page, so it is the description also when the tooltip is closed. */} + + {reason} + + + + + + + + ) +} + +/** + * The button of a filter. It always says its name and what is chosen ("Apparatus: Vault"), + * and opens a Base UI menu, so the arrow keys, Enter, Escape and the type-ahead work and + * a chosen item is announced as checked. A filter that cannot be used is disabled, with + * the reason (see `Unavailable`). + */ +function Shell({ + label, + summary, + unavailable, + className, + triggerRef, + children, +}: Omit & { children: ReactNode }) { + if (unavailable) + return ( + + ) + + return ( + + + } + > + {summary ? `${label}: ` : label} + {summary && {summary}} + + + + + {children} + + + + + ) +} + +const Indicator = ({ children }: { children: ReactNode }) => ( + {children} +) + +/** One choice at a time. "All" (the empty value) takes the filter away. */ +export function SingleFilterMenu({ + value, + onChange, + allLabel, + ...props +}: Props & { + value: string + allLabel: string + onChange: (value: string) => void +}) { + return ( + + + {[{ value: '', label: allLabel }, ...props.options].map((option) => ( + + + + + + {option.label} + + ))} + + + ) +} + +/** Several at a time. The menu stays open, so the user can choose more than one. */ +export function MultiFilterMenu({ + values, + onToggle, + ...props +}: Props & { + values: string[] + /** One value was checked or unchecked. The page applies it to the filters as they are now. */ + onToggle: (value: string, checked: boolean) => void +}) { + return ( + + {props.options.map((option) => ( + onToggle(option.value, checked)} + className={ITEM} + > + + + + + {option.label} + + ))} + + ) +} diff --git a/src/components/elements/SortMenu.tsx b/src/components/elements/SortMenu.tsx new file mode 100644 index 0000000..05b2e88 --- /dev/null +++ b/src/components/elements/SortMenu.tsx @@ -0,0 +1,112 @@ +import { useId } from 'react' +import { Menu } from '@base-ui/react/menu' +import { ArrowUpDown, Check } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + SORT_FIELDS, + sortLabel, + type Sort, + type SortDirection, + type SortField, +} from '@/lib/searchOperations' + +const ITEM = + 'flex min-h-10 cursor-default items-center gap-2 rounded-md px-3 text-sm font-medium text-(--text-color) outline-none select-none data-[highlighted]:bg-(--button-hover-bg) data-[disabled]:opacity-60' + +const LABEL = 'px-3 pt-2 pb-1 text-xs font-bold text-(--text-color)' + +/** One choice of the list is a field and a direction: "Difficulty: high to low". */ +const toValue = (sort: Sort) => `${sort.field}:${sort.direction}` +const fromValue = (value: string): Sort => { + const [field, direction] = value.split(':') + return { field: field as SortField, direction: direction as SortDirection } +} + +/** + * The sort of the Elements page: a button that says the sort ("Code: low to high") and + * opens one list of choices that each say what the user gets ("Name: A to Z", "Difficulty: + * high to low"), as sort menus on other sites do, and not two separate settings. The list + * is the Base UI menu, so the arrow keys, Enter, Escape and the type-ahead work, and a + * radio item says that it is the chosen one. A field that needs an apparatus (difficulty) + * cannot be chosen without one: its items are disabled but can still be reached, and the + * reason is read with them. The choices come from SORT_FIELDS, so a new field needs no + * change here. + */ +export default function SortMenu({ + sort, + hasApparatus, + onChange, + className, +}: { + sort: Sort + hasApparatus: boolean + onChange: (sort: Sort) => void + className?: string +}) { + const reasonId = useId() + const blocked = (needsApparatus?: boolean) => + Boolean(needsApparatus) && !hasApparatus + const reasonShown = SORT_FIELDS.some((option) => + blocked(option.needsApparatus), + ) + + return ( + + + } + > + + + + + + Sort by + onChange(fromValue(value))} + > + {SORT_FIELDS.flatMap( + ({ field, label, directions, needsApparatus }) => + (Object.keys(directions) as SortDirection[]).map( + (direction) => { + const disabled = blocked(needsApparatus) + return ( + + + + + + {`${label}: ${directions[direction]}`} + + ) + }, + ), + )} + + {reasonShown && ( +

+ Choose an apparatus to sort by difficulty. +

+ )} +
+
+
+
+
+ ) +} diff --git a/src/components/gymnasts/YourGymnastsPage.test.tsx b/src/components/gymnasts/YourGymnastsPage.test.tsx index 031196a..9ba9a24 100644 --- a/src/components/gymnasts/YourGymnastsPage.test.tsx +++ b/src/components/gymnasts/YourGymnastsPage.test.tsx @@ -56,6 +56,12 @@ async function addGymnast(name: string) { afterEach(cleanup) describe('YourGymnastsPage: loading the gymnasts', () => { + it('has a title that says what the page is (WCAG 2.4.2)', async () => { + renderPage(() => coachWith(['Emma Berg'])) + await screen.findByText(/Emma Berg/) + expect(document.title).toBe('Your gymnasts – WAG CoP Planner') + }) + it('shows a loading state until the server answers', async () => { const answer = deferred() renderPage(() => answer.promise) diff --git a/src/components/gymnasts/YourGymnastsPage.tsx b/src/components/gymnasts/YourGymnastsPage.tsx index 2e99f2f..a9d1aff 100644 --- a/src/components/gymnasts/YourGymnastsPage.tsx +++ b/src/components/gymnasts/YourGymnastsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { useMutation, useQuery } from "@apollo/client/react"; import { Plus, X } from "lucide-react"; +import PageTitle from "@/components/PageTitle"; import { Button } from "@/components/ui/button"; import DevLoginButton from "@/components/gymnasts/DevLoginButton"; import GymnastList from "@/components/gymnasts/GymnastList"; @@ -87,6 +88,7 @@ export default function YourGymnastsPage() { return (
+ Your gymnasts

{announcement} diff --git a/src/lib/elementFilters.ts b/src/lib/elementFilters.ts new file mode 100644 index 0000000..208193e --- /dev/null +++ b/src/lib/elementFilters.ts @@ -0,0 +1,215 @@ +// The filters of the Elements page: the structure they choose from (apparatus, their +// groups and the difficulty values that exist), and how they are written in and read from +// the address (?apparatus=UB&group=UB-2&difficulty=A,B, and for vault ?apparatus=VT&difficulty=2.4,3.2). +// The filter itself runs on the server (`filter: CatalogFilter` on `searchCatalog`), so +// nothing here depends on how many elements there are: the options are the four apparatus, +// their groups and their difficulty values. + +import { gql, type TypedDocumentNode } from '@apollo/client' +import { + DIFFICULTY_VALUE_BY_LETTER, + getDifficultyLetter, + getDifficultyValue, + type DifficultyLetter, +} from '@/lib/difficulty' + +export type StructureGroup = { id: string; number: number; name: string } +export type StructureApparatus = { + id: string + name: string + groups: StructureGroup[] + /** The difficulty values that exist on the apparatus, lowest first. */ + difficultyValues: number[] +} + +/** + * The apparatus with their groups and difficulty values: a few dozen rows that only change + * with a new Code of Points. The server keeps them in memory, and the Apollo cache keeps + * them for the whole visit, so choosing a filter never asks for them again. + */ +export const STRUCTURE_QUERY: TypedDocumentNode<{ + apparatus: StructureApparatus[] +}> = gql` + query CatalogStructure { + apparatus { + id + name + difficultyValues + groups { + id + number + name + } + } + } +` + +/** + * Vault has D-values (1.6-6.4) and not the element values 0.1-1.0, so there are no letters + * A-J for it. There are 23 different D-values, which is too many to choose between one by + * one, so the interface offers bands of one whole number each (2.0-2.9, 3.0-3.9 ...). A + * band is filtered on the D-values that exist inside it. + */ +export const VAULT_ID = 'VT' + +/** The letters in the order of the menu: A (0.1) to J (1.0). */ +export const DIFFICULTY_LETTERS = Object.keys( + DIFFICULTY_VALUE_BY_LETTER, +) as DifficultyLetter[] + +/** + * The filters that are in force. `difficulty` is lowest first and never letters: the + * values (0.4) of an element, and for vault the start of each chosen band (2 for 2.0-2.9). + */ +export type ElementFilters = { + apparatusId: string | null + groupId: string | null + difficulty: number[] +} + +export const NO_FILTERS: ElementFilters = { + apparatusId: null, + groupId: null, + difficulty: [], +} + +export const hasFilters = (filters: ElementFilters): boolean => + Boolean(filters.apparatusId || filters.groupId || filters.difficulty.length) + +/** The word for a group. A vault group is only a number (see `groupLabel` in elementList.ts). */ +export const groupName = ( + apparatusId: string, + group: StructureGroup, +): string => (apparatusId === VAULT_ID ? `Group ${group.number}` : group.name) + +const byValue = (a: number, b: number) => a - b + +/** The band of a vault D-value: 2.4 is in the band that starts at 2. */ +const bandOf = (value: number) => Math.floor(value + 1e-9) + +/** A difficulty as the user reads it: a letter for an element, "2.0–2.9" for a vault band. */ +export function difficultyLabel(apparatusId: string | null, value: number) { + return apparatusId === VAULT_ID + ? `${value}.0–${value}.9` + : (getDifficultyLetter(value) ?? value.toFixed(1)) +} + +/** A difficulty as the address says it: a letter for an element, the band number (2) for vault. */ +const difficultyParam = (apparatusId: string | null, value: number) => + apparatusId === VAULT_ID ? String(value) : difficultyLabel(null, value) + +/** + * What the Difficulty menu offers. For vault the bands that have a D-value in them, in + * ascending order. For everything else the letters A-J (also with no apparatus chosen). + */ +export function difficultyOptions( + apparatus: StructureApparatus | undefined, +): { value: number; label: string }[] { + if (apparatus?.id === VAULT_ID) + return [...new Set(apparatus.difficultyValues.map(bandOf))] + .sort(byValue) + .map((value) => ({ value, label: difficultyLabel(VAULT_ID, value) })) + return DIFFICULTY_LETTERS.map((letter) => ({ + value: DIFFICULTY_VALUE_BY_LETTER[letter], + label: letter, + })) +} + +/** + * The filters from the address. Anything that does not fit is left out, so an old or + * edited address never sends what the API answers with BAD_USER_INPUT: an apparatus or + * group that does not exist, a group of another apparatus, and a difficulty that is not + * one of the options (letters for an element, a D-value that exists for vault). + * A group without an apparatus gives the apparatus of the group. + */ +export function parseFilters( + params: URLSearchParams, + structure: StructureApparatus[], +): ElementFilters { + const wantedGroup = params.get('group') + const owner = wantedGroup + ? structure.find((a) => a.groups.some((g) => g.id === wantedGroup)) + : undefined + const apparatus = + structure.find((a) => a.id === params.get('apparatus')) ?? owner + const groupId = + apparatus && apparatus.groups.some((g) => g.id === wantedGroup) + ? wantedGroup + : null + + const wanted = (params.get('difficulty') ?? '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + const allowed = new Set( + difficultyOptions(apparatus).map((o) => Math.round(o.value * 10)), + ) + const values = wanted + .map((part) => + apparatus?.id === VAULT_ID + ? Number.isInteger(Number(part)) + ? Number(part) + : undefined + : getDifficultyValue(part), + ) + .filter( + (value): value is number => + value !== undefined && allowed.has(Math.round(value * 10)), + ) + const difficulty = [ + ...new Set(values.map((v) => Math.round(v * 10) / 10)), + ].sort(byValue) + + return { apparatusId: apparatus?.id ?? null, groupId, difficulty } +} + +/** Whether the address asks for a filter, before the structure is known. */ +export const asksForFilters = (params: URLSearchParams): boolean => + ['apparatus', 'group', 'difficulty'].some((key) => params.has(key)) + +/** The address with these filters in it (the rest of it is kept). Nothing set leaves no key. */ +export function withFilters( + params: URLSearchParams, + filters: ElementFilters, +): URLSearchParams { + const next = new URLSearchParams(params) + const set = (key: string, value: string | null) => + value ? next.set(key, value) : next.delete(key) + set('apparatus', filters.apparatusId) + set('group', filters.groupId) + set( + 'difficulty', + filters.difficulty + .map((value) => difficultyParam(filters.apparatusId, value)) + .join(',') || null, + ) + return next +} + +/** What `searchCatalog` takes as `filter`. */ +export type CatalogFilterVariable = { + apparatusId?: string + elementGroupId?: string + difficultyValues?: number[] +} + +/** The `filter` argument, or null when no filter is in force. */ +export function toCatalogFilter( + filters: ElementFilters, + structure: StructureApparatus[], +): CatalogFilterVariable | null { + if (!hasFilters(filters)) return null + // A vault band is sent as the D-values that exist inside it, so the API needs no ranges. + const vault = structure.find((a) => a.id === VAULT_ID) + const difficultyValues = + filters.apparatusId === VAULT_ID + ? (vault?.difficultyValues ?? []).filter((value) => + filters.difficulty.includes(bandOf(value)), + ) + : filters.difficulty + return { + ...(filters.apparatusId && { apparatusId: filters.apparatusId }), + ...(filters.groupId && { elementGroupId: filters.groupId }), + ...(difficultyValues.length && { difficultyValues }), + } +} diff --git a/src/lib/searchOperations.ts b/src/lib/searchOperations.ts index d98f01b..2469210 100644 --- a/src/lib/searchOperations.ts +++ b/src/lib/searchOperations.ts @@ -3,6 +3,7 @@ // sort and pagination"). import { gql, type TypedDocumentNode } from '@apollo/client' +import type { CatalogFilterVariable } from '@/lib/elementFilters' import type { ElementListRow } from '@/lib/elementList' /** How many results one page has. The API allows 1-100. */ @@ -25,9 +26,94 @@ export const MAX_COUNTED = 1000 export const formatTotal = (totalCount: number): string => totalCount > MAX_COUNTED ? `${MAX_COUNTED}+` : String(totalCount) +export type SortField = 'CODE' | 'DIFFICULTY' | 'NAME' +export type SortDirection = 'ASC' | 'DESC' +export type Sort = { field: SortField; direction: SortDirection } + +export const DEFAULT_SORT: Sort = { field: 'CODE', direction: 'ASC' } + +/** + * The fields in the order of the menu, with the word the user sees and what each direction + * means for that field. The menu says the outcome ("Difficulty: high to low") and not + * "descending", the way sort menus on other sites do. A new field is one more line here + * (and in the API): the menu and the address are built from this list. + * `needsApparatus` is for a field the API only allows within one apparatus. + */ +export const SORT_FIELDS: { + field: SortField + label: string + directions: Record + needsApparatus?: boolean +}[] = [ + { + field: 'CODE', + label: 'Code', + directions: { ASC: 'low to high', DESC: 'high to low' }, + }, + { + field: 'DIFFICULTY', + label: 'Difficulty', + directions: { ASC: 'low to high', DESC: 'high to low' }, + needsApparatus: true, + }, + { + field: 'NAME', + label: 'Name', + directions: { ASC: 'A to Z', DESC: 'Z to A' }, + }, +] + +/** The sort as one phrase: "Difficulty: high to low". */ +export const sortLabel = (sort: Sort): string => { + const option = SORT_FIELDS.find((o) => o.field === sort.field)! + return `${option.label}: ${option.directions[sort.direction]}` +} + +/** + * The sort as the address says it (`?sort=difficulty&dir=desc`). What is missing or not + * known is the default. Difficulty is only allowed with an apparatus (the API answers + * BAD_USER_INPUT without one: element values and vault D-values cannot be compared), so + * without one an address with `sort=difficulty` sorts by code. + */ +export function parseSort( + sort: string | null, + dir: string | null, + hasApparatus: boolean, +): Sort { + // No `sort` in the address is the default field (it is left out), and then `dir` still + // counts: "Code: high to low" is only ?dir=desc. + const option = SORT_FIELDS.find((candidate) => + sort === null + ? candidate.field === DEFAULT_SORT.field + : candidate.field.toLowerCase() === sort.toLowerCase(), + ) + // A choice that cannot be used is the default sort as a whole: "Code: high to low" is + // not what "Difficulty: high to low" without an apparatus should become. + if (!option || (option.needsApparatus && !hasApparatus)) return DEFAULT_SORT + return { + field: option.field, + direction: dir?.toLowerCase() === 'desc' ? 'DESC' : 'ASC', + } +} + +/** The sort as the address says it. The default is left out. */ +export function sortParams(sort: Sort): Record { + return { + ...(sort.field !== DEFAULT_SORT.field && { + sort: sort.field.toLowerCase(), + }), + ...(sort.direction !== DEFAULT_SORT.direction && { + dir: sort.direction.toLowerCase(), + }), + } +} + /** What the queries are sent with. `after` is the cursor of the page to load next. */ export type SearchVariables = { search: string | null + /** Left out or null when no filter is chosen. */ + filter?: CatalogFilterVariable | null + sort?: Sort first: number after?: string | null } @@ -101,8 +187,20 @@ export const SEARCH_CATALOG_QUERY: TypedDocumentNode< SearchCatalogResult, SearchVariables > = gql` - query SearchCatalog($search: String, $first: Int!, $after: String) { - searchCatalog(search: $search, first: $first, after: $after) { + query SearchCatalog( + $search: String + $filter: CatalogFilter + $sort: CatalogSort + $first: Int! + $after: String + ) { + searchCatalog( + search: $search + filter: $filter + sort: $sort + first: $first + after: $after + ) { totalCount pageInfo { hasNextPage @@ -127,8 +225,20 @@ export const SEARCH_CATALOG_MORE_QUERY: TypedDocumentNode< SearchCatalogMoreResult, SearchVariables > = gql` - query SearchCatalogMore($search: String, $first: Int!, $after: String) { - searchCatalog(search: $search, first: $first, after: $after) { + query SearchCatalogMore( + $search: String + $filter: CatalogFilter + $sort: CatalogSort + $first: Int! + $after: String + ) { + searchCatalog( + search: $search + filter: $filter + sort: $sort + first: $first + after: $after + ) { pageInfo { hasNextPage endCursor