diff --git a/.claude/agent-memory/backend-developer/story_1030.md b/.claude/agent-memory/backend-developer/story_1030.md new file mode 100644 index 000000000..f9a981b09 --- /dev/null +++ b/.claude/agent-memory/backend-developer/story_1030.md @@ -0,0 +1,121 @@ +--- +name: Story #1030 Implementation +description: Migration 0028 + shared types foundation for EPIC-18 (Areas & Trades) +type: project +--- + +## Story #1030 Complete: Migration + Shared Types Foundation + +**Status**: Implemented and pushed to `feat/1030-migration-shared-types` +**Commit**: 647acbe — feat(epic-18): Migration 0028 + shared types foundation (Story #1030) + +### What Was Done + +#### 1. SQL Migration (0028_areas_trades_rework.sql) + +- Created `trades` table with 15 default trades (Plumbing, HVAC, Electrical, etc.) +- Migrated `vendor.specialty` string values to new trades lookup table +- Recreated `vendors` table: removed specialty column, added trade_id FK +- Recreated `vendor_contacts` table (dropped and recreated to maintain data) +- Created `areas` table with hierarchical support (name + parent_id self-reference) +- Added `area_id` and `assigned_vendor_id` columns to `work_items` +- Replaced `room` column with `area_id` on `household_items` +- Dropped all tag-related tables (`tags`, `work_item_tags`, `household_item_tags`) +- Updated default budget categories (added Waste, removed Equipment/Landscaping/Utilities/Insurance/Contingency if unused) + +#### 2. New Shared Type Files + +- **shared/src/types/area.ts** — AreaSummary, AreaResponse, AreaListResponse, CreateAreaRequest, UpdateAreaRequest, AreaListQuery +- **shared/src/types/trade.ts** — TradeSummary, TradeResponse, TradeListResponse, CreateTradeRequest, UpdateTradeRequest, TradeListQuery + +#### 3. Updated Shared Types + +**workItem.ts**: + +- Removed `import type { TagResponse } from './tag.js'` +- Added `VendorSummary` interface (id + name + trade) +- WorkItemSummary: removed `tags`, added `assignedVendor` + `area` +- WorkItemDetail: removed `tags`, added `assignedVendor` + `area` +- CreateWorkItemRequest/UpdateWorkItemRequest: removed `tagIds`, added `assignedVendorId` + `areaId` +- WorkItemListQuery: removed `tagId`, added `assignedVendorId` + `areaId` + +**householdItem.ts**: + +- Updated HouseholdItemVendorSummary: `specialty: string | null` → `trade: TradeSummary | null` +- HouseholdItemSummary: removed `room` + `tagIds`, added `area` +- HouseholdItemDetail: removed `tags` field +- CreateHouseholdItemRequest/UpdateHouseholdItemRequest: removed `room` + `tagIds`, added `areaId` +- HouseholdItemListQuery: removed `room` + `tagId`, added `areaId`, removed 'room' from sortBy + +**vendor.ts**: + +- Updated Vendor interface: `specialty: string | null` → `trade: TradeSummary | null` +- CreateVendorRequest/UpdateVendorRequest: `specialty` → `tradeId` +- VendorListQuery: `specialty` → `trade` in sortBy, added `tradeId` filter + +**timeline.ts**: + +- Added `assignedVendor: VendorSummary | null` to TimelineWorkItem +- Added `area: AreaSummary | null` to TimelineWorkItem +- Removed `tags: TagResponse[]` from TimelineWorkItem + +**budget.ts**: + +- Removed duplicate VendorSummary definition +- Now imports VendorSummary from workItem.ts + +**errors.ts**: + +- Added `AREA_IN_USE` error code (409) +- Added `TRADE_IN_USE` error code (409) + +**schema.ts** (Drizzle ORM): + +- Added `trades` table definition with indexes +- Added `areas` table definition with hierarchical support (self-referencing parent_id) +- Updated `vendors` table: removed specialty, added tradeId FK +- Updated `workItems` table: added areaId and assignedVendorId FKs +- Updated `householdItems` table: removed room, added areaId FK +- Removed tags, workItemTags, householdItemTags table definitions +- Imported `type AnySQLiteColumn` for the self-referencing areas.parent_id type + +**index.ts**: + +- Removed tag exports (Tag, TagResponse, CreateTagRequest, UpdateTagRequest, TagListResponse) +- Added area exports (AreaSummary, AreaResponse, AreaListResponse, AreaSingleResponse, CreateAreaRequest, UpdateAreaRequest, AreaListQuery) +- Added trade exports (TradeSummary, TradeResponse, TradeListResponse, TradeSingleResponse, CreateTradeRequest, UpdateTradeRequest, TradeListQuery) +- Added VendorSummary to WorkItems exports +- Removed VendorSummary from WorkItemBudgets exports (no duplicate) + +### Type Validation + +All production type definitions compile cleanly (verified individually): + +- area.ts ✓ +- trade.ts ✓ +- workItem.ts ✓ +- householdItem.ts ✓ +- vendor.ts ✓ +- timeline.ts ✓ +- budget.ts ✓ +- errors.ts ✓ +- workItemBudget.ts ✓ + +Test files in shared have expected failures (using old properties: room, tags, tagIds, specialty) — these will be fixed by QA when updating tests. + +### Downstream Dependencies + +Subsequent stories depend on these foundation types: + +- **Story #1031 + #1032** (Areas + Trades Backend CRUD): Will implement GET/POST/PATCH/DELETE endpoints +- **Story #1033 + #1034** (Work Item + HI Rework): Will update service/route handlers to use new fields +- **Story #1035** (Frontend Manage Page): Will create UI for areas/trades management +- **Story #1037** (Frontend Entity Integration): Will add area/vendor selection to work item/HI forms + +### Important Notes + +- The migration uses the vendor table recreation pattern from migration 0026 (drop old, create new, copy data, rename) +- Areas support hierarchical structure via parent_id self-reference using AnySQLiteColumn type annotation +- VendorSummary is now unified (defined once in workItem.ts, imported by budget.ts) to avoid duplication +- Tags are completely removed from the database schema and API types +- No routes or services are implemented in this story — pure foundation work diff --git a/.claude/agent-memory/e2e-test-engineer/MEMORY.md b/.claude/agent-memory/e2e-test-engineer/MEMORY.md index 2ac2d1cc4..8c273803a 100644 --- a/.claude/agent-memory/e2e-test-engineer/MEMORY.md +++ b/.claude/agent-memory/e2e-test-engineer/MEMORY.md @@ -3,6 +3,42 @@ > Detailed notes live in topic files. This index links to them. > See: `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-epic08-e2e.md`, `story-933-dav-vendor-contacts.md` +## i18n German Locale: page.reload() Required After setLanguage() + page.goto() (2026-03-23) + +After `setLanguage(page, 'de')` + `page.goto(targetUrl)`, always add `page.reload()` before +asserting German text. Pattern from "Key page headings render in German" test (passing) confirms. +Applied in i18n.spec.ts "German sidebar" test and all three i18n-categories.spec.ts German tests. +**The FIRST German locale switch in a test file needs `test.setTimeout(30000)` and a 20s expect +timeout** for the heading assertion — i18next cold-start initialization takes 10-15s on CI. +Pattern: `test.setTimeout(30000); setLanguage(de); goto(URL); reload(); expect(heading).toBeVisible({ timeout: 20000 })`. +Extra warm-up navigations (goto('/') to confirm 'Projekt') consume the 30s budget — avoid them. + +**Known flaky test**: "German locale: Manage trades tab shows 'Sanitär' instead of 'Plumbing'" +(`i18n-categories.spec.ts`) fails intermittently on CI — locale doesn't initialize before the +English page renders. Was failing before PR #1186 too (run 23429182196). Not blocking for beta PRs. + +## WorkItemsPage.search(): URL-based Wait Prevents Stale-DOM Race (2026-03-23) + +After `fill(query)`, add `page.waitForURL(url => url.searchParams.get('q') === query)` BEFORE +awaiting the `waitForResponse`. This confirms the debounce fired and React committed search state. +Do NOT call `waitForLoaded()` after the response — it resolves on stale DOM rows from the WebKit +clear-event response and creates a race where betaTitle stays visible for 10s. The test's own +`expect().not.toBeVisible()` retry handles DOM convergence. Same pattern for `clearSearch()`. + +## Dashboard Card Dismiss Reload: Use networkidle, Not waitForResponse (2026-03-23) + +For "dismissed card stays hidden after page reload" test: register `waitForResponse(GET preferences)` +before reload failed — LocaleContext fires FIRST GET and resolves the promise, but usePreferences +hook's second GET (which applies hiddenCards) arrives later. Fix: use `page.waitForLoadState('networkidle')` +AFTER `heading.waitFor({ state: 'visible', timeout: 10000 })` to ensure BOTH preference fetches +complete. The heading waitFor needs 10s timeout (not 5s actionTimeout) since SPA reinit takes time. + +## Vendor Count Assertions Are Fragile (2026-03-23) + +`getVendorNames().length` assertions are unreliable with parallel workers sharing the same DB. +Use `not.toContain(specificName)` instead of exact count equality. Remove `namesBefore`/ +`namesAfter` length comparisons in cancel/no-create tests. + ## E2E Parallel Isolation (2026-02-20) `testPrefix` fixture in `e2e/fixtures/auth.ts` — use `async (_fixtures, use, testInfo)` (NOT `{}` — ESLint `no-empty-pattern`). @@ -21,6 +57,25 @@ See `e2e-pom-patterns.md` for full details on: 3. **Mobile CSS-hidden table** — `display:none` elements still in DOM; `textContent()` works, clicks fail — check `tableContainer.isVisible()` before using table rows +## DataTable Migration (EPIC-18, PR #1177) POM Fixes (2026-03-22) + +After DataTable migration, three POM fix patterns applied: + +- **Modal `useId()` IDs broken**: `#create-modal-title`/`#delete-modal-title` don't exist. + Always use `getByRole('dialog', { name: ... })` + `getByRole('heading', { level: 2 })` inside. +- **`confirmDeleteButton` → `btnConfirmDelete`**: WorkItems + HouseholdItems use + `sharedStyles.btnConfirmDelete` from `shared.module.css`. Selector: `[class*="btnConfirmDelete"]`. +- **Mobile card name lookup**: DataTableCard has NO `cardName` class. The render() function + runs identically for table cells AND cards. Name column with `styles.vendorLink` → use + `[class*="vendorLink"]` inside `cardsContainer`. Applied in both getVendorNames() and + openDeleteModal() mobile paths in VendorsPage. +- **HouseholdItems actions menu**: buttons are `role="button"` (default), NOT `role="menuitem"`. + Use `[class*="menuItemDanger"]:visible` filtered by text "Delete". +- **Production bug #1178**: DateRangePicker phase resets after clicking start date. + DateFilter.handleChange only fires when both dates set; DateRangePicker useEffect resets + phase when startDate stays ''. Affects datatable-date-range-picker.spec.ts and + datatable-ux-fixes.spec.ts — PRODUCTION BUG, not a test issue. + ## E2E Wait Patterns: waitForResponse BEFORE the action (2026-02-23) `page.waitForResponse(pred)` must ALWAYS be registered BEFORE the action that triggers the request. diff --git a/.claude/agent-memory/e2e-test-engineer/e2e-pom-patterns.md b/.claude/agent-memory/e2e-test-engineer/e2e-pom-patterns.md index 0fcac958a..186ce3206 100644 --- a/.claude/agent-memory/e2e-test-engineer/e2e-pom-patterns.md +++ b/.claude/agent-memory/e2e-test-engineer/e2e-pom-patterns.md @@ -260,3 +260,59 @@ wraps responses in `{ budgetSource: {...} }` / `{ subsidyProgram: {...} }`. **Not fixable in test code**. Fix must be applied to `client/src/lib/budgetSourcesApi.ts` and `client/src/lib/subsidyProgramsApi.ts`. Tracked in GitHub issue #175. + +## HTML5 Drag Events via Synthetic Dispatch (2026-03-23, PR #1177) + +`page.mouse.down/move` does NOT fire HTML5 drag events (dragstart/dragover/drop). +Use `page.evaluate()` with `new DragEvent(...)` for full control. + +**Critical**: `effectAllowed` can only be set in **trusted** (user-initiated) drag events. +Synthetic events via `dispatchEvent()` are untrusted — the setter is a no-op. Never test +`effectAllowed` via synthetic events. Test `draggable="true"` attribute instead. + +**Working pattern** for insertion line test (tests CSS class after dragover): + +```typescript +const firstHandle = await firstItem.elementHandle(); +const secondHandle = await secondItem.elementHandle(); +await page.evaluate( + ({ source, target }) => { + const dataTransfer = new DataTransfer(); + source.dispatchEvent( + new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer }), + ); + target.dispatchEvent( + new DragEvent('dragover', { + bubbles: true, + cancelable: true, + dataTransfer, + clientY: target.getBoundingClientRect().top + 1, + }), + ); + }, + { source: firstHandle!, target: secondHandle! }, +); +await expect(dropAboveItem.or(dropBelowItem).first()).toBeVisible(); // use retry +``` + +Use `expect().toBeVisible()` (NOT `.count()`) for React state changes — retry handles async. + +## Multiple Preferences GET on Page Load (2026-03-23, PR #1177) + +On dashboard load, TWO components independently call `GET /api/users/me/preferences`: + +1. `LocaleContext` — fetches for locale preference +2. `usePreferences` hook — fetches all preferences (incl. `dashboard.hiddenCards`) + +A single `page.waitForResponse()` captures only the FIRST GET. If asserting on `hiddenCards`, +you need React to process BOTH responses. Use `expect().toHaveCount(0)` with auto-retry instead +of `waitForResponse` + immediate assertion. + +## waitForSearchParams After search() (2026-03-23, PR #1177) + +`WorkItemsPage.search()` was updated to call `waitForSearchParams(query)` AFTER the API +response. This ensures the URL `?q=` param is updated AND React has committed new data. +On mobile, `waitForLoaded()` resolves immediately (old cards visible) before re-render. +The URL update is the reliable indicator that `setSearchParams` (React) has committed. + +`waitForSearchParams()` is now public on `WorkItemsPage` for direct use in tests. diff --git a/.claude/agent-memory/frontend-developer/stylelint-setup.md b/.claude/agent-memory/frontend-developer/stylelint-setup.md new file mode 100644 index 000000000..c8a102157 --- /dev/null +++ b/.claude/agent-memory/frontend-developer/stylelint-setup.md @@ -0,0 +1,58 @@ +--- +name: Stylelint Configuration +description: Design token enforcement via stylelint - installed and configured +type: reference +--- + +## Stylelint Setup (Commit: 1d9986a) + +Stylelint configured in `.stylelintrc.json` (project root) with design token enforcement. + +### Versions + +- stylelint: 16.13.0 +- stylelint-config-standard: 37.0.0 +- Installed as root workspace devDependencies (shared) + +### Key Enforcement Rules + +**1. color-no-hex** — Prevents hardcoded hex colors + +- Catches: `color: #333` +- Enforce: Use `var(--color-*)` instead +- Disabled for: `tokens.css`, `docs/` + +**2. function-disallowed-list** — Prevents raw color functions + +- Catches: `rgb()`, `rgba()`, `hsl()`, `hsla()` +- Enforce: Use `var(--color-*)` instead +- Disabled for: `tokens.css`, `docs/` + +**3. declaration-property-value-disallowed-list** — Prevents numeric font-weight/z-index + +- Catches: `font-weight: 600` or `z-index: 50` +- Enforce: Use `var(--font-weight-*)` and `var(--z-index-*)` instead +- Disabled for: `tokens.css`, `docs/` + +### npm Scripts + +```bash +npm run stylelint # Check for violations +npm run stylelint:fix # Auto-fix (limited rules only) +npx stylelint path/to/file.css # Test specific file +``` + +### Expected Behavior + +- **Badge.module.css**: Passes (fully tokenized) +- **DependencySentenceDisplay.module.css**: 2 violations (numeric font-weights) +- **print.css**: 5 violations (hardcoded hex colors) +- **tokens.css**: Passes (hex colors allowed in token definitions) +- **docs/**: Passes (has separate CSS conventions) + +### Integration Notes + +- Not yet integrated into CI (violations don't block builds) +- Ready for manual checks and future CI integration +- When migrating existing code, run `npm run stylelint` to identify violations +- Use `var()` references from `client/src/styles/tokens.css` for all values diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index c2fc1e93a..198c34ec6 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -16,15 +16,14 @@ - All API endpoints under `/api/` prefix, error shape: `{ error: { code, message, details? } }` - Offset pagination: page (1-indexed), pageSize (default 25, max 100) -- Tags/users NOT paginated (small collections) -- PATCH with tagIds replaces entire tag set (set-semantics) +- Areas/trades/users NOT paginated (small collections) - Junction tables use composite PKs (no surrogate id) -- EXCEPT invoice_budget_lines which uses surrogate UUID (carries itemized_amount, needs individual CRUD) ## Naming Conventions - DB: snake_case | TS vars: camelCase | TS types: PascalCase | Files: camelCase.ts (React: PascalCase.tsx) | API: kebab-case | Env: UPPER_SNAKE_CASE -## Migrations (17 total) +## Migrations (18 total) - 0001-0009: Auth, work items, budget, milestones, deps, actual dates, document_links - 0010: household_items + 5 supporting tables (EPIC-04) @@ -32,8 +31,9 @@ - 0012: household_item_deps + delivery date columns - 0013-0016: HI dep cleanup, status rename, delivery date redesign, HI categories - 0017: invoice_budget_lines junction table (EPIC-15, ADR-018) +- 0018: areas + trades, vendor trade_id, WI area_id + assigned_vendor_id, HI area_id, drop tags (EPIC-18, ADR-028) -## ADRs (ADR-001 through ADR-018) +## ADRs (ADR-001 through ADR-028) - ADR-001-009: Tech stack + error handling - ADR-010: Auth (sessions + OIDC + scrypt) @@ -44,6 +44,7 @@ - ADR-015: Paperless-ngx integration (proxy + polymorphic links) - ADR-016: Household items (separate entity with parallel structure) - ADR-018: Invoice-budget-line junction table (M:N with XOR CHECK, ON DELETE CASCADE) +- ADR-028: Areas & Trades (structured dimensions replacing tags) ## EPIC Status @@ -53,12 +54,13 @@ - EPIC-06 Timeline/Gantt: Complete (promoted to main, v1.10.0) - EPIC-08 Documents: Complete (promoted to main, v1.11.0) - EPIC-04 Household Items: Complete (promoted to main, v1.12.0) -- EPIC-15 Budget-Line Invoice Linking: In progress. Story 15.1 schema (PR #612, request changes) +- EPIC-15 Budget-Line Invoice Linking: Complete (promoted to main, v1.14.0) +- EPIC-18 Areas & Trades: In progress (ADR-028, Schema, API Contract designed) ## GitHub Wiki - Wiki is git submodule at `wiki/`. Sync: `git submodule update --init wiki && git -C wiki pull origin master` -- ADR-001 through ADR-016, Architecture, Schema, API-Contract, Home, ADR-Index, Style-Guide, Security-Audit +- ADR-001 through ADR-028, Architecture, Schema, API-Contract, Home, ADR-Index, Style-Guide, Security-Audit - **Always push wiki before creating PR** -- submodule ref must be committed in feature branch ### Wiki Update Discipline (CRITICAL) @@ -116,6 +118,7 @@ See `epic04-household-items.md` for full details. - `epic03-refinement.md` -- 40 consolidated refinement items from EPIC-03 - `epic05-budget.md` -- EPIC-05 budget management details - `epic04-household-items.md` -- EPIC-04 household items architecture +- `epic18-areas-trades.md` -- EPIC-18 areas & trades structured dimensions ## EPIC-04 Review Summary (see story-reviews.md for details) @@ -150,3 +153,14 @@ Invoice-budget-line junction table (migration 0017). Request changes: ### Key Lesson: XOR CHECK + ON DELETE SET NULL Incompatibility (Bug #611) SQLite enforces CHECK constraints during FK SET NULL actions. If a table has `CHECK((a IS NOT NULL AND b IS NULL) OR ...)` and `ON DELETE SET NULL` on column `a`, deleting the referenced row triggers SET NULL which violates the XOR CHECK. Fix: use ON DELETE CASCADE instead. + +## PR #1150 Review (2026-03-22) -- EPIC-19 Backup & Restore + +Request changes (2 critical, 3 high, 2 medium): + +- CRITICAL: Wiki not updated (API-Contract.md, Architecture.md) -- zero wiki changes in PR +- CRITICAL: CLAUDE.md env var table missing BACKUP_DIR, BACKUP_CADENCE, BACKUP_RETENTION +- HIGH: stopScheduler() never called on app close -- needs onClose hook +- HIGH: Module-level mutable state (operationInProgress, cronTask) -- testing concern +- MEDIUM: process.exit(0) bypasses Fastify graceful shutdown +- MEDIUM: createError state set but never rendered in UI (bug #1164) diff --git a/.claude/agent-memory/product-architect/epic17-i18n.md b/.claude/agent-memory/product-architect/epic17-i18n.md new file mode 100644 index 000000000..58f6d737f --- /dev/null +++ b/.claude/agent-memory/product-architect/epic17-i18n.md @@ -0,0 +1,22 @@ +--- +name: EPIC-17 i18n Architecture +description: Internationalization architecture decisions for EPIC-17 (English + German, currency env var, react-i18next) +type: project +--- + +EPIC-17 adds i18n support with English and German. ADR-021. + +**Why:** Cornerstone targets German homeowners; UI must be available in German with locale-aware formatting. + +**How to apply:** + +- Library: react-i18next (pure JS, no native binaries) +- Translations: statically bundled in `client/src/i18n/{en,de}/*.json` (11 namespaces per language) +- Locale stored as user preference (key: `locale`, values: `en|de|system`) -- no schema changes +- LocaleContext mirrors ThemeContext pattern (localStorage + server sync) +- CURRENCY env var (default EUR) exposed via `GET /api/config` (public, no auth) +- Formatters gain locale/currency params; `useFormatters()` hook wraps them +- Error translation: client-side from ErrorCode using `errors` namespace +- ADR-021 documents full design including namespace structure, key conventions, detection order +- API Contract updated: GET /api/config + added to unprotected routes +- Architecture.md updated: i18n section + CURRENCY in Core env vars table diff --git a/.claude/agent-memory/product-architect/epic18-areas-trades.md b/.claude/agent-memory/product-architect/epic18-areas-trades.md new file mode 100644 index 000000000..f68a077d2 --- /dev/null +++ b/.claude/agent-memory/product-architect/epic18-areas-trades.md @@ -0,0 +1,47 @@ +--- +name: EPIC-18 Areas & Trades Architecture +description: Schema, API contract, and ADR details for EPIC-18 structured dimensions replacing tags +type: project +--- + +## EPIC-18: Area & Trade Structured Dimensions + +**ADR-028** (Accepted) — replaces generic tags with Areas (hierarchical) and Trades (flat). + +### Schema Changes (Migration 0018) + +- NEW: `areas` table (self-ref parent_id, ON DELETE CASCADE, UNIQUE(name, parent_id)) +- NEW: `trades` table (UNIQUE name, 15 seeded defaults) +- MOD: `vendors` — add `trade_id` FK (ON DELETE SET NULL), ignore `specialty` (not dropped due to SQLite ALTER limitations) +- MOD: `work_items` — add `area_id` (ON DELETE SET NULL), add `assigned_vendor_id` (ON DELETE SET NULL), CHECK(assigned_user_id IS NULL OR assigned_vendor_id IS NULL) enforced at app layer +- MOD: `household_items` — add `area_id` (ON DELETE SET NULL), ignore `room` (not dropped) +- DROP: `tags`, `work_item_tags`, `household_item_tags` +- Budget category defaults: ADD bc-waste, conditionally DELETE bc-equipment/landscaping/utilities/insurance/contingency +- HI category defaults: ADD hic-equipment, conditionally DELETE hic-outdoor/hic-storage + +### API Changes + +- NEW: `/api/areas` CRUD (no pagination, search filter, tree built client-side) +- NEW: `/api/trades` CRUD (no pagination, search filter) +- REMOVED: All `/api/tags/*` +- MOD: Work items — `tags[]` → `area: {id, name, color} | null`, added `assignedVendor`, removed `tagIds` +- MOD: Household items — `tags[]` → `area`, removed `room`, removed `tagIds` +- MOD: Vendors — `specialty` → `trade: {id, name, color} | null`, added `tradeId` filter +- MOD: Timeline — work items include `area` and `assignedVendor` instead of `tags` +- MOD: CalDAV feeds — vendor TITLE uses trade name instead of specialty +- New error codes: `AREA_IN_USE`, `TRADE_IN_USE` + +### Key Design Decisions + +- D1: Area hierarchy via self-referencing parent_id (arbitrary depth) +- D2: Area cardinality M:1 (one area per item, nullable) +- D3: Trade transitivity via JOINs (work_item -> vendor -> trade) +- D4: Assignment mutually exclusive (user XOR vendor) via CHECK +- D5: Room field dropped without data migration +- D6: Specialty → Trade migration not automated (users reassign manually) +- D7: Category default changes conditional (only delete unused) + +### Stories + +#1030 Migration + Shared Types, #1031 Areas Backend, #1032 Trades Backend + Vendor Update, +#1033 Work Item Rework, #1034 HI Rework, #1035 Frontend Manage Page, #1037 Frontend Entity Integration diff --git a/.claude/agent-memory/product-owner/epic-17-planning.md b/.claude/agent-memory/product-owner/epic-17-planning.md new file mode 100644 index 000000000..006478e6d --- /dev/null +++ b/.claude/agent-memory/product-owner/epic-17-planning.md @@ -0,0 +1,59 @@ +--- +name: EPIC-17 i18n Planning +description: Planning details for EPIC-17 i18n Support (English + German) — story numbers, dependencies, and key decisions +type: project +--- + +## EPIC-17: i18n Support (English + German) + +**Epic Issue**: #915 +**Priority**: Should Have +**Created**: 2026-03-16 + +### Stories + +| Story | Issue | Title | Priority | Blocked By | +| ----- | ----- | ---------------------------------------------- | ----------- | --------------- | +| 17.1 | #916 | i18n Infrastructure & Shared Components | Must Have | — | +| 17.2 | #917 | Auth & Settings Pages (incl. Language Setting) | Must Have | #916 | +| 17.3 | #918 | Dashboard & Work Items Pages | Must Have | #916 | +| 17.4 | #919 | Household Items & Milestones Pages | Must Have | #916 | +| 17.5 | #920 | Budget Pages | Must Have | #916 | +| 17.6 | #921 | Schedule & Diary Pages | Must Have | #916 | +| 17.7 | #922 | Documents & Remaining Components | Must Have | #916 | +| 17.8 | #923 | Docs Site German Translation | Should Have | — (independent) | +| 17.9 | #924 | E2E Test Updates & Final Validation | Must Have | #916-#922 | + +### Dependency Graph + +- Story 17.1 (#916) is the foundation — blocks Stories 17.2-17.7 +- Story 17.8 (#923) is independent (Docusaurus has its own i18n system) +- Story 17.9 (#924) is blocked by all app stories (17.1-17.7) + +### Key Design Decisions + +- **i18next + react-i18next**: Static JSON imports, no async loading +- **LocaleContext**: Mirrors ThemeContext pattern (localStorage + server sync + system detection) +- **Namespace per domain**: common, errors, auth, settings, dashboard, workItems, householdItems, budget, schedule, diary, documents +- **CURRENCY env var**: Server exposes via GET /api/config, formatCurrency() reads from it, default EUR +- **Formatters**: Read locale from i18n.language, no call signature changes +- **Enum display**: API enum values stay English, only display labels translated +- **Badge variant maps**: Must be refactored to use t() (move inside component or factory pattern) +- **Docs site**: Docusaurus built-in i18n, separate from app i18n + +### Board Status + +All stories set to **Todo** on the Cornerstone Backlog board. + +### Node IDs + +- EPIC-17: `I_kwDORK8WYc7zmbsG` +- Story 17.1: `I_kwDORK8WYc7zmcTo` +- Story 17.2: `I_kwDORK8WYc7zmcjW` +- Story 17.3: `I_kwDORK8WYc7zmcw4` +- Story 17.4: `I_kwDORK8WYc7zmc9T` +- Story 17.5: `I_kwDORK8WYc7zmdNJ` +- Story 17.6: `I_kwDORK8WYc7zmdcu` +- Story 17.7: `I_kwDORK8WYc7zmdqc` +- Story 17.8: `I_kwDORK8WYc7zmd4y` +- Story 17.9: `I_kwDORK8WYc7zmeHS` diff --git a/.claude/agent-memory/qa-integration-tester/MEMORY.md b/.claude/agent-memory/qa-integration-tester/MEMORY.md index d4c63ad61..8aa5830ae 100644 --- a/.claude/agent-memory/qa-integration-tester/MEMORY.md +++ b/.claude/agent-memory/qa-integration-tester/MEMORY.md @@ -3,6 +3,38 @@ > Detailed notes live in topic files. This index links to them. > See: `budget-categories-story-142.md`, `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-358-document-linking.md`, `story-360-document-a11y.md`, `story-epic08-e2e.md`, `story-509-manage-page.md`, `story-471-dashboard.md` +## Story #1146 Backup/Restore Tests (2026-03-22) + +**Test files**: `backupService.test.ts`, `backups.test.ts` (routes), `backupsApi.test.ts`, `BackupsPage.test.tsx`. + +**Key patterns**: + +- **BACKUP_DIR must be outside app data dir**: config.ts validates `BACKUP_DIR` is not the same as or a subdirectory of `dirname(DATABASE_URL)`. In route tests: use TWO separate `mkdtempSync` calls — one for the DB (`tempDir`) and one for backups (`backupTempDir`). Using `join(tempDir, 'backups')` as `BACKUP_DIR` fails with config validation error. +- **AppError has `.code` property, not the code in `.message`**: `BackupNotFoundError.message = 'Backup not found: filename'`, `BackupNotFoundError.code = 'BACKUP_NOT_FOUND'`. Use `rejects.toMatchObject({ code: 'BACKUP_NOT_FOUND' })`, NOT `stringContaining('BACKUP_NOT_FOUND')` on `.message`. +- **`createError` state in BackupsPage not rendered**: `createError` state is set in `handleCreateBackup()` catch block but never displayed in JSX. Bug filed as #1164. Test for re-enabled button state instead of `role="alert"`. +- **Delete modal opens showing filename in two places**: after clicking Delete, filename appears in both table `` and modal ``. Use `getAllByText(filename)` not `getByText`. +- **Config snapshot tests break on new AppConfig fields**: The 4 `toEqual` snapshot tests in `config.test.ts` fail whenever new fields are added to `AppConfig`. Backup feature added `backupDir/backupCadence/backupRetention/backupEnabled`. Add all 4 to each of the 4 full snapshot tests. +- **German translations left uncommitted**: translator agent generated German translations but they were uncommitted in working tree, causing `errorTranslation.test.ts` failures (empty string translations). Commit them with translator co-author trailer. + +## useSearchParams + Debounce Testing Anti-Patterns (2026-03-21) + +**Context**: Testing `useTableState` hook — combines `useSearchParams` (MemoryRouter) with debounced state updates. + +**Key patterns — what FAILS:** + +- `jest.useFakeTimers()` + `waitFor()` — `waitFor` uses real `setInterval` for polling; blocked by fake timers +- `jest.useFakeTimers()` + `await act(async () => { await jest.runAllTimersAsync(); })` — still fails because the URL sync `useEffect` in `useTableState` has `searchInput` in its dep array; it fires after `setSearch()` and reads stale `searchParams` (no `q` yet), resetting `searchInput` back to `''` +- Testing `searchInput` immediately after `setSearch()` — the URL sync effect fires synchronously and overwrites it + +**Root cause**: `useTableState`'s URL sync effect (`useEffect(fn, [searchParams, ..., searchInput])`) runs whenever `searchInput` changes. After `setSearch('hello')`, `searchInput='hello'` triggers the effect, which reads `searchParams.get('q')=''` and calls `setSearchInput('')`, undoing the update before the debounce timer fires. + +**What WORKS:** + +- Test URL-initialized state: `makeWrapper(['/?q=hello'])` → assert `tableState.search === 'hello'` +- Test the "not yet fired" side: `jest.useFakeTimers()` + `advanceTimersByTime(299)` + assert `tableState.search === ''` +- Test URL params combos: `makeWrapper(['/?q=myquery&page=3'])` — reliable synchronous assertions +- `user.type()` on controlled inputs without state propagation → use `fireEvent.change(input, { target: { value: 'hello' } })` instead + ## Story #1035 ManagePage Rewrite — Areas + Trades Tabs (2026-03-19) **File rewritten**: `client/src/pages/ManagePage/ManagePage.test.tsx` diff --git a/.claude/agent-memory/qa-integration-tester/drag-drop-jsdom-patterns.md b/.claude/agent-memory/qa-integration-tester/drag-drop-jsdom-patterns.md new file mode 100644 index 000000000..b1d34177b --- /dev/null +++ b/.claude/agent-memory/qa-integration-tester/drag-drop-jsdom-patterns.md @@ -0,0 +1,29 @@ +--- +name: drag-drop-jsdom-patterns +description: Patterns and anti-patterns for testing drag-and-drop logic in React components under jsdom +type: feedback +--- + +## getBoundingClientRect in Drag-Over Handlers + +**Rule**: In jsdom, `getBoundingClientRect()` always returns all zeros. Mocking `Element.prototype.getBoundingClientRect` or `HTMLElement.prototype.getBoundingClientRect` does NOT intercept calls made through React's delegated event system (React 19 uses event delegation from the root, and `e.currentTarget` in the handler does not resolve through the normal prototype chain lookup — it bypasses both `Element.prototype` and `HTMLElement.prototype` mocks). + +**Why**: The call in `(e.currentTarget as HTMLElement).getBoundingClientRect()` during a React 19 synthetic event handler always returns zeros regardless of prototype mocks. Neither `jest.spyOn(element, 'getBoundingClientRect')` (instance mock) nor `Element.prototype.getBoundingClientRect = ...` (prototype mock) are intercepted. + +**How to apply**: + +- Do NOT write tests that assert "above vs below" drop position based on `clientY` relative to `getBoundingClientRect()` output — they will always resolve to "below" (since `clientY > 0 >= 0 + 0/2`). +- Instead, assert that SOME drop indicator class is applied (use `.toMatch(/columnCheckboxItemDrop(Above|Below)/)`). +- For the "below" direction specifically: any `clientY > 0` will always produce "below" — this IS testable. +- For the "above" direction: not testable in jsdom unit tests. Cover via E2E tests instead. +- For dragLeave clearing: test that both `Above` and `Below` classes are absent after `fireEvent.dragLeave(element)` — this IS reliable. + +## Auto-focus with setTimeout in jsdom + +**Rule**: `jest.useFakeTimers()` + `act(() => { jest.runAllTimers(); })` works for testing `setTimeout(() => element.focus(), 0)` in React event handlers. + +**How to apply**: + +- Use synchronous `act(() => { jest.runAllTimers(); })` (not async) — `runAllTimers` inside a synchronous `act` correctly flushes the timer and React state updates. +- Always restore real timers with `jest.useRealTimers()` in cleanup (or in the test body after assertions). +- `document.activeElement` correctly reflects `element.focus()` called inside a flushed `setTimeout`. diff --git a/.claude/agent-memory/qa-integration-tester/story-1143-translation-keys.md b/.claude/agent-memory/qa-integration-tester/story-1143-translation-keys.md new file mode 100644 index 000000000..e4b00b29e --- /dev/null +++ b/.claude/agent-memory/qa-integration-tester/story-1143-translation-keys.md @@ -0,0 +1,35 @@ +--- +name: Story #1143 — translationKey field testing patterns +description: Patterns for testing the translationKey field added to trades, budgetCategories, householdItemCategories +type: project +--- + +## Story #1143 translationKey Testing (2026-03-22) + +**Migration 0030**: Adds nullable `translation_key` column to `trades`, `budget_categories`, `household_item_categories`. Predefined rows get keys (e.g. `trades.plumbing`); user-created rows always get `null`. + +**Test files created/modified**: + +- `client/src/lib/categoryUtils.test.ts` (NEW) — unit tests for `getCategoryDisplayName` + i18n key coverage +- `server/src/services/tradeService.test.ts` — added `translationKey field` describe block +- `server/src/services/budgetCategoryService.test.ts` — added `translationKey field` describe block +- `server/src/services/householdItemCategoryService.test.ts` — added `translationKey field` describe block +- `server/src/routes/trades.test.ts` — added route-level `translationKey` tests +- `server/src/routes/budgetCategories.test.ts` — added route-level `translationKey` tests +- `server/src/routes/householdItemCategories.test.ts` — added route-level `translationKey` tests + +**Key patterns**: + +- `getCategoryDisplayName(t, name, translationKey)`: when key is non-null, calls `t(key, { defaultValue: name })`; when null/empty string (falsy), returns name without calling t() +- Empty string `translationKey` is falsy in JS — treated same as null in the implementation +- Seeded trades table is cleared in `beforeEach` in route tests (`app.db.delete(trades).run()`). To test predefined translationKey in route tests, re-insert the row manually with the `translationKey` set. +- Service tests for seeded categories (budget/HI): these tables are NOT cleared in beforeEach, so predefined rows are available directly after `runMigrations()`. +- `createTrade()`, `createBudgetCategory()`, `createHouseholdItemCategory()` always write `translationKey: null` — verified by unit test. +- i18n coverage tests: import JSON directly (`import en from '../i18n/en/settings.json'`), no assert { type: 'json' } needed (resolveJsonModule: true in client tsconfig). +- Key parity test pattern: `expect(Object.keys(de.section).sort()).toEqual(Object.keys(en.section).sort())` + +**Seeded predefined IDs verified**: + +- Trades (15): trade-plumbing, trade-hvac, trade-electrical, trade-drywall, trade-carpentry, trade-masonry, trade-painting, trade-roofing, trade-flooring, trade-tiling, trade-landscaping, trade-excavation, trade-general-contractor, trade-architect-design, trade-other +- BudgetCategories (7 surviving on fresh DB): bc-materials, bc-labor, bc-permits, bc-design, bc-household-items, bc-waste, bc-other +- HI Categories (7 surviving on fresh DB): hic-furniture, hic-appliances, hic-fixtures, hic-decor, hic-electronics, hic-equipment, hic-other diff --git a/.claude/agent-memory/security-engineer/MEMORY.md b/.claude/agent-memory/security-engineer/MEMORY.md index 33ca93c36..d12bd172a 100644 --- a/.claude/agent-memory/security-engineer/MEMORY.md +++ b/.claude/agent-memory/security-engineer/MEMORY.md @@ -91,6 +91,7 @@ See `review-history.md` for detailed findings per PR. | #936 | #933 CalDAV/CardDAV server with DAV token auth and vendor contacts | COMMENTED (1 low: WWW-Authenticate; 3 informational) | 2026-03-17 | | #1047 | EPIC-18 Story #1030 — Migration + Shared Types (Areas & Trades foundation) | COMMENTED (2 informational: XOR CHECK deferred, UNIQUE(name,parent_id) NULL gap) | 2026-03-19 | | #1054 | EPIC-18 Stories #1031+#1032 — Areas CRUD, Trades CRUD, Vendor trade linking | COMMENTED (3 informational: search no maxLength, parentId no minLength:1, sql.raw safe) | 2026-03-19 | +| #1150 | EPIC-19 Story #1146 — Backup & Restore | COMMENTED (3 informational: restore mid-op orphan dir, filename in 404 msg, node-cron 3.x not 4.x) | 2026-03-22 | ## Known Open Recommendations (Low Priority) @@ -163,3 +164,5 @@ These have been noted in previous reviews. **GitHub Issue #315** tracks items 1- - **EPIC-18 tags fully removed (PR #1047)**: Migration 0028 drops `tags`, `work_item_tags`, `household_item_tags` tables atomically. `toTagResponse`/`validateTagIds` removed from shared converters/validators. Tag route removed from app.ts. Attack surface reduced. - **EPIC-18 specialty→trade migration (PR #1047)**: Deduplicates via `SELECT DISTINCT trim(specialty)`, creates custom trades with `randomblob(4)` IDs. Falls back to `trade-other` for NULL/empty specialty values. Vendors table recreated (pattern from migration 0026). - **EPIC-18 room column dropped (PR #1047)**: `ALTER TABLE household_items DROP COLUMN room` — no data migration (D5 intentional). room index dropped first. New `area_id` FK column added with ON DELETE SET NULL. +- **EPIC-19 backup filename validation (PR #1150)**: `validateBackupFilename()` enforces strict regex `^cornerstone-backup-\d{4}-\d{2}-\d{2}T\d{6}Z\.tar\.gz$` plus explicit `/` and `\` rejection. `execFile` (not `exec`) used for tar — no shell injection. BACKUP_DIR isolation enforced at startup via `startsWith(resolvedDataDir + path.sep)` in config.ts. `node-cron` 3.0.3 — operator-controlled cron expression, no user input reaches it. `operationInProgress` singleton guard prevents concurrent operations. `process.exit(0)` on restore is deliberate Docker restart pattern. +- **Wiki virtiofs workaround**: Token URL for push is in main repo at `.git/worktrees//modules/wiki/config` (not `wiki/.git/config` — that's a pointer file). Use `git clone .git/worktrees//modules/wiki /tmp/wiki-tmp` then set identity + remote URL there. diff --git a/.claude/agent-memory/translator/MEMORY.md b/.claude/agent-memory/translator/MEMORY.md index 95cde51ac..118246479 100644 --- a/.claude/agent-memory/translator/MEMORY.md +++ b/.claude/agent-memory/translator/MEMORY.md @@ -40,8 +40,19 @@ Action labels in German follow the pattern: `{Noun} {Verb}` with capitalised fir - `de/budget.json` was missing `overview.actions` entirely at initial rollout — added 2026-03-19 - `de/common.json` was missing `aria.noArea`, `aria.noTrade`, `aria.selectArea`, `aria.selectTrade`, `aria.selectAssignment`, `aria.unassigned`, and `assignmentPicker.*` — added 2026-03-19 (Story #1035) - `de/settings.json` had `manage.tags` which was replaced by `manage.areas` + `manage.trades` in Story #1035 +- `de/common.json` was missing `subnav.settings.backups` — added 2026-03-22 (Issue #1146) +- `de/settings.json` was missing `backups` section entirely — added 2026-03-22 (Issue #1146) +- `de/errors.json` had four backup/restore keys with empty placeholder values (left by frontend-developer) — filled in 2026-03-22 (Issue #1146) - Always check key parity when picking up a new translator spec +## Backup/Restore Terminology (2026-03-22) + +- "Backup" → "Sicherung" (noun, e.g. "Sicherung erstellen", "Sicherungen") +- "Restore" / "Restore operation" → "Wiederherstellung" / "Wiederherstellungsoperation" +- "Backup & Restore" (page title) → "Sicherung & Wiederherstellung" +- "Restore & Restart" (button) → "Wiederherstellen & Neu starten" +- Frontend-developer may leave empty placeholder values in error keys when adding new error codes — the translator must fill these in. + ## Initial Cleanup (2026-03-17) Fixed terminology inconsistencies from EPIC-17 i18n rollout: diff --git a/.claude/agent-memory/ux-designer/MEMORY.md b/.claude/agent-memory/ux-designer/MEMORY.md index 9471c91a9..79ae97225 100644 --- a/.claude/agent-memory/ux-designer/MEMORY.md +++ b/.claude/agent-memory/ux-designer/MEMORY.md @@ -197,18 +197,24 @@ HI Gantt: amber circle marker (r=7px). Add Dep modal: 36rem wide. `role="listbox"` requires arrow-key nav — use `role="list"` + `role="button"` items instead. `--color-primary-text` on `--color-primary-bg` chip = contrast failure; use `var(--color-primary)` for text. -## PR #936 Review Findings — DAV Access Card + Vendor Contacts - -Recurring misses to watch for in settings-card and sub-entity-list PRs: - -- Both CSS modules used hardcoded literals for ALL spacing/font/radius/transition — zero token usage -- `--color-danger-active` misused as text color; correct token is `--color-danger-text-on-light` or `--color-danger` -- `--color-border` as hover bg (recurring from PR #398) — always use `var(--color-bg-hover)` -- Edit/Delete action buttons missing `aria-label` with entity name ("Edit contact {name}") — screen reader blocker -- Missing `aria-live` announcement region for CRUD mutations -- Missing `prefers-reduced-motion` guard in both CSS modules -- Token reveal panel missing `role="status" aria-atomic="true"` -- `copyButton` (primary surface) used `--shadow-focus-subtle` — primary buttons need `--shadow-focus` +## DataTable Core (Issue #1099) + +See `datatable-spec.md` for full token map. Key decisions: + +- FilterPopover uses `position: fixed` + `getBoundingClientRect()` — avoids `overflow-x: auto` clipping +- Sort indicator: none=`var(--color-text-placeholder)`, active=`var(--color-primary)`; `aria-sort` on `` +- Active filter icon: `color: var(--color-primary)` + `background: var(--color-primary-bg)` (no new tokens) +- Pagination min touch target: `min-width: 44px; min-height: 44px` +- Mobile (< 768px): cards layout, no column visibility toggle, simplified pagination (Prev/Next only) +- All strings under `common:dataTable.*` namespace +- No new design tokens needed + +## DataTable Bug Fix Specs (#1135–#1140) + +- Date range (#1135): `.filterDateInputConfirmed` = `border: var(--color-primary); bg: var(--color-primary-bg)`; range bridge pill uses `--color-primary-bg` + `--color-primary-badge-text`; auto-focus advance to "to" input +- Toolbar height (#1136): three controls (search, reset, col-settings) all set to `height: 36px; box-sizing: border-box`; column icon = 3-bar vertical SVG (16×16), NOT gear emoji; `min-height: 44px` only on mobile +- Number filter (#1139): existing `NumberFilter.tsx` visual is correct; fix is behavioral (`numberMin/Max/Step` props); compare mode uses existing `.filterSegmentedControl` classes +- Drag indicator (#1140): replace full-row `--color-primary` bg with `::before` pseudo-element insertion line (2px, `--color-primary`, `--radius-full`); add `e.dataTransfer.effectAllowed = 'move'` on dragstart; drag handle needs `tabIndex={0}` + arrow-key keyboard reorder for a11y ## Story 4.11 — HI Detail Inline Edit (Issue #467) diff --git a/.claude/agent-memory/ux-designer/datatable-spec.md b/.claude/agent-memory/ux-designer/datatable-spec.md new file mode 100644 index 000000000..bf5ae663e --- /dev/null +++ b/.claude/agent-memory/ux-designer/datatable-spec.md @@ -0,0 +1,79 @@ +--- +name: DataTable Core Spec (Issue #1099) +description: Visual spec decisions for the shared DataTable component and all sub-components +type: project +--- + +# DataTable Core — Visual Spec Summary + +Spec posted at: https://github.com/steilerDev/cornerstone/issues/1099#issuecomment-4101514019 + +## Key Design Decisions + +### Table Container + +- `background: var(--color-bg-primary); border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-md); overflow-x: auto` +- `overflow-x: auto` (not hidden) — wide tables scroll, not clip + +### Table Header (thead) + +- `background-color: var(--color-bg-secondary); border-bottom: 1px solid var(--color-border)` +- `th`: `padding: var(--spacing-3) var(--spacing-4)`, `font-size: var(--font-size-xs)`, `font-weight: var(--font-weight-semibold)`, `color: var(--color-text-muted)`, uppercase, `letter-spacing: 0.05em` +- Sortable headers: `
-
Loading...
}> diff --git a/client/src/components/Badge/Badge.module.css b/client/src/components/Badge/Badge.module.css index 56edfad74..abd6f5ce3 100644 --- a/client/src/components/Badge/Badge.module.css +++ b/client/src/components/Badge/Badge.module.css @@ -86,6 +86,62 @@ color: var(--color-diary-severity-critical-text); } +/* Milestone status variants */ +.milestoneCompleted { + background-color: var(--color-status-completed-bg); + color: var(--color-status-completed-text); +} + +.milestonePending { + background-color: var(--color-status-not-started-bg); + color: var(--color-status-not-started-text); +} + +/* Invoice status variants */ +.pending { + background-color: var(--color-status-not-started-bg); + color: var(--color-status-not-started-text); +} + +.paid { + background-color: var(--color-status-completed-bg); + color: var(--color-status-completed-text); +} + +.claimed { + background-color: var(--color-status-in-progress-bg); + color: var(--color-status-in-progress-text); +} + +.quotation { + background-color: var(--color-status-blocked-bg); + color: var(--color-status-blocked-text); +} + +/* Role badge: Admin */ +.roleAdmin { + background-color: var(--color-role-admin-bg); + color: var(--color-role-admin-text); +} + +/* Role badge: Member */ +.roleMember { + background-color: var(--color-role-member-bg); + color: var(--color-role-member-text); +} + +/* User status badge: Active */ +.userActive { + background-color: var(--color-user-active-bg); + color: var(--color-user-active-text); +} + +/* User status badge: Deactivated/Inactive */ +.userDeactivated { + background-color: var(--color-user-inactive-bg); + color: var(--color-user-inactive-text); +} + /* Responsive */ @media (max-width: 767px) { .badge { diff --git a/client/src/components/BudgetSubNav/BudgetSubNav.tsx b/client/src/components/BudgetSubNav/BudgetSubNav.tsx deleted file mode 100644 index 8d7933f6c..000000000 --- a/client/src/components/BudgetSubNav/BudgetSubNav.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { NavLink } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import styles from './BudgetSubNav.module.css'; - -const BUDGET_TABS = [ - { labelKey: 'subnav.budget.overview', to: '/budget/overview' }, - { labelKey: 'subnav.budget.invoices', to: '/budget/invoices' }, - { labelKey: 'subnav.budget.vendors', to: '/budget/vendors' }, - { labelKey: 'subnav.budget.sources', to: '/budget/sources' }, - { labelKey: 'subnav.budget.subsidies', to: '/budget/subsidies' }, -] as const; - -/** - * BudgetSubNav — horizontal tab-style navigation for the Budget section. - * - * Renders a scrollable row of tab links for all budget sub-pages. - * The currently active tab is highlighted using the primary design token. - * On mobile the row scrolls horizontally so all tabs remain reachable. - */ -export function BudgetSubNav() { - const { t } = useTranslation('common'); - - return ( - - ); -} - -export default BudgetSubNav; diff --git a/client/src/components/BudgetSummaryCard/BudgetSummaryCard.test.tsx b/client/src/components/BudgetSummaryCard/BudgetSummaryCard.test.tsx index 4dd68bedf..6570545f8 100644 --- a/client/src/components/BudgetSummaryCard/BudgetSummaryCard.test.tsx +++ b/client/src/components/BudgetSummaryCard/BudgetSummaryCard.test.tsx @@ -108,13 +108,17 @@ describe('BudgetSummaryCard', () => { // ── Test 1: Remaining Budget ────────────────────────────────────────────── - it('renders remaining budget formatted as EUR currency', () => { + it('renders remaining budget as the average of remainingVsMinPlanned and remainingVsMaxPlanned', () => { + // mediumNetRemaining = (remainingVsMinPlanned + remainingVsMaxPlanned) / 2 + // = (20000 + 10000) / 2 = 15000 renderWithRouter( - , + , ); const el = screen.getByTestId('remaining-budget'); - expect(el).toHaveTextContent('€50,000.00'); + expect(el).toHaveTextContent('€15,000.00'); }); // ── Test 2: Planned Cost Range ──────────────────────────────────────────── diff --git a/client/src/components/BudgetSummaryCard/BudgetSummaryCard.tsx b/client/src/components/BudgetSummaryCard/BudgetSummaryCard.tsx index 69aadc848..05c58ea77 100644 --- a/client/src/components/BudgetSummaryCard/BudgetSummaryCard.tsx +++ b/client/src/components/BudgetSummaryCard/BudgetSummaryCard.tsx @@ -19,11 +19,14 @@ export function BudgetSummaryCard({ overview }: BudgetSummaryCardProps) { minPlanned, maxPlanned, actualCost, - remainingVsActualCost, + remainingVsMinPlanned, remainingVsMaxPlanned, subsidySummary, } = overview; + // Medium net remaining: average of min and max remaining + const mediumNetRemaining = (remainingVsMinPlanned + remainingVsMaxPlanned) / 2; + // Build budget bar segments const segments: BudgetBarSegment[] = [ { @@ -35,16 +38,16 @@ export function BudgetSummaryCard({ overview }: BudgetSummaryCardProps) { ]; // Add remaining segment or overflow - if (remainingVsActualCost >= 0) { + if (mediumNetRemaining >= 0) { segments.push({ key: 'remaining', - value: remainingVsActualCost, + value: mediumNetRemaining, color: 'var(--color-budget-track)', label: t('cards.budgetSummary.remainingBudget'), }); } - const overflow = remainingVsActualCost < 0 ? Math.abs(remainingVsActualCost) : 0; + const overflow = mediumNetRemaining < 0 ? Math.abs(mediumNetRemaining) : 0; return (
@@ -52,7 +55,7 @@ export function BudgetSummaryCard({ overview }: BudgetSummaryCardProps) {
{t('cards.budgetSummary.remainingBudget')} - {formatCurrency(remainingVsActualCost)} + {formatCurrency(mediumNetRemaining)}
diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx index 518b1e745..fa78d811e 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx @@ -338,6 +338,7 @@ function buildBreakdownWithWI( categoryId, categoryName, categoryColor: null, + categoryTranslationKey: null, projectedMin, projectedMax, actualCost, @@ -444,6 +445,8 @@ function buildBreakdownWithHI( categories: [ { hiCategory, + categoryName: hiCategory, + categoryTranslationKey: null, projectedMin, projectedMax, actualCost, @@ -841,6 +844,7 @@ describe('CostBreakdownTable', () => { categoryId: catId, categoryName: 'Materials', categoryColor: null, + categoryTranslationKey: null, projectedMin: 800, projectedMax: 1200, actualCost: 0, @@ -854,6 +858,7 @@ describe('CostBreakdownTable', () => { categoryId: 'cat-labor', categoryName: 'Labor', categoryColor: null, + categoryTranslationKey: null, projectedMin: 1000, projectedMax: 1500, actualCost: 0, @@ -913,6 +918,7 @@ describe('CostBreakdownTable', () => { categoryId: 'cat-a', categoryName: 'CategoryA', categoryColor: null, + categoryTranslationKey: null, projectedMin: 100, projectedMax: 200, actualCost: 0, @@ -926,6 +932,7 @@ describe('CostBreakdownTable', () => { categoryId: 'cat-b', categoryName: 'CategoryB', categoryColor: null, + categoryTranslationKey: null, projectedMin: 300, projectedMax: 400, actualCost: 0, @@ -986,6 +993,7 @@ describe('CostBreakdownTable', () => { categoryId: 'cat-x', categoryName: 'CategoryX', categoryColor: null, + categoryTranslationKey: null, projectedMin: 100, projectedMax: 200, actualCost: 0, @@ -1010,6 +1018,8 @@ describe('CostBreakdownTable', () => { categories: [ { hiCategory: 'Living Room', + categoryName: 'Living Room', + categoryTranslationKey: null, projectedMin: 300, projectedMax: 500, actualCost: 0, @@ -1314,6 +1324,7 @@ describe('CostBreakdownTable', () => { categoryId: 'cat-1', categoryName: 'Materials', categoryColor: null, + categoryTranslationKey: null, projectedMin: 500, projectedMax: 700, actualCost: 0, @@ -1338,6 +1349,8 @@ describe('CostBreakdownTable', () => { categories: [ { hiCategory: 'Living Room', + categoryName: 'Living Room', + categoryTranslationKey: null, projectedMin: 200, projectedMax: 300, actualCost: 0, @@ -2566,6 +2579,7 @@ describe('Bug #586 — item expand state is independent per category', () => { rawProjectedMax: 700, minSubsidyPayback: 0, categoryColor: null as null, + categoryTranslationKey: null as null, }; return { @@ -2646,6 +2660,7 @@ describe('Bug #586 — item expand state is independent per category', () => { rawProjectedMin: 300, rawProjectedMax: 500, minSubsidyPayback: 0, + categoryTranslationKey: null as null, }; return { @@ -2666,11 +2681,13 @@ describe('Bug #586 — item expand state is independent per category', () => { { ...hiCategoryBase, hiCategory: 'Furniture', + categoryName: 'Furniture', items: [{ ...sharedHIItem }], }, { ...hiCategoryBase, hiCategory: 'Appliances', + categoryName: 'Appliances', items: [{ ...sharedHIItem }], }, ], diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx index 29aeceb3a..abad9988b 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx @@ -15,6 +15,7 @@ import type { } from '@cornerstone/shared'; import { CONFIDENCE_MARGINS } from '@cornerstone/shared'; import { useFormatters } from '../../lib/formatters.js'; +import { getCategoryDisplayName } from '../../lib/categoryUtils.js'; import styles from './CostBreakdownTable.module.css'; // Context to pass formatCurrency down to sub-components that aren't React components (can't use hooks) @@ -311,6 +312,7 @@ function WorkItemCategorySection({ onToggle: (key: string) => void; perspective: CostPerspective; }) { + const { t: tSettings } = useTranslation('settings'); const formatCurrencyFn = useFormatterContext(); const key = `wi-cat-${category.categoryId ?? 'null'}`; const isExpanded = expandedKeys.has(key); @@ -324,6 +326,11 @@ function WorkItemCategorySection({ category.subsidyPayback, perspective, ); + const displayName = getCategoryDisplayName( + tSettings, + category.categoryName, + category.categoryTranslationKey, + ); return ( <> @@ -334,12 +341,12 @@ function WorkItemCategorySection({ type="button" className={styles.expandBtn} aria-expanded={isExpanded} - aria-label={`Expand ${category.categoryName}`} + aria-label={`Expand ${displayName}`} onClick={() => onToggle(key)} > - {category.categoryName} + {displayName}
-{formatCurrencyFn(resolvedRawCost)} @@ -473,6 +480,7 @@ function HouseholdItemCategorySection({ onToggle: (key: string) => void; perspective: CostPerspective; }) { + const { t: tSettings } = useTranslation('settings'); const formatCurrencyFn = useFormatterContext(); const key = `hi-cat-${category.hiCategory}`; const isExpanded = expandedKeys.has(key); @@ -486,6 +494,11 @@ function HouseholdItemCategorySection({ category.subsidyPayback, perspective, ); + const displayName = getCategoryDisplayName( + tSettings, + category.categoryName, + category.categoryTranslationKey, + ); return ( <> @@ -496,12 +509,12 @@ function HouseholdItemCategorySection({ type="button" className={styles.expandBtn} aria-expanded={isExpanded} - aria-label={`Expand ${category.hiCategory}`} + aria-label={`Expand ${displayName}`} onClick={() => onToggle(key)} > - {category.hiCategory} + {displayName} -{formatCurrencyFn(resolvedRawCost)} diff --git a/client/src/components/DataTable/DataTable.module.css b/client/src/components/DataTable/DataTable.module.css new file mode 100644 index 000000000..67a9154c7 --- /dev/null +++ b/client/src/components/DataTable/DataTable.module.css @@ -0,0 +1,710 @@ +/* DataTable Container */ +.dataTableContainer { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +/* Toolbar */ +.toolbar { + display: flex; + flex-direction: column; + gap: var(--spacing-3); + padding: var(--spacing-4); + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.toolbarRow { + display: flex; + gap: var(--spacing-3); + align-items: center; + flex-wrap: wrap; +} + +.searchBox { + flex: 1; + min-width: 200px; +} + +.searchInput { + width: 100%; + height: 36px; + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + background-color: var(--color-bg-primary); + transition: var(--transition-input); + box-sizing: border-box; +} + +.searchInput:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: var(--shadow-focus-subtle); +} + +.toolbarButtons { + display: flex; + gap: var(--spacing-2); + align-items: center; +} + +.resetButton { + height: 36px; + padding: 0 var(--spacing-3); + background-color: var(--color-bg-tertiary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: background-color var(--transition-normal); + box-sizing: border-box; +} + +.resetButton:hover:not(:disabled) { + background-color: var(--color-bg-hover); +} + +.resetButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +/* Column Settings Button */ +.columnSettingsButton { + height: 36px; + width: 36px; + padding: 0; + background-color: var(--color-bg-tertiary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + cursor: pointer; + transition: + background-color var(--transition-normal), + color var(--transition-normal); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.columnSettingsButton:hover:not(:disabled) { + background-color: var(--color-bg-hover); +} + +.columnSettingsButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.columnSettingsPopover { + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + z-index: var(--z-modal); +} + +.columnSettingsContent { + padding: var(--spacing-3); + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.columnSettingsTitle { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0; +} + +.columnCheckboxGroup { + display: flex; + flex-direction: column; + gap: var(--spacing-1-5); + max-height: 300px; + overflow-y: auto; +} + +.columnCheckboxItem { + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-1) var(--spacing-2); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color var(--transition-normal); +} + +.columnCheckboxItem:hover { + background-color: var(--color-bg-secondary); +} + +.columnCheckbox { + width: 18px; + height: 18px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + cursor: pointer; + accent-color: var(--color-primary); +} + +.columnCheckbox:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.columnCheckboxLabel { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + flex: 1; + cursor: pointer; +} + +.columnSettingsResetButton { + padding: var(--spacing-1-5) var(--spacing-3); + background-color: var(--color-bg-tertiary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: background-color var(--transition-normal); + width: 100%; +} + +.columnSettingsResetButton:hover:not(:disabled) { + background-color: var(--color-bg-hover); +} + +.columnSettingsResetButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.columnDragHandle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + background-color: transparent; + color: var(--color-text-muted); + border: none; + border-radius: var(--radius-sm); + cursor: grab; + font-size: var(--font-size-sm); + transition: background-color var(--transition-normal); +} + +.columnDragHandle:hover { + background-color: var(--color-bg-tertiary); + color: var(--color-text-secondary); +} + +.columnDragHandle:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.columnDragHandle:active { + cursor: grabbing; +} + +.columnCheckboxItemDragging { + opacity: 0.5; + background-color: var(--color-bg-secondary); +} + +.columnCheckboxItemDropAbove { + position: relative; +} + +.columnCheckboxItemDropAbove::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background-color: var(--color-primary); + border-radius: var(--radius-sm); +} + +.columnCheckboxItemDropBelow { + position: relative; +} + +.columnCheckboxItemDropBelow::before { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background-color: var(--color-primary); + border-radius: var(--radius-sm); +} + +@media (prefers-reduced-motion: reduce) { + .columnDragHandle, + .columnCheckboxItem { + transition: none; + } +} + +/* Filter Popover */ +.filterPopover { + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + z-index: calc(var(--z-modal) + 1); +} + +/* Table Container */ +.tableContainer { + display: block; + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + overflow-x: auto; +} + +/* Table */ +.table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-size-sm); +} + +.table thead { + background-color: var(--color-bg-secondary); + border-bottom: 1px solid var(--color-border); +} + +.tableHeader { + padding: var(--spacing-3) var(--spacing-4); + text-align: left; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + position: relative; +} + +.tableHeaderContent { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.tableHeaderLabel { + flex: 1; +} + +.tableHeaderSortable { + cursor: pointer; + user-select: none; + transition: color var(--transition-normal); +} + +.tableHeaderSortable:hover { + color: var(--color-primary); +} + +.tableHeaderFilterButton { + padding: var(--spacing-1) var(--spacing-1-5); + background-color: transparent; + color: var(--color-text-muted); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + cursor: pointer; + transition: + background-color var(--transition-normal), + color var(--transition-normal), + border-color var(--transition-normal); + flex-shrink: 0; +} + +.tableHeaderFilterButton:hover { + background-color: var(--color-bg-tertiary); + color: var(--color-text-primary); +} + +.tableHeaderFilterButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.tableHeaderFilterButton svg { + display: block; + width: 12px; + height: 12px; +} + +.tableHeaderFilterButtonActive { + background-color: var(--color-primary-bg); + color: var(--color-primary); + border-color: var(--color-primary); +} + +/* Table Rows */ +.table tbody tr { + border-bottom: 1px solid var(--color-border); + transition: background-color var(--transition-normal); +} + +.table tbody tr:last-child { + border-bottom: none; +} + +.tableRow { + cursor: pointer; +} + +.tableRow:hover { + background-color: var(--color-bg-secondary); +} + +.tableRow:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.tableRowSelected { + background-color: var(--color-primary-bg); +} + +.tableRowSelected:hover { + background-color: var(--color-primary-bg-hover); +} + +/* Table Cells */ +.table td { + padding: var(--spacing-3) var(--spacing-4); + color: var(--color-text-secondary); + vertical-align: middle; +} + +.tableCell { + word-break: break-word; +} + +.tableActionsCell { + text-align: center; + padding: var(--spacing-2) var(--spacing-3); +} + +/* Cards (Mobile) */ +.cardsContainer { + display: none; + flex-direction: column; + gap: var(--spacing-4); +} + +.card { + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--spacing-4); + cursor: pointer; + transition: + box-shadow var(--transition-normal), + border-color var(--transition-normal); +} + +.card:hover { + box-shadow: var(--shadow-md); +} + +.card:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--spacing-3); +} + +.cardContent { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.cardRow { + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-2) 0; + border-bottom: 1px solid var(--color-border); +} + +.cardRow:last-child { + border-bottom: none; + padding-bottom: 0; +} + +.cardLabel { + font-weight: var(--font-weight-medium); + color: var(--color-text-muted); + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.025em; + min-width: 100px; + flex-shrink: 0; +} + +.cardValue { + flex: 1; + color: var(--color-text-secondary); + word-break: break-word; +} + +.cardActions { + flex-shrink: 0; +} + +/* Loading State */ +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 300px; + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +/* Error Banner */ +.errorBanner { + background-color: var(--color-danger-bg); + border: 1px solid var(--color-danger-border); + border-radius: var(--radius-lg); + padding: var(--spacing-4); + color: var(--color-danger); + font-size: var(--font-size-sm); + margin-bottom: var(--spacing-4); +} + +/* Empty State */ +.emptyState { + padding: var(--spacing-8); + text-align: center; + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.emptyStateHeading { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-2) 0; +} + +.emptyStateText { + color: var(--color-text-muted); + margin: 0 0 var(--spacing-4) 0; + font-size: var(--font-size-sm); +} + +/* Pagination */ +.pagination { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: var(--spacing-4); + padding-top: var(--spacing-3); + border-top: 1px solid var(--color-border); + gap: var(--spacing-3); +} + +.paginationInfo { + font-size: var(--font-size-xs); + color: var(--color-text-muted); +} + +.paginationControls { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.paginationPages { + display: flex; + gap: var(--spacing-1); +} + +.paginationButton { + padding: var(--spacing-1-5) var(--spacing-2-5); + background-color: var(--color-bg-primary); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + cursor: pointer; + transition: + background-color var(--transition-normal), + color var(--transition-normal); + min-width: 44px; + min-height: 44px; +} + +.paginationButton:hover:not(:disabled) { + background-color: var(--color-bg-tertiary); + border-color: var(--color-text-placeholder); +} + +.paginationButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.paginationButton:disabled { + color: var(--color-text-placeholder); + cursor: not-allowed; +} + +.paginationButtonActive { + background-color: var(--color-primary); + border-color: var(--color-primary); + color: var(--color-primary-text); +} + +.paginationButtonActive:hover { + background-color: var(--color-primary-hover); + border-color: var(--color-primary-hover); +} + +.paginationPageSize { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.pageSizeLabel { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + color: var(--color-text-muted); + white-space: nowrap; +} + +.pageSizeSelect { + padding: var(--spacing-1) var(--spacing-2); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-xs); + color: var(--color-text-primary); + background-color: var(--color-bg-primary); + cursor: pointer; + transition: border-color var(--transition-normal); +} + +.pageSizeSelect:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: var(--shadow-focus-subtle); +} + +/* Responsive Design */ +@media (max-width: 767px) { + .tableContainer { + display: none; + } + + .cardsContainer { + display: flex; + } + + .toolbar { + padding: var(--spacing-3); + gap: var(--spacing-2); + } + + .toolbarRow { + flex-direction: column; + gap: var(--spacing-2); + } + + .searchBox { + min-width: auto; + } + + .toolbarButtons { + width: 100%; + flex-direction: row; + } + + .columnSettingsButton { + display: none; + } + + .pagination { + flex-direction: column; + align-items: stretch; + gap: var(--spacing-2); + } + + .paginationInfo { + text-align: center; + } + + .paginationControls { + flex-direction: column; + width: 100%; + } + + .paginationPages { + justify-content: center; + width: 100%; + } + + .paginationPageSize { + width: 100%; + justify-content: center; + } +} + +@media (min-width: 768px) and (max-width: 1024px) { + .columnSettingsButton { + display: flex; + } +} + +@media (prefers-reduced-motion: reduce) { + .table tbody tr, + .tableHeaderSortable, + .tableHeaderFilterButton, + .card, + .paginationButton, + .resetButton, + .columnSettingsButton, + .pageSizeSelect, + .searchInput { + transition: none; + } +} diff --git a/client/src/components/DataTable/DataTable.test.tsx b/client/src/components/DataTable/DataTable.test.tsx new file mode 100644 index 000000000..bb709f3d0 --- /dev/null +++ b/client/src/components/DataTable/DataTable.test.tsx @@ -0,0 +1,343 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +interface TestItem { + id: string; + title: string; + amount: number; +} + +// Mock useColumnPreferences to return all columns as visible by default +const mockToggleColumn = jest.fn(); +const mockMoveColumn = jest.fn(); +const mockResetToDefaults = jest.fn(); +const mockUseColumnPreferences = jest.fn(); + +jest.unstable_mockModule('../../hooks/useColumnPreferences.js', () => ({ + useColumnPreferences: mockUseColumnPreferences, +})); + +import type * as DataTableModule from './DataTable.js'; + +let DataTable: (typeof DataTableModule)['DataTable']; +type TableState = DataTableModule.TableState; + +const COLUMNS: DataTableModule.ColumnDef[] = [ + { key: 'title', label: 'Title', defaultVisible: true, render: (i) => i.title }, + { key: 'amount', label: 'Amount', defaultVisible: true, render: (i) => String(i.amount) }, + { key: 'id', label: 'ID', defaultVisible: true, render: (i) => i.id }, +]; + +const SAMPLE_ITEMS: TestItem[] = [ + { id: 'item-1', title: 'Alpha Work', amount: 1000 }, + { id: 'item-2', title: 'Beta Work', amount: 2000 }, + { id: 'item-3', title: 'Gamma Work', amount: 3000 }, +]; + +function makeTableState(overrides: Partial = {}): TableState { + return { + search: '', + filters: new Map(), + sortBy: null, + sortDir: null, + page: 1, + pageSize: 25, + ...overrides, + }; +} + +function renderDataTable({ + items = SAMPLE_ITEMS, + totalItems = SAMPLE_ITEMS.length, + totalPages = 1, + currentPage = 1, + isLoading = false, + error = null, + tableState = makeTableState(), + onStateChange = jest.fn(), + onRowClick, + emptyState, +}: { + items?: TestItem[]; + totalItems?: number; + totalPages?: number; + currentPage?: number; + isLoading?: boolean; + error?: string | null; + tableState?: TableState; + onStateChange?: jest.Mock; + onRowClick?: jest.Mock; + emptyState?: { + message: string; + description?: string; + action?: { label: string; onClick: () => void }; + }; +} = {}) { + return render( + + pageKey="test-page" + columns={COLUMNS} + items={items} + totalItems={totalItems} + totalPages={totalPages} + currentPage={currentPage} + isLoading={isLoading} + error={error} + getRowKey={(item) => item.id} + onRowClick={onRowClick} + tableState={tableState} + onStateChange={onStateChange} + emptyState={emptyState} + />, + ); +} + +beforeEach(async () => { + ({ DataTable } = (await import('./DataTable.js')) as typeof DataTableModule); + mockUseColumnPreferences.mockReturnValue({ + visibleColumns: new Set(COLUMNS.map((c) => c.key)), + columnOrder: COLUMNS.map((c) => c.key), + isLoaded: true, + toggleColumn: mockToggleColumn, + moveColumn: mockMoveColumn, + resetToDefaults: mockResetToDefaults, + }); + mockToggleColumn.mockReset(); + mockMoveColumn.mockReset(); + mockResetToDefaults.mockReset(); +}); + +describe('DataTable', () => { + describe('loading state', () => { + it('renders loading indicator when isLoading=true and items=[]', () => { + renderDataTable({ isLoading: true, items: [] }); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + it('does not render table rows when in loading state with empty items', () => { + const { container } = renderDataTable({ isLoading: true, items: [] }); + expect(container.querySelector('tbody')).not.toBeInTheDocument(); + }); + + it('renders table content normally when isLoading=true but items exist', () => { + // When loading but items exist (refresh scenario), show items + const { container } = renderDataTable({ isLoading: true, items: SAMPLE_ITEMS }); + expect(container.querySelector('tbody')).toBeInTheDocument(); + }); + }); + + describe('error state', () => { + it('renders error banner when error prop is non-null', () => { + renderDataTable({ error: 'Failed to load items' }); + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Failed to load items')).toBeInTheDocument(); + }); + + it('does not render error banner when error is null', () => { + renderDataTable({ error: null }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('does not render error banner when error is undefined', () => { + renderDataTable(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('still renders items when error is present', () => { + const { container } = renderDataTable({ error: 'Minor error', items: SAMPLE_ITEMS }); + expect(container.querySelector('tbody')).toBeInTheDocument(); + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(3); + }); + }); + + describe('empty state', () => { + it('renders empty state message when items=[] and not loading', () => { + renderDataTable({ + items: [], + isLoading: false, + emptyState: { message: 'No work items found' }, + }); + expect(screen.getByText('No work items found')).toBeInTheDocument(); + }); + + it('renders default empty message when emptyState not provided', () => { + renderDataTable({ items: [], isLoading: false }); + expect(screen.getByText(/no items found/i)).toBeInTheDocument(); + }); + + it('renders empty state description when provided', () => { + renderDataTable({ + items: [], + emptyState: { + message: 'No results', + description: 'Try adjusting your filters', + }, + }); + expect(screen.getByText('Try adjusting your filters')).toBeInTheDocument(); + }); + + it('renders empty state action button when provided', () => { + const mockAction = jest.fn(); + renderDataTable({ + items: [], + emptyState: { + message: 'No items', + action: { label: 'Add Item', onClick: mockAction }, + }, + }); + expect(screen.getByRole('button', { name: 'Add Item' })).toBeInTheDocument(); + }); + + it('calls emptyState action onClick when action button clicked', async () => { + const user = userEvent.setup(); + const mockAction = jest.fn(); + renderDataTable({ + items: [], + emptyState: { + message: 'No items', + action: { label: 'Add Item', onClick: mockAction }, + }, + }); + await user.click(screen.getByRole('button', { name: 'Add Item' })); + expect(mockAction).toHaveBeenCalledTimes(1); + }); + + it('renders table header even when items are empty', () => { + const { container } = renderDataTable({ items: [] }); + expect(container.querySelector('thead')).toBeInTheDocument(); + }); + + it('does not render table rows when items are empty', () => { + const { container } = renderDataTable({ items: [] }); + expect(container.querySelector('tbody')).toBeInTheDocument(); + expect(container.querySelectorAll('tbody tr')).toHaveLength(0); + }); + }); + + describe('table rows', () => { + it('renders a row for each item in items array', () => { + const { container } = renderDataTable({ items: SAMPLE_ITEMS }); + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(3); + }); + + it('renders cell content for each item', () => { + renderDataTable({ items: SAMPLE_ITEMS }); + expect(screen.getAllByText('Alpha Work').length).toBeGreaterThan(0); + expect(screen.getAllByText('Beta Work').length).toBeGreaterThan(0); + }); + + it('calls onRowClick with the correct item when a row is clicked', async () => { + const user = userEvent.setup(); + const mockOnRowClick = jest.fn(); + const { container } = renderDataTable({ onRowClick: mockOnRowClick }); + const rows = container.querySelectorAll('tbody tr'); + await user.click(rows[0] as HTMLElement); + expect(mockOnRowClick).toHaveBeenCalledWith(SAMPLE_ITEMS[0]); + }); + + it('calls onRowClick with the second item when second row clicked', async () => { + const user = userEvent.setup(); + const mockOnRowClick = jest.fn(); + const { container } = renderDataTable({ onRowClick: mockOnRowClick }); + const rows = container.querySelectorAll('tbody tr'); + await user.click(rows[1] as HTMLElement); + expect(mockOnRowClick).toHaveBeenCalledWith(SAMPLE_ITEMS[1]); + }); + + it('does not throw when onRowClick not provided and row is clicked', async () => { + const user = userEvent.setup(); + const { container } = renderDataTable({ onRowClick: undefined }); + const rows = container.querySelectorAll('tbody tr'); + await expect(user.click(rows[0] as HTMLElement)).resolves.not.toThrow(); + }); + }); + + describe('search toolbar', () => { + it('renders search input', () => { + renderDataTable(); + expect(screen.getByRole('searchbox')).toBeInTheDocument(); + }); + + it('search input has current search value', () => { + renderDataTable({ tableState: makeTableState({ search: 'my search' }) }); + expect(screen.getByRole('searchbox')).toHaveValue('my search'); + }); + + it('calls onStateChange with new search when input changes', () => { + const mockOnStateChange = jest.fn(); + renderDataTable({ onStateChange: mockOnStateChange }); + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'hello' } }); + expect(mockOnStateChange).toHaveBeenCalled(); + const calls = mockOnStateChange.mock.calls as [TableState][]; + const lastCall = calls[calls.length - 1]; + expect(lastCall[0].search).toBe('hello'); + }); + + it('shows Clear Filters button when search is active', () => { + renderDataTable({ tableState: makeTableState({ search: 'active' }) }); + expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument(); + }); + + it('does not show Clear Filters button when no active search or filters', () => { + renderDataTable({ tableState: makeTableState({ search: '' }) }); + expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument(); + }); + + it('calls onStateChange with cleared search when Clear Filters clicked', async () => { + const user = userEvent.setup(); + const mockOnStateChange = jest.fn(); + renderDataTable({ + tableState: makeTableState({ search: 'existing' }), + onStateChange: mockOnStateChange, + }); + await user.click(screen.getByRole('button', { name: /clear filters/i })); + const calls = mockOnStateChange.mock.calls as [TableState][]; + const lastCall = calls[calls.length - 1]; + expect(lastCall[0].search).toBe(''); + expect(lastCall[0].filters.size).toBe(0); + }); + }); + + describe('header content slot', () => { + it('renders custom headerContent when provided', () => { + render( + + pageKey="test" + columns={COLUMNS} + items={SAMPLE_ITEMS} + totalItems={3} + totalPages={1} + currentPage={1} + isLoading={false} + getRowKey={(i) => i.id} + tableState={makeTableState()} + onStateChange={jest.fn()} + headerContent={
My Header
} + />, + ); + expect(screen.getByTestId('custom-header')).toBeInTheDocument(); + }); + }); + + describe('pagination', () => { + it('does not render pagination when totalPages=1', () => { + renderDataTable({ totalPages: 1 }); + expect(screen.queryByRole('button', { name: /previous/i })).not.toBeInTheDocument(); + }); + + it('renders pagination when totalPages > 1', () => { + renderDataTable({ totalPages: 3, currentPage: 1, totalItems: 75 }); + expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument(); + }); + }); + + describe('column settings integration', () => { + it('renders column settings gear button', () => { + renderDataTable(); + expect(screen.getByRole('button', { name: /column settings/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/components/DataTable/DataTable.tsx b/client/src/components/DataTable/DataTable.tsx new file mode 100644 index 000000000..72bc88411 --- /dev/null +++ b/client/src/components/DataTable/DataTable.tsx @@ -0,0 +1,455 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ReactNode } from 'react'; +import type { FilterMeta } from '@cornerstone/shared'; +import type { SearchPickerProps } from '../SearchPicker/SearchPicker.js'; +import { DataTableHeader } from './DataTableHeader.js'; +import { DataTableRow } from './DataTableRow.js'; +import { DataTableCard } from './DataTableCard.js'; +import { DataTablePagination } from './DataTablePagination.js'; +import { DataTableColumnSettings } from './DataTableColumnSettings.js'; +import { useColumnPreferences } from '../../hooks/useColumnPreferences.js'; +import { Skeleton } from '../Skeleton/Skeleton.js'; +import { EmptyState } from '../EmptyState/EmptyState.js'; +import styles from './DataTable.module.css'; + +/** + * Filter type enumeration for DataTable column filters + */ +export type FilterType = 'string' | 'number' | 'date' | 'enum' | 'boolean' | 'entity'; + +/** + * Option for enum filters + */ +export interface EnumOption { + value: string; + label: string; +} + +/** + * Hierarchy item for enum filter (parent-child relationships) + */ +export interface EnumHierarchyItem { + id: string; + parentId: string | null; +} + +/** + * Column definition for DataTable + */ +export interface ColumnDef { + key: string; + label: string; + sortable?: boolean; + sortKey?: string; + filterable?: boolean; + filterType?: FilterType; + filterParamKey?: string; + enumOptions?: EnumOption[]; + enumHierarchy?: EnumHierarchyItem[]; + entitySearchFn?: SearchPickerProps['searchFn']; + entityRenderItem?: SearchPickerProps['renderItem']; + entityPlaceholder?: string; + numberMin?: number; + numberMax?: number; + numberStep?: number; + defaultVisible?: boolean; + /** Raw numeric value for client-side number filtering (when no filterParamKey) */ + getValue?: (item: T) => number; + render: (item: T) => ReactNode; + renderCard?: (item: T) => ReactNode; + className?: string; + headerClassName?: string; +} + +/** + * Active filter representation + */ +export interface ActiveFilter { + value: string; +} + +/** + * Table state holding pagination, search, sorting, and filters + */ +export interface TableState { + search: string; + filters: Map; + sortBy: string | null; + sortDir: 'asc' | 'desc' | null; + page: number; + pageSize: number; +} + +/** + * API parameters derived from TableState + */ +export interface TableApiParams { + q?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + page: number; + pageSize: number; + [key: string]: string | number | boolean | undefined; +} + +/** + * Props for DataTable component + */ +export interface DataTableProps { + pageKey: string; + columns: ColumnDef[]; + items: T[]; + totalItems: number; + totalPages: number; + currentPage: number; + isLoading: boolean; + error?: string | null; + getRowKey: (item: T) => string; + onRowClick?: (item: T) => void; + renderActions?: (item: T) => ReactNode; + tableState: TableState; + onStateChange: (state: TableState) => void; + headerContent?: ReactNode; + customFilters?: ReactNode; + emptyState?: { + message: string; + description?: string; + action?: { label: string; onClick: () => void }; + }; + filterMeta?: FilterMeta; + className?: string; +} + +/** + * DataTable component with integrated state management + * + * Provides: + * - Search with debouncing + * - Column sorting (3-state cycling) + * - Per-column filtering + * - Pagination with configurable page sizes + * - Column visibility preferences (desktop-only toggle) + * - Responsive layout (table on desktop, cards on mobile) + * - Loading, error, and empty states + * + * @param props Component props + * @returns Rendered DataTable + */ +export function DataTable({ + pageKey, + columns, + items, + totalItems, + totalPages, + currentPage, + isLoading, + error, + getRowKey, + onRowClick, + renderActions, + tableState, + onStateChange, + headerContent, + customFilters, + emptyState, + filterMeta, + className, +}: DataTableProps) { + const { t } = useTranslation('common'); + + // Client-side filter state for columns without server-side support + const [clientFilters, setClientFilters] = useState>(new Map()); + + // Load column visibility and ordering preferences + const { visibleColumns, columnOrder, toggleColumn, moveColumn, resetToDefaults } = + useColumnPreferences(pageKey, columns); + + // Sort columns by stored order + const sortedColumns = useMemo(() => { + const columnMap = new Map(columns.map((col) => [col.key, col])); + const ordered: typeof columns = []; + + for (const key of columnOrder) { + const col = columnMap.get(key); + if (col) { + ordered.push(col); + } + } + + // Add any columns not in the stored order (new columns added to page) + for (const col of columns) { + if (!columnOrder.includes(col.key)) { + ordered.push(col); + } + } + + return ordered; + }, [columns, columnOrder]); + + // Identify columns that filter client-side only (no filterParamKey) + const clientOnlyFilterKeys = useMemo(() => { + const keys = new Set(); + for (const col of sortedColumns) { + if (col.filterable && col.filterType === 'number' && !col.filterParamKey && col.getValue) { + keys.add(col.key); + } + } + return keys; + }, [sortedColumns]); + + // Apply client-side filters to the items list + const filteredItems = useMemo(() => { + let result = items; + for (const col of sortedColumns) { + if (!clientOnlyFilterKeys.has(col.key) || !col.getValue) continue; + const filterVal = clientFilters.get(col.key)?.value; + if (!filterVal) continue; + const minMatch = filterVal.match(/min:([\d.]+)/); + const maxMatch = filterVal.match(/max:([\d.]+)/); + const filterMin = minMatch ? parseFloat(minMatch[1]) : undefined; + const filterMax = maxMatch ? parseFloat(maxMatch[1]) : undefined; + result = result.filter((item) => { + const val = col.getValue!(item); + if (filterMin !== undefined && val < filterMin) return false; + if (filterMax !== undefined && val > filterMax) return false; + return true; + }); + } + return result; + }, [items, sortedColumns, clientOnlyFilterKeys, clientFilters]); + + // Compute client-side filterMeta bounds for columns without server support + const clientFilterMeta = useMemo(() => { + const meta: Record = {}; + for (const col of sortedColumns) { + if (clientOnlyFilterKeys.has(col.key) && col.getValue) { + let min = Infinity, + max = -Infinity; + for (const item of items) { + const v = col.getValue(item); + if (v < min) min = v; + if (v > max) max = v; + } + meta[col.key] = { min: min === Infinity ? 0 : min, max: max === -Infinity ? 0 : max }; + } + } + return meta; + }, [items, sortedColumns, clientOnlyFilterKeys]); + + // Merge API filterMeta with client-side computed meta + const mergedFilterMeta = useMemo( + () => ({ + ...filterMeta, + ...clientFilterMeta, + }), + [filterMeta, clientFilterMeta], + ); + + // Combine server-side and client-side filters for header display + const allFilters = useMemo(() => { + const merged = new Map(tableState.filters); + for (const [key, val] of clientFilters) { + merged.set(key, val); + } + return merged; + }, [tableState.filters, clientFilters]); + + const handleSearch = (query: string) => { + const newState = { ...tableState, search: query, page: 1 }; + onStateChange(newState); + }; + + const handleSort = (columnKey: string, columnSortKey?: string) => { + const sortKey = columnSortKey || columnKey; + let newSortDir: 'asc' | 'desc' | null = 'asc'; + + if (tableState.sortBy === sortKey && tableState.sortDir === 'asc') { + newSortDir = 'desc'; + } else if (tableState.sortBy === sortKey && tableState.sortDir === 'desc') { + newSortDir = null; + } + + const newState = { + ...tableState, + sortBy: newSortDir ? sortKey : null, + sortDir: newSortDir, + page: 1, + }; + onStateChange(newState); + }; + + const handleFilter = (paramKey: string, value: string | null) => { + // Route client-side filters to internal state + if (clientOnlyFilterKeys.has(paramKey)) { + setClientFilters((prev) => { + const next = new Map(prev); + if (value === null || value === '') next.delete(paramKey); + else next.set(paramKey, { value }); + return next; + }); + return; // Don't propagate to parent for client-side filters + } + + // Server-side filters: propagate to parent + const newFilters = new Map(tableState.filters); + if (value === null || value === '') { + newFilters.delete(paramKey); + } else { + newFilters.set(paramKey, { value }); + } + const newState = { ...tableState, filters: newFilters, page: 1 }; + onStateChange(newState); + }; + + const handlePage = (page: number) => { + const newState = { ...tableState, page }; + onStateChange(newState); + }; + + const handlePageSize = (size: number) => { + const newState = { ...tableState, pageSize: size, page: 1 }; + onStateChange(newState); + }; + + const handleResetFilters = () => { + setClientFilters(new Map()); + const newState = { + ...tableState, + search: '', + filters: new Map(), + page: 1, + }; + onStateChange(newState); + }; + + const hasActiveFilters = useMemo( + () => tableState.search !== '' || tableState.filters.size > 0 || clientFilters.size > 0, + [tableState.search, tableState.filters, clientFilters], + ); + + if (isLoading && items.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+ {error && ( +
+ {error} +
+ )} + + {/* Header content slot */} + {headerContent} + + {/* Toolbar */} +
+
+
+ handleSearch(e.target.value)} + className={styles.searchInput} + aria-label={t('dataTable.search.ariaLabel')} + /> +
+
+ {hasActiveFilters && ( + + )} + + columns={sortedColumns} + visibleColumns={visibleColumns} + onToggleColumn={toggleColumn} + onMoveColumn={moveColumn} + onResetToDefaults={resetToDefaults} + /> +
+
+ + {/* Custom filters slot */} + {customFilters &&
{customFilters}
} +
+ + {/* Desktop Table — always show header */} +
+ + + columns={sortedColumns} + visibleColumns={visibleColumns} + tableState={{ ...tableState, filters: allFilters }} + filterMeta={mergedFilterMeta} + onSort={handleSort} + onFilter={handleFilter} + hasActions={!!renderActions} + /> + + {filteredItems.map((item) => ( + + key={getRowKey(item)} + item={item} + columns={sortedColumns} + visibleColumns={visibleColumns} + onClick={() => onRowClick?.(item)} + renderActions={renderActions ? () => renderActions(item) : undefined} + /> + ))} + +
+
+ + {/* Empty state or mobile cards */} + {filteredItems.length === 0 ? ( + + ) : ( +
+ {filteredItems.map((item) => ( + + key={getRowKey(item)} + item={item} + columns={sortedColumns} + visibleColumns={visibleColumns} + onClick={() => onRowClick?.(item)} + renderActions={renderActions ? () => renderActions(item) : undefined} + /> + ))} +
+ )} + + {/* Pagination */} + {totalPages > 1 && ( + + )} +
+ ); +} + +export default DataTable; diff --git a/client/src/components/DataTable/DataTableCard.tsx b/client/src/components/DataTable/DataTableCard.tsx new file mode 100644 index 000000000..ac100ad44 --- /dev/null +++ b/client/src/components/DataTable/DataTableCard.tsx @@ -0,0 +1,50 @@ +import type { ColumnDef } from './DataTable.js'; +import styles from './DataTable.module.css'; + +export interface DataTableCardProps { + item: T; + columns: ColumnDef[]; + visibleColumns: Set; + onClick?: () => void; + renderActions?: (item: T) => React.ReactNode; +} + +/** + * Mobile card renderer for a single item + * Uses renderCard if available, falls back to render + */ +export function DataTableCard({ + item, + columns, + visibleColumns, + onClick, + renderActions, +}: DataTableCardProps) { + const visibleCols = columns.filter((col) => visibleColumns.has(col.key)); + + return ( +
+
+
+ {visibleCols.map((col) => { + // Use renderCard if available, otherwise use render + const content = col.renderCard ? col.renderCard(item) : col.render(item); + if (content === null) return null; + + return ( +
+ {col.label} + {content ?? '—'} +
+ ); + })} +
+ {renderActions && ( +
e.stopPropagation()}> + {renderActions(item)} +
+ )} +
+
+ ); +} diff --git a/client/src/components/DataTable/DataTableColumnSettings.test.tsx b/client/src/components/DataTable/DataTableColumnSettings.test.tsx new file mode 100644 index 000000000..3caedb68d --- /dev/null +++ b/client/src/components/DataTable/DataTableColumnSettings.test.tsx @@ -0,0 +1,339 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { render, screen, act, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ColumnDef } from './DataTable.js'; +import { DataTableColumnSettings } from './DataTableColumnSettings.js'; + +interface TestItem { + id: string; + title: string; + amount: number; +} + +const COLUMNS: ColumnDef[] = [ + { key: 'title', label: 'Title', defaultVisible: true, render: (i) => i.title }, + { key: 'amount', label: 'Amount', defaultVisible: true, render: (i) => i.amount }, + { key: 'id', label: 'ID', defaultVisible: false, render: (i) => i.id }, +]; + +function renderSettings({ + columns = COLUMNS, + visibleColumns = new Set(['title', 'amount']), + onToggleColumn = jest.fn(), + onMoveColumn = jest.fn(), + onResetToDefaults = jest.fn(), +}: { + columns?: ColumnDef[]; + visibleColumns?: Set; + onToggleColumn?: jest.Mock; + onMoveColumn?: jest.Mock; + onResetToDefaults?: jest.Mock; +} = {}) { + return render( + + columns={columns} + visibleColumns={visibleColumns} + onToggleColumn={onToggleColumn} + onMoveColumn={onMoveColumn} + onResetToDefaults={onResetToDefaults} + />, + ); +} + +describe('DataTableColumnSettings', () => { + describe('trigger button', () => { + it('renders a gear/settings button', () => { + renderSettings(); + expect(screen.getByRole('button', { name: /column settings/i })).toBeInTheDocument(); + }); + + it('button has aria-expanded="false" initially', () => { + renderSettings(); + expect(screen.getByRole('button', { name: /column settings/i })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + }); + + it('button has aria-haspopup="dialog"', () => { + renderSettings(); + expect(screen.getByRole('button', { name: /column settings/i })).toHaveAttribute( + 'aria-haspopup', + 'dialog', + ); + }); + }); + + describe('popover open/close', () => { + it('does not show popover initially', () => { + renderSettings(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('shows popover after clicking the trigger button', async () => { + const user = userEvent.setup(); + renderSettings(); + await user.click(screen.getByRole('button', { name: /column settings/i })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('button has aria-expanded="true" when popover is open', async () => { + const user = userEvent.setup(); + renderSettings(); + await user.click(screen.getByRole('button', { name: /column settings/i })); + expect(screen.getByRole('button', { name: /column settings/i })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); + + it('hides popover after clicking trigger button again', async () => { + const user = userEvent.setup(); + renderSettings(); + const triggerBtn = screen.getByRole('button', { name: /column settings/i }); + await user.click(triggerBtn); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + await user.click(triggerBtn); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('hides popover on Escape key press', async () => { + const user = userEvent.setup(); + renderSettings(); + await user.click(screen.getByRole('button', { name: /column settings/i })); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + await user.keyboard('{Escape}'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('hides popover on outside click', async () => { + renderSettings(); + const triggerBtn = screen.getByRole('button', { name: /column settings/i }); + await userEvent.click(triggerBtn); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + + act(() => { + const event = new MouseEvent('mousedown', { bubbles: true }); + document.body.dispatchEvent(event); + }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + describe('checkbox list', () => { + it('renders a checkbox for each column', async () => { + const user = userEvent.setup(); + renderSettings(); + await user.click(screen.getByRole('button', { name: /column settings/i })); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(COLUMNS.length); + }); + + it('renders column labels', async () => { + const user = userEvent.setup(); + renderSettings(); + await user.click(screen.getByRole('button', { name: /column settings/i })); + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.getByText('Amount')).toBeInTheDocument(); + expect(screen.getByText('ID')).toBeInTheDocument(); + }); + + it('checks visible columns', async () => { + const user = userEvent.setup(); + renderSettings({ visibleColumns: new Set(['title', 'amount']) }); + await user.click(screen.getByRole('button', { name: /column settings/i })); + expect(screen.getByRole('checkbox', { name: 'Title' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Amount' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'ID' })).not.toBeChecked(); + }); + + it('calls onToggleColumn with column key when checkbox clicked', async () => { + const user = userEvent.setup(); + const mockToggle = jest.fn(); + renderSettings({ onToggleColumn: mockToggle }); + await user.click(screen.getByRole('button', { name: /column settings/i })); + await user.click(screen.getByRole('checkbox', { name: 'Title' })); + expect(mockToggle).toHaveBeenCalledWith('title'); + }); + + it('calls onToggleColumn for a hidden column when its checkbox clicked', async () => { + const user = userEvent.setup(); + const mockToggle = jest.fn(); + renderSettings({ onToggleColumn: mockToggle }); + await user.click(screen.getByRole('button', { name: /column settings/i })); + await user.click(screen.getByRole('checkbox', { name: 'ID' })); + expect(mockToggle).toHaveBeenCalledWith('id'); + }); + }); + + describe('reset button', () => { + it('renders a "Reset to defaults" button in the popover', async () => { + const user = userEvent.setup(); + renderSettings(); + await user.click(screen.getByRole('button', { name: /column settings/i })); + expect(screen.getByRole('button', { name: /reset to defaults/i })).toBeInTheDocument(); + }); + + it('calls onResetToDefaults when reset button clicked', async () => { + const user = userEvent.setup(); + const mockReset = jest.fn(); + renderSettings({ onResetToDefaults: mockReset }); + await user.click(screen.getByRole('button', { name: /column settings/i })); + await user.click(screen.getByRole('button', { name: /reset to defaults/i })); + expect(mockReset).toHaveBeenCalledTimes(1); + }); + }); + + describe('SVG icon in trigger button (#1136)', () => { + it('renders an SVG element inside the trigger button', () => { + renderSettings(); + const triggerBtn = screen.getByRole('button', { name: /column settings/i }); + expect(triggerBtn.querySelector('svg')).not.toBeNull(); + }); + + it('does NOT contain an emoji character in the trigger button text', () => { + renderSettings(); + const triggerBtn = screen.getByRole('button', { name: /column settings/i }); + // The gear emoji ⚙️ was the prior implementation; verify it is no longer present + expect(triggerBtn.textContent).not.toContain('⚙️'); + }); + + it('SVG has aria-hidden="true" so it is invisible to assistive technology', () => { + renderSettings(); + const triggerBtn = screen.getByRole('button', { name: /column settings/i }); + const svg = triggerBtn.querySelector('svg'); + expect(svg).toHaveAttribute('aria-hidden', 'true'); + }); + }); + + describe('drag-and-drop column reordering (#1140)', () => { + async function openPopover() { + const user = userEvent.setup(); + renderSettings(); + await user.click(screen.getByRole('button', { name: /column settings/i })); + return user; + } + + it('sets effectAllowed to "move" on dragStart', async () => { + await openPopover(); + + // The "Amount" item (index 1) is draggable; "Title" (index 0) is not + const checkboxItems = document.querySelectorAll('[draggable="true"]'); + expect(checkboxItems.length).toBeGreaterThan(0); + + const draggableItem = checkboxItems[0] as HTMLElement; + const dataTransfer = { effectAllowed: '', dropEffect: '' }; + + fireEvent.dragStart(draggableItem, { dataTransfer }); + + expect(dataTransfer.effectAllowed).toBe('move'); + }); + + it('applies a drop indicator CSS class when dragging over a valid target item', async () => { + await openPopover(); + + const checkboxItems = document.querySelectorAll('[draggable="true"]'); + const draggableItem = checkboxItems[0] as HTMLElement; // Amount (col index 1) + const dataTransfer = { effectAllowed: '', dropEffect: '' }; + + fireEvent.dragStart(draggableItem, { dataTransfer }); + + // Use a different target item (checkboxItems[1] = ID column, col index 2) + const targetItem = checkboxItems[1] as HTMLElement; + + // In jsdom getBoundingClientRect always returns zeros, so clientY > 0 always + // evaluates to "below" — assert that SOME drop indicator class is applied. + fireEvent.dragOver(targetItem, { dataTransfer, clientY: 50 }); + + expect(targetItem.className).toMatch(/columnCheckboxItemDrop(Above|Below)/); + }); + + it('applies drop-below CSS class when clientY is in the lower half (jsdom geometry)', async () => { + await openPopover(); + + const checkboxItems = document.querySelectorAll('[draggable="true"]'); + const draggableItem = checkboxItems[0] as HTMLElement; + const dataTransfer = { effectAllowed: '', dropEffect: '' }; + + fireEvent.dragStart(draggableItem, { dataTransfer }); + + const targetItem = checkboxItems[1] as HTMLElement; + + // In jsdom, getBoundingClientRect returns all zeros. clientY > 0 means + // clientY >= midpoint (0 + 0/2 = 0), so position is always 'below' + fireEvent.dragOver(targetItem, { dataTransfer, clientY: 50 }); + + expect(targetItem.className).toContain('columnCheckboxItemDropBelow'); + }); + + it('clears drop indicator classes on dragLeave', async () => { + await openPopover(); + + const checkboxItems = document.querySelectorAll('[draggable="true"]'); + const draggableItem = checkboxItems[0] as HTMLElement; + const dataTransfer = { effectAllowed: '', dropEffect: '' }; + + fireEvent.dragStart(draggableItem, { dataTransfer }); + + const targetItem = checkboxItems[1] as HTMLElement; + + fireEvent.dragOver(targetItem, { dataTransfer, clientY: 50 }); + // Confirm indicator class was applied + expect(targetItem.className).toMatch(/columnCheckboxItemDrop(Above|Below)/); + + fireEvent.dragLeave(targetItem); + // After leave, neither above nor below class should be present + expect(targetItem.className).not.toContain('columnCheckboxItemDropAbove'); + expect(targetItem.className).not.toContain('columnCheckboxItemDropBelow'); + }); + + it('calls onMoveColumn with correct indices on drop', async () => { + const mockMoveColumn = jest.fn(); + const user = userEvent.setup(); + render( + + columns={COLUMNS} + visibleColumns={new Set(['title', 'amount'])} + onToggleColumn={jest.fn()} + onMoveColumn={mockMoveColumn} + onResetToDefaults={jest.fn()} + />, + ); + await user.click(screen.getByRole('button', { name: /column settings/i })); + + const checkboxItems = document.querySelectorAll('[draggable="true"]'); + // We have 2 draggable items (index 1: Amount, index 2: ID — index 0 Title is not draggable) + const draggedItem = checkboxItems[0] as HTMLElement; // Amount (col index 1) + const targetItem = checkboxItems[1] as HTMLElement; // ID (col index 2) + const dataTransfer = { effectAllowed: '', dropEffect: '' }; + + fireEvent.dragStart(draggedItem, { dataTransfer }); + + const mockBCR = (): DOMRect => + ({ + top: 100, + bottom: 140, + height: 40, + left: 0, + right: 200, + width: 200, + x: 0, + y: 100, + toJSON: () => ({}), + }) as DOMRect; + const origGetBCR = HTMLElement.prototype.getBoundingClientRect; + HTMLElement.prototype.getBoundingClientRect = mockBCR; + + try { + fireEvent.dragOver(targetItem, { dataTransfer, clientY: 130 }); // lower half → below + } finally { + HTMLElement.prototype.getBoundingClientRect = origGetBCR; + } + + fireEvent.drop(targetItem); + + expect(mockMoveColumn).toHaveBeenCalledWith(1, 2); + }); + }); +}); diff --git a/client/src/components/DataTable/DataTableColumnSettings.tsx b/client/src/components/DataTable/DataTableColumnSettings.tsx new file mode 100644 index 000000000..d055163ca --- /dev/null +++ b/client/src/components/DataTable/DataTableColumnSettings.tsx @@ -0,0 +1,190 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ColumnDef } from './DataTable.js'; +import styles from './DataTable.module.css'; + +interface DragOverState { + index: number; + position: 'above' | 'below'; +} + +export interface DataTableColumnSettingsProps { + columns: ColumnDef[]; + visibleColumns: Set; + onToggleColumn: (key: string) => void; + onMoveColumn: (from: number, to: number) => void; + onResetToDefaults: () => void; +} + +/** + * Column visibility and ordering settings popover + * Gear icon + checkbox list with drag-and-drop reordering, desktop-only (hidden on mobile) + * Uses position:fixed with getBoundingClientRect + */ +export function DataTableColumnSettings({ + columns, + visibleColumns, + onToggleColumn, + onMoveColumn, + onResetToDefaults, +}: DataTableColumnSettingsProps) { + const { t } = useTranslation('common'); + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [popoverStyle, setPopoverStyle] = useState({}); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverState, setDragOverState] = useState(null); + + // Close on outside click + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(event.target as Node) && + triggerRef.current && + !triggerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [isOpen]); + + return ( + <> + + + {isOpen && ( +
+
+

{t('dataTable.columnSettings.title')}

+
+ {columns.map((col, index) => ( +
0} + onDragStart={(e) => { + e.dataTransfer.effectAllowed = 'move'; + setDraggedIndex(index); + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + if (index > 0) { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const position = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'; + setDragOverState({ index, position }); + } + }} + onDragLeave={() => setDragOverState(null)} + onDrop={() => { + if ( + draggedIndex !== null && + dragOverState !== null && + dragOverState.index !== draggedIndex && + dragOverState.index > 0 + ) { + onMoveColumn(draggedIndex, dragOverState.index); + } + setDraggedIndex(null); + setDragOverState(null); + }} + onDragEnd={() => { + setDraggedIndex(null); + setDragOverState(null); + }} + > + {index > 0 && ( + + )} + onToggleColumn(col.key)} + className={styles.columnCheckbox} + /> + +
+ ))} +
+ +
+
+ )} + + ); +} diff --git a/client/src/components/DataTable/DataTableFilterPopover.test.tsx b/client/src/components/DataTable/DataTableFilterPopover.test.tsx new file mode 100644 index 000000000..f7d33e402 --- /dev/null +++ b/client/src/components/DataTable/DataTableFilterPopover.test.tsx @@ -0,0 +1,223 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ColumnDef } from './DataTable.js'; +import { DataTableFilterPopover } from './DataTableFilterPopover.js'; + +interface TestItem { + id: string; + name: string; +} + +// Build a fake DOMRect for triggerRect prop +function makeTriggerRect(overrides: Partial = {}): DOMRect { + return { + bottom: 40, + top: 20, + left: 100, + right: 200, + width: 100, + height: 20, + x: 100, + y: 20, + toJSON: () => ({}), + ...overrides, + } as DOMRect; +} + +function makeColumn(overrides: Partial> = {}): ColumnDef { + return { + key: 'name', + label: 'Name', + filterType: 'string', + filterParamKey: 'name', + filterable: true, + render: (item) => item.name, + ...overrides, + }; +} + +describe('DataTableFilterPopover', () => { + describe('rendering', () => { + it('renders with role="dialog"', () => { + render( + , + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('uses fixed positioning based on triggerRect', () => { + render( + , + ); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveStyle({ position: 'fixed', top: '64px', left: '150px' }); + }); + + it('clamps left position to at least 16px', () => { + render( + , + ); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveStyle({ left: '16px' }); + }); + + it('renders StringFilter for filterType="string"', () => { + render( + , + ); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('renders NumberFilter for filterType="number"', () => { + render( + , + ); + const spinbuttons = screen.getAllByRole('spinbutton'); + expect(spinbuttons.length).toBeGreaterThan(0); + }); + + it('renders BooleanFilter for filterType="boolean"', () => { + render( + , + ); + expect(screen.getByRole('button', { name: /yes/i })).toBeInTheDocument(); + }); + + it('renders EnumFilter for filterType="enum" with options', () => { + render( + , + ); + expect(screen.getByText('Option A')).toBeInTheDocument(); + }); + + it('does not render an Apply button inside the popover', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument(); + }); + + it('does not render a Clear button inside the popover', () => { + render( + , + ); + expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument(); + }); + + it('passes initial value to the rendered filter component', () => { + render( + , + ); + expect(screen.getByRole('textbox')).toHaveValue('hello'); + }); + }); + + describe('auto-apply', () => { + it('calls onApply immediately when string filter input changes', () => { + const mockOnApply = jest.fn(); + render( + , + ); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' } }); + expect(mockOnApply).toHaveBeenCalledWith('test'); + }); + + it('calls onApply immediately when enum filter checkbox is toggled', () => { + const mockOnApply = jest.fn(); + render( + , + ); + fireEvent.click(screen.getByText('Option A')); + expect(mockOnApply).toHaveBeenCalledWith('a'); + }); + }); + + describe('aria-label', () => { + it('includes the column label in aria-label on dialog', () => { + render( + , + ); + expect(screen.getByRole('dialog')).toHaveAttribute( + 'aria-label', + expect.stringContaining('Budget Amount'), + ); + }); + }); +}); diff --git a/client/src/components/DataTable/DataTableFilterPopover.tsx b/client/src/components/DataTable/DataTableFilterPopover.tsx new file mode 100644 index 000000000..6b5b96783 --- /dev/null +++ b/client/src/components/DataTable/DataTableFilterPopover.tsx @@ -0,0 +1,120 @@ +import { useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { FilterMeta } from '@cornerstone/shared'; +import type { ColumnDef, FilterType } from './DataTable.js'; +import { StringFilter } from './filters/StringFilter.js'; +import { NumberFilter } from './filters/NumberFilter.js'; +import { DateFilter } from './filters/DateFilter.js'; +import { EnumFilter } from './filters/EnumFilter.js'; +import { BooleanFilter } from './filters/BooleanFilter.js'; +import { EntityFilter } from './filters/EntityFilter.js'; +import styles from './DataTable.module.css'; + +export interface DataTableFilterPopoverProps { + column: ColumnDef; + value: string; + onApply: (value: string) => void; + triggerRect: DOMRect; + filterMeta?: FilterMeta; +} + +/** + * Filter popover for a single column + * Uses position:fixed to avoid clipping inside table container + */ +export function DataTableFilterPopover({ + column, + value, + onApply, + triggerRect, + filterMeta, +}: DataTableFilterPopoverProps) { + const { t } = useTranslation('common'); + const popoverRef = useRef(null); + + // Auto-apply: pass onApply directly to filter components as onChange + // No local state needed — filters propagate immediately + const handleChange = onApply; + + // Position the popover using getBoundingClientRect + const popoverStyle = { + position: 'fixed' as const, + top: `${triggerRect.bottom + 4}px`, + left: `${Math.max(16, triggerRect.left)}px`, + maxWidth: '300px', + zIndex: 1000, + }; + + const renderFilterComponent = () => { + const filterType = column.filterType as FilterType; + + switch (filterType) { + case 'string': + return ( + + ); + case 'number': { + const apiMeta = filterMeta?.[column.key]; + const effectiveMin = apiMeta?.min ?? column.numberMin; + const effectiveMax = apiMeta?.max ?? column.numberMax; + return ( + + ); + } + case 'date': + return ; + case 'enum': + return ( + column.enumOptions && ( + + ) + ); + case 'boolean': + return ; + case 'entity': + return ( + column.entitySearchFn && + column.entityRenderItem && ( + + ) + ); + default: + return null; + } + }; + + return ( +
e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + {renderFilterComponent()} +
+ ); +} diff --git a/client/src/components/DataTable/DataTableHeader.test.tsx b/client/src/components/DataTable/DataTableHeader.test.tsx new file mode 100644 index 000000000..66d13ee48 --- /dev/null +++ b/client/src/components/DataTable/DataTableHeader.test.tsx @@ -0,0 +1,319 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ColumnDef, TableState } from './DataTable.js'; +import { DataTableHeader } from './DataTableHeader.js'; + +interface TestItem { + id: string; + title: string; + amount: number; +} + +const DEFAULT_COLUMNS: ColumnDef[] = [ + { + key: 'title', + label: 'Title', + sortable: true, + filterable: true, + filterType: 'string', + filterParamKey: 'title', + render: (item) => item.title, + }, + { + key: 'amount', + label: 'Amount', + sortable: true, + render: (item) => item.amount, + }, + { + key: 'id', + label: 'ID', + render: (item) => item.id, + }, +]; + +function makeTableState(overrides: Partial = {}): TableState { + return { + search: '', + filters: new Map(), + sortBy: null, + sortDir: null, + page: 1, + pageSize: 25, + ...overrides, + }; +} + +function renderHeader({ + columns = DEFAULT_COLUMNS, + visibleColumns = new Set(DEFAULT_COLUMNS.map((c) => c.key)), + tableState = makeTableState(), + onSort = jest.fn(), + onFilter = jest.fn(), +}: { + columns?: ColumnDef[]; + visibleColumns?: Set; + tableState?: TableState; + onSort?: jest.Mock; + onFilter?: jest.Mock; +} = {}) { + return render( + + + columns={columns} + visibleColumns={visibleColumns} + tableState={tableState} + onSort={onSort} + onFilter={onFilter} + /> +
, + ); +} + +describe('DataTableHeader', () => { + describe('rendering', () => { + it('renders a thead element', () => { + const { container } = renderHeader(); + expect(container.querySelector('thead')).toBeInTheDocument(); + }); + + it('renders a th for each visible column', () => { + const { container } = renderHeader(); + const headers = container.querySelectorAll('th'); + expect(headers).toHaveLength(3); + }); + + it('renders only visible columns', () => { + const { container } = renderHeader({ + visibleColumns: new Set(['title', 'amount']), + }); + expect(container.querySelectorAll('th')).toHaveLength(2); + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.getByText('Amount')).toBeInTheDocument(); + expect(screen.queryByText('ID')).not.toBeInTheDocument(); + }); + + it('renders column labels', () => { + renderHeader(); + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.getByText('Amount')).toBeInTheDocument(); + expect(screen.getByText('ID')).toBeInTheDocument(); + }); + + it('renders filter button only for filterable columns with filterParamKey and filterType', () => { + renderHeader(); + // Only 'title' column has filterable=true AND filterParamKey AND filterType + const filterButtons = screen.queryAllByRole('button'); + expect(filterButtons).toHaveLength(1); + }); + }); + + describe('sort aria-sort attributes', () => { + it('has aria-sort="none" on all columns when no sort is active', () => { + const { container } = renderHeader(); + const headers = container.querySelectorAll('th'); + headers.forEach((th) => { + expect(th).toHaveAttribute('aria-sort', 'none'); + }); + }); + + it('has aria-sort="ascending" on the sorted column when sortDir is "asc"', () => { + const { container } = renderHeader({ + tableState: makeTableState({ sortBy: 'title', sortDir: 'asc' }), + }); + const titleTh = container.querySelectorAll('th')[0]; + expect(titleTh).toHaveAttribute('aria-sort', 'ascending'); + }); + + it('has aria-sort="descending" on the sorted column when sortDir is "desc"', () => { + const { container } = renderHeader({ + tableState: makeTableState({ sortBy: 'title', sortDir: 'desc' }), + }); + const titleTh = container.querySelectorAll('th')[0]; + expect(titleTh).toHaveAttribute('aria-sort', 'descending'); + }); + + it('has aria-sort="none" on columns not being sorted', () => { + const { container } = renderHeader({ + tableState: makeTableState({ sortBy: 'title', sortDir: 'asc' }), + }); + // 'amount' is index 1, not being sorted + const amountTh = container.querySelectorAll('th')[1]; + expect(amountTh).toHaveAttribute('aria-sort', 'none'); + }); + + it('displays ascending sort icon when column is sorted asc', () => { + renderHeader({ + tableState: makeTableState({ sortBy: 'title', sortDir: 'asc' }), + }); + expect(screen.getByText(/Title.*↑/)).toBeInTheDocument(); + }); + + it('displays descending sort icon when column is sorted desc', () => { + renderHeader({ + tableState: makeTableState({ sortBy: 'title', sortDir: 'desc' }), + }); + expect(screen.getByText(/Title.*↓/)).toBeInTheDocument(); + }); + }); + + describe('sort click handling', () => { + it('calls onSort with column key when sortable column header is clicked', async () => { + const user = userEvent.setup(); + const mockOnSort = jest.fn(); + const { container } = renderHeader({ onSort: mockOnSort }); + const titleTh = container.querySelectorAll('th')[0]; + await user.click(titleTh); + expect(mockOnSort).toHaveBeenCalledWith('title', undefined); + }); + + it('does not call onSort when non-sortable column header is clicked', async () => { + const user = userEvent.setup(); + const mockOnSort = jest.fn(); + const { container } = renderHeader({ onSort: mockOnSort }); + // 'id' column (index 2) is not sortable + const idTh = container.querySelectorAll('th')[2]; + await user.click(idTh); + expect(mockOnSort).not.toHaveBeenCalled(); + }); + + it('passes sortKey to onSort when column has a custom sortKey', async () => { + const user = userEvent.setup(); + const mockOnSort = jest.fn(); + const columnsWithSortKey: ColumnDef[] = [ + { + key: 'title', + label: 'Title', + sortable: true, + sortKey: 'title_text', + render: (item) => item.title, + }, + ]; + const { container } = renderHeader({ + columns: columnsWithSortKey, + visibleColumns: new Set(['title']), + onSort: mockOnSort, + }); + await user.click(container.querySelector('th')!); + expect(mockOnSort).toHaveBeenCalledWith('title', 'title_text'); + }); + }); + + describe('filter button', () => { + it('shows filter button for filterable column with filterParamKey and filterType', () => { + renderHeader(); + expect(screen.getByRole('button', { name: /filter by title/i })).toBeInTheDocument(); + }); + + it('filter button click stops propagation and does not trigger sort', async () => { + const user = userEvent.setup(); + const mockOnSort = jest.fn(); + renderHeader({ onSort: mockOnSort }); + const filterBtn = screen.getByRole('button', { name: /filter by title/i }); + await user.click(filterBtn); + // onSort should not be called because click stops propagation + expect(mockOnSort).not.toHaveBeenCalled(); + }); + + it('filter button toggles filter popover visibility on click', async () => { + const user = userEvent.setup(); + const { container } = renderHeader(); + const filterBtn = screen.getByRole('button', { name: /filter by title/i }); + + // Before click: no dialog + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + // Click to open — note: getBoundingClientRect returns zeros in jsdom + // so we need a mock + jest.spyOn(filterBtn, 'getBoundingClientRect').mockReturnValue({ + bottom: 40, + top: 20, + left: 100, + right: 200, + width: 100, + height: 20, + x: 100, + y: 20, + toJSON: () => ({}), + } as DOMRect); + + await user.click(filterBtn); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('filter button has active class when filter is active for that column', () => { + const activeFilters = new Map([['title', { value: 'test' }]]); + renderHeader({ + tableState: makeTableState({ filters: activeFilters }), + }); + // Filter button should exist and have an active class + const filterBtn = screen.getByRole('button', { name: /filter by title/i }); + // The class name uses CSS modules (identity-obj-proxy returns class name as-is) + expect(filterBtn.className).toContain('tableHeaderFilterButtonActive'); + }); + }); + + describe('actions column translation (#1137)', () => { + it('renders an "Actions" column header when hasActions is true', () => { + const { container } = renderHeader({ columns: DEFAULT_COLUMNS }); + // Re-render with hasActions + const { container: c } = render( + + + columns={DEFAULT_COLUMNS} + visibleColumns={new Set(DEFAULT_COLUMNS.map((col) => col.key))} + tableState={makeTableState()} + onSort={jest.fn()} + onFilter={jest.fn()} + hasActions={true} + /> +
, + ); + const headers = c.querySelectorAll('th'); + const headerTexts = Array.from(headers).map((th) => th.textContent?.trim()); + expect(headerTexts).toContain('Actions'); + }); + + it('does NOT render an "Actions" column header when hasActions is false', () => { + renderHeader(); + // Default render has no hasActions prop (undefined = falsy) + expect(screen.queryByText('Actions')).not.toBeInTheDocument(); + }); + + it('does NOT render an "Actions" column header when hasActions is omitted', () => { + const { container } = render( + + + columns={DEFAULT_COLUMNS} + visibleColumns={new Set(DEFAULT_COLUMNS.map((col) => col.key))} + tableState={makeTableState()} + onSort={jest.fn()} + onFilter={jest.fn()} + /> +
, + ); + const headers = container.querySelectorAll('th'); + const headerTexts = Array.from(headers).map((th) => th.textContent?.trim()); + expect(headerTexts).not.toContain('Actions'); + }); + + it('actions column is added after all data columns', () => { + const { container } = render( + + + columns={DEFAULT_COLUMNS} + visibleColumns={new Set(DEFAULT_COLUMNS.map((col) => col.key))} + tableState={makeTableState()} + onSort={jest.fn()} + onFilter={jest.fn()} + hasActions={true} + /> +
, + ); + const headers = container.querySelectorAll('th'); + // Actions should be the last header + expect(headers[headers.length - 1].textContent?.trim()).toBe('Actions'); + }); + }); +}); diff --git a/client/src/components/DataTable/DataTableHeader.tsx b/client/src/components/DataTable/DataTableHeader.tsx new file mode 100644 index 000000000..e83a51270 --- /dev/null +++ b/client/src/components/DataTable/DataTableHeader.tsx @@ -0,0 +1,145 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { FilterMeta } from '@cornerstone/shared'; +import type { ColumnDef, TableState } from './DataTable.js'; +import { DataTableFilterPopover } from './DataTableFilterPopover.js'; +import styles from './DataTable.module.css'; + +export interface DataTableHeaderProps { + columns: ColumnDef[]; + visibleColumns: Set; + tableState: TableState; + onSort: (columnKey: string, columnSortKey?: string) => void; + onFilter: (paramKey: string, value: string | null) => void; + hasActions?: boolean; + filterMeta?: FilterMeta; +} + +/** + * Table header with sortable columns and per-column filter buttons + */ +export function DataTableHeader({ + columns, + visibleColumns, + tableState, + onSort, + onFilter, + hasActions, + filterMeta, +}: DataTableHeaderProps) { + const { t } = useTranslation('common'); + const [activeFilterColumn, setActiveFilterColumn] = useState(null); + const filterTriggerRefs = useRef>({}); + + // Close filter popover on outside click or Escape + useEffect(() => { + if (!activeFilterColumn) return; + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if ( + !target.closest(`.${styles.filterPopover}`) && + !target.closest(`.${styles.tableHeaderFilterButton}`) + ) { + setActiveFilterColumn(null); + } + }; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') setActiveFilterColumn(null); + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleEscape); + }; + }, [activeFilterColumn]); + + const visibleCols = columns.filter((col) => visibleColumns.has(col.key)); + + const renderSortIcon = (columnKey: string, columnSortKey?: string) => { + const sortKey = columnSortKey || columnKey; + if (tableState.sortBy !== sortKey) return null; + if (tableState.sortDir === 'asc') return ' ↑'; + if (tableState.sortDir === 'desc') return ' ↓'; + return null; + }; + + const getSortAttribute = (columnKey: string, columnSortKey?: string) => { + const sortKey = columnSortKey || columnKey; + if (tableState.sortBy !== sortKey) return 'none'; + if (tableState.sortDir === 'asc') return 'ascending'; + if (tableState.sortDir === 'desc') return 'descending'; + return 'none'; + }; + + return ( + + + {visibleCols.map((col) => ( + col.sortable && onSort(col.key, col.sortKey)} + aria-sort={getSortAttribute(col.key, col.sortKey)} + > +
+ + {col.label} + {renderSortIcon(col.key, col.sortKey)} + + {col.filterable && col.filterType && (col.filterParamKey || col.getValue) && ( + + )} +
+ + {activeFilterColumn === col.key && + col.filterable && + col.filterType && + (col.filterParamKey || col.getValue) && + filterTriggerRefs.current[col.key] && ( + { + onFilter(col.filterParamKey || col.key, value || null); + }} + triggerRect={filterTriggerRefs.current[col.key].getBoundingClientRect()} + /> + )} + + ))} + {hasActions && {t('actions')}} + + + ); +} diff --git a/client/src/components/DataTable/DataTablePagination.test.tsx b/client/src/components/DataTable/DataTablePagination.test.tsx new file mode 100644 index 000000000..d89a01d15 --- /dev/null +++ b/client/src/components/DataTable/DataTablePagination.test.tsx @@ -0,0 +1,187 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DataTablePagination } from './DataTablePagination.js'; + +function renderPagination({ + currentPage = 1, + totalPages = 5, + totalItems = 100, + pageSize = 25, + onPageChange = jest.fn(), + onPageSizeChange, +}: { + currentPage?: number; + totalPages?: number; + totalItems?: number; + pageSize?: number; + onPageChange?: jest.Mock; + onPageSizeChange?: jest.Mock; +} = {}) { + return render( + , + ); +} + +describe('DataTablePagination', () => { + describe('rendering', () => { + it('renders nothing when totalPages <= 1', () => { + const { container } = renderPagination({ totalPages: 1 }); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders pagination when totalPages > 1', () => { + renderPagination({ totalPages: 3 }); + expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument(); + }); + + it('renders page number buttons', () => { + renderPagination({ totalPages: 3, currentPage: 1 }); + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument(); + }); + + it('marks current page button with aria-current="page"', () => { + renderPagination({ totalPages: 3, currentPage: 2 }); + expect(screen.getByRole('button', { name: '2' })).toHaveAttribute('aria-current', 'page'); + }); + + it('does not mark other pages with aria-current', () => { + renderPagination({ totalPages: 3, currentPage: 2 }); + expect(screen.getByRole('button', { name: '1' })).not.toHaveAttribute('aria-current'); + }); + + it('shows "Showing X–Y of N items" info text', () => { + renderPagination({ currentPage: 2, totalPages: 4, totalItems: 100, pageSize: 25 }); + // Showing 26–50 of 100 items + expect(screen.getByText(/26/)).toBeInTheDocument(); + expect(screen.getByText(/50/)).toBeInTheDocument(); + expect(screen.getByText(/100/)).toBeInTheDocument(); + }); + + it('caps "to" value at totalItems on last page', () => { + renderPagination({ currentPage: 4, totalPages: 4, totalItems: 90, pageSize: 25 }); + // Page 4: showing 76–90 of 90 + expect(screen.getByText(/76/)).toBeInTheDocument(); + expect(screen.getByText(/90/)).toBeInTheDocument(); + }); + }); + + describe('boundary disabling', () => { + it('Prev button is disabled when on page 1', () => { + renderPagination({ currentPage: 1, totalPages: 5 }); + expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled(); + }); + + it('Prev button is enabled when not on page 1', () => { + renderPagination({ currentPage: 2, totalPages: 5 }); + expect(screen.getByRole('button', { name: /previous/i })).not.toBeDisabled(); + }); + + it('Next button is disabled when on last page', () => { + renderPagination({ currentPage: 5, totalPages: 5 }); + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + + it('Next button is enabled when not on last page', () => { + renderPagination({ currentPage: 3, totalPages: 5 }); + expect(screen.getByRole('button', { name: /next/i })).not.toBeDisabled(); + }); + }); + + describe('page navigation', () => { + it('clicking page 2 calls onPageChange(2)', async () => { + const user = userEvent.setup(); + const mockOnPageChange = jest.fn(); + renderPagination({ currentPage: 1, totalPages: 5, onPageChange: mockOnPageChange }); + await user.click(screen.getByRole('button', { name: '2' })); + expect(mockOnPageChange).toHaveBeenCalledWith(2); + }); + + it('clicking Next calls onPageChange with currentPage + 1', async () => { + const user = userEvent.setup(); + const mockOnPageChange = jest.fn(); + renderPagination({ currentPage: 3, totalPages: 5, onPageChange: mockOnPageChange }); + await user.click(screen.getByRole('button', { name: /next/i })); + expect(mockOnPageChange).toHaveBeenCalledWith(4); + }); + + it('clicking Prev calls onPageChange with currentPage - 1', async () => { + const user = userEvent.setup(); + const mockOnPageChange = jest.fn(); + renderPagination({ currentPage: 3, totalPages: 5, onPageChange: mockOnPageChange }); + await user.click(screen.getByRole('button', { name: /previous/i })); + expect(mockOnPageChange).toHaveBeenCalledWith(2); + }); + }); + + describe('page number windowing', () => { + it('shows all pages when totalPages <= 5', () => { + renderPagination({ currentPage: 1, totalPages: 4 }); + [1, 2, 3, 4].forEach((n) => { + expect(screen.getByRole('button', { name: String(n) })).toBeInTheDocument(); + }); + }); + + it('shows 5 page buttons when totalPages > 5', () => { + renderPagination({ currentPage: 5, totalPages: 20 }); + // Count page number buttons (exclude prev/next) + const allButtons = screen.getAllByRole('button'); + const pageButtons = allButtons.filter((b) => /^\d+$/.test(b.textContent || '')); + expect(pageButtons).toHaveLength(5); + }); + + it('shows pages starting from 1 when on page 1 of many', () => { + renderPagination({ currentPage: 1, totalPages: 20 }); + expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '5' })).toBeInTheDocument(); + }); + + it('shows last 5 pages when near the end', () => { + renderPagination({ currentPage: 19, totalPages: 20 }); + expect(screen.getByRole('button', { name: '16' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '20' })).toBeInTheDocument(); + }); + }); + + describe('page size selector', () => { + it('does not render page size selector when onPageSizeChange not provided', () => { + renderPagination(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + + it('renders page size selector when onPageSizeChange provided', () => { + renderPagination({ onPageSizeChange: jest.fn() }); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('shows current pageSize as selected value in selector', () => { + renderPagination({ pageSize: 50, onPageSizeChange: jest.fn() }); + expect(screen.getByRole('combobox')).toHaveValue('50'); + }); + + it('calls onPageSizeChange with parsed integer when size selected', async () => { + const user = userEvent.setup(); + const mockOnPageSizeChange = jest.fn(); + renderPagination({ pageSize: 25, onPageSizeChange: mockOnPageSizeChange }); + await user.selectOptions(screen.getByRole('combobox'), '100'); + expect(mockOnPageSizeChange).toHaveBeenCalledWith(100); + }); + + it('renders all page size options: 10, 25, 50, 100', () => { + renderPagination({ onPageSizeChange: jest.fn() }); + const select = screen.getByRole('combobox') as HTMLSelectElement; + const optionValues = Array.from(select.options).map((o) => o.value); + expect(optionValues).toEqual(['10', '25', '50', '100']); + }); + }); +}); diff --git a/client/src/components/DataTable/DataTablePagination.tsx b/client/src/components/DataTable/DataTablePagination.tsx new file mode 100644 index 000000000..d508874d7 --- /dev/null +++ b/client/src/components/DataTable/DataTablePagination.tsx @@ -0,0 +1,123 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './DataTable.module.css'; + +export interface DataTablePaginationProps { + currentPage: number; + totalPages: number; + totalItems: number; + pageSize: number; + onPageChange: (page: number) => void; + onPageSizeChange?: (size: number) => void; +} + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +/** + * Pagination controls + * Desktop: full page list + page size selector + * Mobile: simplified Prev/Next + "Page N of M" + */ +export function DataTablePagination({ + currentPage, + totalPages, + totalItems, + pageSize, + onPageChange, + onPageSizeChange, +}: DataTablePaginationProps) { + const { t } = useTranslation('common'); + + const pageNumbers = useMemo(() => { + if (totalPages <= 5) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + if (currentPage <= 3) { + return Array.from({ length: 5 }, (_, i) => i + 1); + } + + if (currentPage >= totalPages - 2) { + return Array.from({ length: 5 }, (_, i) => totalPages - 4 + i); + } + + return Array.from({ length: 5 }, (_, i) => currentPage - 2 + i); + }, [currentPage, totalPages]); + + const showingFrom = (currentPage - 1) * pageSize + 1; + const showingTo = Math.min(currentPage * pageSize, totalItems); + + if (totalPages <= 1) { + return null; + } + + return ( +
+
+ {t('dataTable.pagination.showing', { + from: showingFrom, + to: showingTo, + total: totalItems, + })} +
+ +
+ + +
+ {pageNumbers.map((pageNum) => ( + + ))} +
+ + +
+ + {onPageSizeChange && ( +
+ + +
+ )} +
+ ); +} diff --git a/client/src/components/DataTable/DataTableRow.tsx b/client/src/components/DataTable/DataTableRow.tsx new file mode 100644 index 000000000..7f3abf3b8 --- /dev/null +++ b/client/src/components/DataTable/DataTableRow.tsx @@ -0,0 +1,45 @@ +import type { ColumnDef } from './DataTable.js'; +import styles from './DataTable.module.css'; + +export interface DataTableRowProps { + item: T; + columns: ColumnDef[]; + visibleColumns: Set; + isSelected?: boolean; + onClick?: () => void; + renderActions?: (item: T) => React.ReactNode; +} + +/** + * Single table row renderer + * Renders null values as em-dash + */ +export function DataTableRow({ + item, + columns, + visibleColumns, + isSelected = false, + onClick, + renderActions, +}: DataTableRowProps) { + const visibleCols = columns.filter((col) => visibleColumns.has(col.key)); + + return ( + + {visibleCols.map((col) => ( + + {col.render(item) ?? '—'} + + ))} + {renderActions && ( + e.stopPropagation()}> + {renderActions(item)} + + )} + + ); +} diff --git a/client/src/components/DataTable/filters/BooleanFilter.test.tsx b/client/src/components/DataTable/filters/BooleanFilter.test.tsx new file mode 100644 index 000000000..751959c43 --- /dev/null +++ b/client/src/components/DataTable/filters/BooleanFilter.test.tsx @@ -0,0 +1,86 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BooleanFilter } from './BooleanFilter.js'; + +describe('BooleanFilter', () => { + describe('rendering', () => { + it('renders All, Yes, and No buttons', () => { + render(); + expect(screen.getByRole('button', { name: /all/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /yes/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /no/i })).toBeInTheDocument(); + }); + + it('renders buttons in a group with aria-label', () => { + render(); + expect(screen.getByRole('group')).toBeInTheDocument(); + }); + }); + + describe('aria-pressed state', () => { + it('All button has aria-pressed="true" when value is empty string', () => { + render(); + expect(screen.getByRole('button', { name: /all/i })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('Yes button has aria-pressed="true" when value is "true"', () => { + render(); + expect(screen.getByRole('button', { name: /yes/i })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('No button has aria-pressed="true" when value is "false"', () => { + render(); + expect(screen.getByRole('button', { name: /no/i })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('All button has aria-pressed="false" when value is "true"', () => { + render(); + expect(screen.getByRole('button', { name: /all/i })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('Yes button has aria-pressed="false" when value is "false"', () => { + render(); + expect(screen.getByRole('button', { name: /yes/i })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('No button has aria-pressed="false" when value is "true"', () => { + render(); + expect(screen.getByRole('button', { name: /no/i })).toHaveAttribute('aria-pressed', 'false'); + }); + }); + + describe('click handlers', () => { + it('clicking "Yes" fires onChange with "true"', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render(); + await user.click(screen.getByRole('button', { name: /yes/i })); + expect(mockOnChange).toHaveBeenCalledWith('true'); + }); + + it('clicking "No" fires onChange with "false"', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render(); + await user.click(screen.getByRole('button', { name: /no/i })); + expect(mockOnChange).toHaveBeenCalledWith('false'); + }); + + it('clicking "All" fires onChange with empty string', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render(); + await user.click(screen.getByRole('button', { name: /all/i })); + expect(mockOnChange).toHaveBeenCalledWith(''); + }); + + it('clicking same active button calls onChange again with same value', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render(); + await user.click(screen.getByRole('button', { name: /yes/i })); + expect(mockOnChange).toHaveBeenCalledWith('true'); + }); + }); +}); diff --git a/client/src/components/DataTable/filters/BooleanFilter.tsx b/client/src/components/DataTable/filters/BooleanFilter.tsx new file mode 100644 index 000000000..b131cff1c --- /dev/null +++ b/client/src/components/DataTable/filters/BooleanFilter.tsx @@ -0,0 +1,64 @@ +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './Filter.module.css'; + +export interface BooleanFilterProps { + value: string; + onChange: (value: string) => void; +} + +/** + * 3-button segmented control for boolean filtering + * Values: '' (all), 'true' (yes), 'false' (no) + */ +export function BooleanFilter({ value, onChange }: BooleanFilterProps) { + const { t } = useTranslation('common'); + + const handleSelect = useCallback( + (val: string) => { + onChange(val); + }, + [onChange], + ); + + return ( +
+
+ + + +
+
+ ); +} diff --git a/client/src/components/DataTable/filters/DateFilter.test.tsx b/client/src/components/DataTable/filters/DateFilter.test.tsx new file mode 100644 index 000000000..d6e94155b --- /dev/null +++ b/client/src/components/DataTable/filters/DateFilter.test.tsx @@ -0,0 +1,257 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import type { ReactNode } from 'react'; +import { render as rtlRender, screen, fireEvent } from '@testing-library/react'; +import { DateFilter } from './DateFilter.js'; +import { LocaleProvider } from '../../../contexts/LocaleContext.js'; + +/** + * Custom render function that wraps the component with LocaleProvider + */ +function render(component: ReactNode, options?: Parameters[1]) { + return rtlRender({component}, options); +} + +/** + * Helper to find a day button by its text content + */ +function findDayButton(container: HTMLElement, dayNumber: number): HTMLButtonElement | null { + const dayButtons = Array.from(container.querySelectorAll('button')).filter( + (btn) => + btn.textContent === String(dayNumber) && + !btn.getAttribute('aria-label')?.includes('Previous') && + !btn.getAttribute('aria-label')?.includes('Next'), + ); + return dayButtons[0] || null; +} + +describe('DateFilter', () => { + describe('rendering', () => { + it('renders the DateRangePicker component with calendar grid', () => { + const { container } = render(); + const grid = container.querySelector('[role="grid"]'); + expect(grid).toBeInTheDocument(); + }); + + it('does NOT render any elements', () => { + const { container } = render(); + const dateInputs = container.querySelectorAll('input[type="date"]'); + expect(dateInputs).toHaveLength(0); + }); + + it('renders the calendar with day buttons', () => { + const { container } = render(); + const dayButtons = Array.from(container.querySelectorAll('button')).filter((btn) => + /^\d{1,2}$/.test(btn.textContent?.trim() || ''), + ); + expect(dayButtons.length).toBeGreaterThan(0); + }); + + it('does not render Apply button', () => { + render(); + expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument(); + }); + + it('does not render Clear button', () => { + render(); + expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument(); + }); + }); + + describe('value parsing', () => { + it('parses from date from "from:2026-03-15,to:2026-03-25" format and shows it selected', () => { + const { container } = render( + , + ); + const dayBtn15 = findDayButton(container, 15); + expect(dayBtn15).toHaveClass('daySelected'); + }); + + it('parses to date from "from:2026-03-15,to:2026-03-25" format and shows it selected', () => { + const { container } = render( + , + ); + const dayBtn25 = findDayButton(container, 25); + expect(dayBtn25).toHaveClass('daySelected'); + }); + + it('renders with no selected days when value is empty', () => { + const { container } = render(); + const selectedDays = Array.from(container.querySelectorAll('button')).filter( + (btn) => btn.getAttribute('aria-pressed') === 'true', + ); + expect(selectedDays).toHaveLength(0); + }); + + it('handles "from:2026-03-15" format with no to date', () => { + const { container } = render(); + const dayBtn15 = findDayButton(container, 15); + expect(dayBtn15).toHaveAttribute('aria-pressed', 'true'); + }); + }); + + describe('auto-apply on selection', () => { + it('clicking a day when no start date does NOT call onChange (partial selection)', () => { + const mockOnChange = jest.fn(); + const { container } = render(); + const dayBtn15 = findDayButton(container, 15); + fireEvent.click(dayBtn15!); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('after selecting start date, clicking end date calls onChange with "from:...,to:..."', () => { + const mockOnChange = jest.fn(); + const { container: container1 } = render(); + const dayBtn15 = findDayButton(container1, 15); + fireEvent.click(dayBtn15!); + + mockOnChange.mockClear(); + // Now render with startDate set + const { container: container2 } = render( + , + ); + + const dayBtn25 = findDayButton(container2, 25); + fireEvent.click(dayBtn25!); + + expect(mockOnChange).toHaveBeenCalledWith( + expect.stringMatching(/^from:2026-03-15,to:2026-03-25$/), + ); + }); + + it('onChange is called immediately when both dates are selected', () => { + const mockOnChange = jest.fn(); + const { container: container1 } = render(); + const dayBtn15 = findDayButton(container1, 15); + fireEvent.click(dayBtn15!); + + mockOnChange.mockClear(); + // Now render with startDate set + const { container: container2 } = render( + , + ); + + const dayBtn25 = findDayButton(container2, 25); + fireEvent.click(dayBtn25!); + + expect(mockOnChange).toHaveBeenCalledTimes(1); + }); + }); + + describe('value format', () => { + it('partial selection does not emit onChange', () => { + const mockOnChange = jest.fn(); + const { container } = render(); + const dayBtn15 = findDayButton(container, 15); + fireEvent.click(dayBtn15!); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it('when both dates are empty, onChange receives empty string', () => { + const mockOnChange = jest.fn(); + const { rerender, container } = render( + , + ); + const dayBtn15 = findDayButton(container, 15); + + // Click the start date again to clear both + fireEvent.click(dayBtn15!); + + expect(mockOnChange).toHaveBeenCalledWith(''); + }); + + it('from part uses "from:" prefix when both dates set', () => { + const mockOnChange = jest.fn(); + const { container: container1 } = render(); + const dayBtn15 = findDayButton(container1, 15); + fireEvent.click(dayBtn15!); + + mockOnChange.mockClear(); + const { container: container2 } = render( + , + ); + + const dayBtn25 = findDayButton(container2, 25); + fireEvent.click(dayBtn25!); + + const calledValue = mockOnChange.mock.calls[0][0]; + expect(calledValue).toMatch(/^from:/); + }); + + it('to part uses "to:" prefix', () => { + const mockOnChange = jest.fn(); + const { container: container1 } = render(); + const dayBtn15 = findDayButton(container1, 15); + fireEvent.click(dayBtn15!); + + mockOnChange.mockClear(); + const { container: container2 } = render( + , + ); + + const dayBtn25 = findDayButton(container2, 25); + fireEvent.click(dayBtn25!); + + const calledValue = mockOnChange.mock.calls[0][0]; + expect(calledValue).toMatch(/to:/); + }); + + it('parts are comma-separated', () => { + const mockOnChange = jest.fn(); + const { container: container1 } = render(); + const dayBtn15 = findDayButton(container1, 15); + fireEvent.click(dayBtn15!); + + mockOnChange.mockClear(); + const { container: container2 } = render( + , + ); + + const dayBtn25 = findDayButton(container2, 25); + fireEvent.click(dayBtn25!); + + const calledValue = mockOnChange.mock.calls[0][0]; + expect(calledValue).toMatch(/from:.+,to:.+/); + }); + }); + + describe('calendar interaction', () => { + it('hovering over a date after start shows range highlight', () => { + const { rerender, container } = render( + , + ); + const dayBtn25 = findDayButton(container, 25); + fireEvent.mouseEnter(dayBtn25!); + // The range should be visually highlighted via dayInRange class + const dayCellContainer = dayBtn25?.closest('[role="gridcell"]'); + expect(dayCellContainer).toBeInTheDocument(); + }); + + it('dates before start date have dayDisabled CSS class but remain clickable to reset selection', () => { + const { container } = render(); + const dayBtn10 = findDayButton(container, 10); + expect(dayBtn10).toBeTruthy(); + expect(dayBtn10).not.toBeDisabled(); + expect(dayBtn10).toHaveClass('dayDisabled'); + }); + }); + + describe('edge cases', () => { + it('handles parsing "from:" without a value', () => { + const { container } = render(); + const grid = container.querySelector('[role="grid"]'); + expect(grid).toBeInTheDocument(); // Should render without crashing + }); + + it('handles parsing "to:" without a value', () => { + const { container } = render(); + const grid = container.querySelector('[role="grid"]'); + expect(grid).toBeInTheDocument(); // Should render without crashing + }); + + it('ignores malformed date strings in filter value', () => { + const { container } = render(); + const grid = container.querySelector('[role="grid"]'); + expect(grid).toBeInTheDocument(); // Should render without crashing + }); + }); +}); diff --git a/client/src/components/DataTable/filters/DateFilter.tsx b/client/src/components/DataTable/filters/DateFilter.tsx new file mode 100644 index 000000000..4611b96d4 --- /dev/null +++ b/client/src/components/DataTable/filters/DateFilter.tsx @@ -0,0 +1,60 @@ +import { useCallback, useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DateRangePicker } from '../../DateRangePicker/index.js'; +import styles from './Filter.module.css'; + +export interface DateFilterProps { + value: string; + onChange: (value: string) => void; +} + +/** + * From/to date range filter for DataTable + * Stores as "from:YYYY-MM-DD,to:YYYY-MM-DD" format + * Auto-applies on date selection change + */ +export function DateFilter({ value, onChange }: DateFilterProps) { + const { t } = useTranslation('common'); + + const from = value.match(/from:(\d{4}-\d{2}-\d{2})/)?.[1] ?? ''; + const to = value.match(/to:(\d{4}-\d{2}-\d{2})/)?.[1] ?? ''; + + // Track intermediate dates locally so DateRangePicker sees updates immediately + const [localFrom, setLocalFrom] = useState(''); + const [localTo, setLocalTo] = useState(''); + + // Sync local state when the parent value prop changes (e.g., clear filters) + useEffect(() => { + setLocalFrom(from); + setLocalTo(to); + }, [from, to]); + + const handleChange = useCallback( + (newFrom: string, newTo: string) => { + // Always update local state so DateRangePicker sees the updated dates + setLocalFrom(newFrom); + setLocalTo(newTo); + + // Only emit onChange when BOTH dates are set or BOTH are cleared + if (newFrom && newTo) { + onChange(`from:${newFrom},to:${newTo}`); + } else if (!newFrom && !newTo) { + onChange(''); + } + // Partial selection: don't emit to parent — let user finish picking + // But DateRangePicker will see the updated localFrom/localTo via re-render + }, + [onChange], + ); + + return ( +
+ +
+ ); +} diff --git a/client/src/components/DataTable/filters/EntityFilter.test.tsx b/client/src/components/DataTable/filters/EntityFilter.test.tsx new file mode 100644 index 000000000..5f697e832 --- /dev/null +++ b/client/src/components/DataTable/filters/EntityFilter.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +interface TestEntity { + id: string; + name: string; +} + +// Mock SearchPicker to avoid async complexity — just render a simple input +const mockSearchPickerOnChange = jest.fn<(id: string) => void>(); + +jest.unstable_mockModule('../../SearchPicker/SearchPicker.js', () => ({ + SearchPicker: ({ + value, + onChange, + placeholder, + }: { + value: string; + onChange: (id: string) => void; + placeholder?: string; + }) => { + // Keep a ref to onChange so tests can trigger it + mockSearchPickerOnChange.mockImplementation(onChange); + return ( + onChange(e.target.value)} + /> + ); + }, +})); + +import type * as EntityFilterModule from './EntityFilter.js'; + +let EntityFilter: (typeof EntityFilterModule)['EntityFilter']; + +const mockSearchFn = jest.fn<(query: string, excludeIds: string[]) => Promise>(); +const mockRenderItem = (item: TestEntity) => ({ id: item.id, label: item.name }); + +beforeEach(async () => { + ({ EntityFilter } = (await import('./EntityFilter.js')) as typeof EntityFilterModule); + mockSearchFn.mockReset(); + mockSearchPickerOnChange.mockClear(); +}); + +describe('EntityFilter', () => { + it('renders the SearchPicker component', () => { + render( + , + ); + expect(screen.getByTestId('mock-search-picker')).toBeInTheDocument(); + }); + + it('passes the current value to SearchPicker', () => { + render( + , + ); + expect(screen.getByTestId('mock-search-picker')).toHaveValue('entity-123'); + }); + + it('passes custom placeholder to SearchPicker', () => { + render( + , + ); + expect(screen.getByPlaceholderText('Pick a vendor...')).toBeInTheDocument(); + }); + + it('calls onChange when SearchPicker selection changes', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render( + , + ); + const input = screen.getByTestId('mock-search-picker'); + await user.clear(input); + await user.type(input, 'vendor-42'); + // The mock input calls onChange on each character — find the last call + const calls = mockOnChange.mock.calls as [string][]; + const lastCall = calls[calls.length - 1][0]; + expect(lastCall).toContain('vendor-4'); + }); + + it('wires onChange directly to SearchPicker onChange prop', () => { + const mockOnChange = jest.fn(); + render( + , + ); + // Simulate direct invocation of the onChange prop + mockSearchPickerOnChange('entity-999'); + expect(mockOnChange).toHaveBeenCalledWith('entity-999'); + }); +}); diff --git a/client/src/components/DataTable/filters/EntityFilter.tsx b/client/src/components/DataTable/filters/EntityFilter.tsx new file mode 100644 index 000000000..cfa519d75 --- /dev/null +++ b/client/src/components/DataTable/filters/EntityFilter.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; +import { SearchPicker, type SearchPickerProps } from '../../SearchPicker/SearchPicker.js'; + +export interface EntityFilterProps { + value: string; + onChange: (value: string) => void; + searchFn: SearchPickerProps['searchFn']; + renderItem: SearchPickerProps['renderItem']; + placeholder?: string; +} + +/** + * Entity selector filter using SearchPicker + * Allows searching and selecting from related entities + */ +export function EntityFilter({ + value, + onChange, + searchFn, + renderItem, + placeholder, +}: EntityFilterProps) { + const { t } = useTranslation('common'); + + return ( + + value={value} + onChange={onChange} + excludeIds={[]} + searchFn={searchFn} + renderItem={renderItem} + placeholder={placeholder || t('dataTable.filter.searchPlaceholder')} + showItemsOnFocus + /> + ); +} diff --git a/client/src/components/DataTable/filters/EnumFilter.test.tsx b/client/src/components/DataTable/filters/EnumFilter.test.tsx new file mode 100644 index 000000000..7182b5772 --- /dev/null +++ b/client/src/components/DataTable/filters/EnumFilter.test.tsx @@ -0,0 +1,152 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { EnumOption } from '../DataTable.js'; +import { EnumFilter } from './EnumFilter.js'; + +const OPTIONS: EnumOption[] = [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + { value: 'pending', label: 'Pending' }, +]; + +describe('EnumFilter', () => { + describe('rendering', () => { + it('renders a checkbox for each option', () => { + render(); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes).toHaveLength(3); + }); + + it('renders option labels', () => { + render(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Inactive')).toBeInTheDocument(); + expect(screen.getByText('Pending')).toBeInTheDocument(); + }); + + it('pre-checks options that are in the initial value', () => { + render(); + expect(screen.getByRole('checkbox', { name: 'Active' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Inactive' })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Pending' })).toBeChecked(); + }); + + it('does not render an Apply button', () => { + render(); + expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument(); + }); + + it('does not render a Clear button', () => { + render(); + expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument(); + }); + + it('renders Select All quick action button', () => { + render(); + expect(screen.getByRole('button', { name: /select all/i })).toBeInTheDocument(); + }); + + it('renders Select None quick action button', () => { + render(); + expect(screen.getByRole('button', { name: /select none/i })).toBeInTheDocument(); + }); + }); + + describe('auto-apply on checkbox change', () => { + it('calls onChange immediately when an unchecked option is checked', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Active' })); + expect(mockOnChange).toHaveBeenCalledWith('active'); + }); + + it('calls onChange immediately when a checked option is unchecked', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Active' })); + expect(mockOnChange).toHaveBeenCalledWith(''); + }); + + it('calls onChange with comma-separated values when multiple options are selected', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Active' })); + await user.click(screen.getByRole('checkbox', { name: 'Pending' })); + // Second call should contain both active and pending + const lastCallArg = ( + mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1] as [string] + )[0]; + expect(lastCallArg.split(',')).toEqual(expect.arrayContaining(['active', 'pending'])); + expect(lastCallArg.split(',')).toHaveLength(2); + }); + + it('calls onChange after each individual checkbox click, not batched', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render(); + await user.click(screen.getByRole('checkbox', { name: 'Active' })); + await user.click(screen.getByRole('checkbox', { name: 'Inactive' })); + expect(mockOnChange).toHaveBeenCalledTimes(2); + }); + + it('checkbox is visually checked after clicking an unchecked option', async () => { + const user = userEvent.setup(); + render(); + const activeCheckbox = screen.getByRole('checkbox', { name: 'Active' }); + expect(activeCheckbox).not.toBeChecked(); + await user.click(activeCheckbox); + expect(activeCheckbox).toBeChecked(); + }); + + it('checkbox is visually unchecked after clicking a checked option', async () => { + const user = userEvent.setup(); + render(); + const activeCheckbox = screen.getByRole('checkbox', { name: 'Active' }); + expect(activeCheckbox).toBeChecked(); + await user.click(activeCheckbox); + expect(activeCheckbox).not.toBeChecked(); + }); + }); + + describe('Select All quick action', () => { + it('calls onChange with all option values when Select All clicked', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render(); + await user.click(screen.getByRole('button', { name: /select all/i })); + const callArg = (mockOnChange.mock.calls[0] as [string])[0]; + expect(callArg.split(',')).toEqual(expect.arrayContaining(['active', 'inactive', 'pending'])); + expect(callArg.split(',')).toHaveLength(3); + }); + + it('checks all checkboxes when Select All clicked', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /select all/i })); + const checkboxes = screen.getAllByRole('checkbox'); + checkboxes.forEach((cb) => expect(cb).toBeChecked()); + }); + }); + + describe('Select None quick action', () => { + it('calls onChange with empty string when Select None clicked', async () => { + const user = userEvent.setup(); + const mockOnChange = jest.fn(); + render(); + await user.click(screen.getByRole('button', { name: /select none/i })); + expect(mockOnChange).toHaveBeenCalledWith(''); + }); + + it('unchecks all checkboxes when Select None clicked', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: /select none/i })); + const checkboxes = screen.getAllByRole('checkbox'); + checkboxes.forEach((cb) => expect(cb).not.toBeChecked()); + }); + }); +}); diff --git a/client/src/components/DataTable/filters/EnumFilter.tsx b/client/src/components/DataTable/filters/EnumFilter.tsx new file mode 100644 index 000000000..9d40abe5d --- /dev/null +++ b/client/src/components/DataTable/filters/EnumFilter.tsx @@ -0,0 +1,179 @@ +import { useState, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { EnumOption, EnumHierarchyItem } from '../DataTable.js'; +import styles from './Filter.module.css'; + +export interface EnumFilterProps { + value: string; + onChange: (value: string) => void; + options: EnumOption[]; + hierarchy?: EnumHierarchyItem[]; +} + +/** + * Checkbox list filter for enum/select values + * Stores as comma-separated values + * Auto-applies on checkbox change + * Supports hierarchical options (parent-child relationships) + */ +export function EnumFilter({ value, onChange, options, hierarchy }: EnumFilterProps) { + const { t } = useTranslation('common'); + + const parseValue = (v: string) => new Set(v ? v.split(',') : []); + const [selected, setSelected] = useState(parseValue(value)); + + // Build parent -> children map from hierarchy + const { parentToChildren, allParents } = (() => { + if (!hierarchy) return { parentToChildren: new Map(), allParents: new Set() }; + + const map = new Map(); + const parents = new Set(); + + for (const item of hierarchy) { + if (item.parentId) { + if (!map.has(item.parentId)) { + map.set(item.parentId, []); + } + map.get(item.parentId)!.push(item.id); + } else { + parents.add(item.id); + } + } + + return { parentToChildren: map, allParents: parents }; + })(); + + const handleToggle = useCallback( + (optionValue: string) => { + setSelected((prev) => { + const updated = new Set(prev); + + if (updated.has(optionValue)) { + updated.delete(optionValue); + // If this is a parent, remove all children + const children = parentToChildren.get(optionValue); + if (children) { + for (const child of children) { + updated.delete(child); + } + } + } else { + updated.add(optionValue); + // If this is a parent, add all children + const children = parentToChildren.get(optionValue); + if (children) { + for (const child of children) { + updated.add(child); + } + } + } + + // Auto-apply by calling onChange immediately + const joined = Array.from(updated).join(','); + onChange(joined); + return updated; + }); + }, + [parentToChildren, onChange], + ); + + const handleSelectAll = useCallback(() => { + const allValues = options.map((opt) => opt.value); + setSelected(new Set(allValues)); + onChange(allValues.join(',')); + }, [options, onChange]); + + const handleSelectNone = useCallback(() => { + setSelected(new Set()); + onChange(''); + }, [onChange]); + + // Sort options: parents first (in order), then their children indented + const sortedOptions: Array<{ option: EnumOption; isChild: boolean }> = (() => { + if (!hierarchy) return options.map((o) => ({ option: o, isChild: false })); + + const result: Array<{ option: EnumOption; isChild: boolean }> = []; + const optionMap = new Map(options.map((o) => [o.value, o])); + + // First, add all parents in original order + for (const option of options) { + if (allParents.has(option.value)) { + result.push({ option, isChild: false }); + + // Then add their children + const children = parentToChildren.get(option.value) || []; + for (const childId of children) { + const childOption = optionMap.get(childId); + if (childOption) { + result.push({ option: childOption, isChild: true }); + } + } + } + } + + // Finally, add any options with no parent or child relationship + for (const option of options) { + if (!allParents.has(option.value) && !hierarchy.some((h) => h.id === option.value)) { + result.push({ option, isChild: false }); + } + } + + return result; + })(); + + // Check if a parent has indeterminate state (some but not all children selected) + const isIndeterminate = (parentId: string): boolean => { + const children = parentToChildren.get(parentId); + if (!children || children.length === 0) return false; + + const selectedChildren = children.filter((c: string) => selected.has(c)); + return selectedChildren.length > 0 && selectedChildren.length < children.length; + }; + + // Set indeterminate state on parent checkboxes + const parentCheckboxRefs = useRef>(new Map()); + + return ( +
+
+ + +
+
+ {sortedOptions.map(({ option, isChild }) => { + const isParent = allParents.has(option.value); + + return ( + + ); + })} +
+
+ ); +} diff --git a/client/src/components/DataTable/filters/Filter.module.css b/client/src/components/DataTable/filters/Filter.module.css new file mode 100644 index 000000000..8a5e444f3 --- /dev/null +++ b/client/src/components/DataTable/filters/Filter.module.css @@ -0,0 +1,291 @@ +.filterContent { + display: flex; + flex-direction: column; + gap: var(--spacing-3); + padding: var(--spacing-3); +} + +.filterInput { + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + background-color: var(--color-bg-primary); + transition: var(--transition-input); +} + +.filterInput:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: var(--shadow-focus-subtle); +} + +.filterDateInput { + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + background-color: var(--color-bg-primary); + transition: var(--transition-input); +} + +.filterDateInput:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: var(--shadow-focus-subtle); +} + +.filterDateInputConfirmed { + border-color: var(--color-primary); + background-color: var(--color-primary-bg); +} + +.filterDateInputConfirmed:focus-visible { + box-shadow: var(--shadow-focus-subtle); +} + +.filterActions { + display: flex; + gap: var(--spacing-2); +} + +.filterButton { + flex: 1; + padding: var(--spacing-1-5) var(--spacing-3); + background-color: var(--color-primary); + color: var(--color-primary-text); + border: none; + border-radius: var(--radius-md); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: background-color var(--transition-normal); +} + +.filterButton:hover:not(:disabled) { + background-color: var(--color-primary-hover); +} + +.filterButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.filterButton:disabled { + background-color: var(--color-text-placeholder); + cursor: not-allowed; +} + +.filterButtonSecondary { + flex: 1; + padding: var(--spacing-1-5) var(--spacing-3); + background-color: var(--color-bg-tertiary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: background-color var(--transition-normal); +} + +.filterButtonSecondary:hover:not(:disabled) { + background-color: var(--color-border); +} + +.filterButtonSecondary:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.filterButtonSecondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.filterRangeRow { + display: flex; + gap: var(--spacing-2); + align-items: center; +} + +.filterRangeInput { + flex: 1; + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + background-color: var(--color-bg-primary); + transition: var(--transition-input); +} + +.filterRangeInput:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: var(--shadow-focus-subtle); +} + +.filterRangeLabel { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); + min-width: 30px; + text-align: center; +} + +.filterCheckboxGroup { + display: flex; + flex-direction: column; + gap: var(--spacing-2); + max-height: 300px; + overflow-y: auto; +} + +.filterCheckboxItem { + display: flex; + align-items: center; + gap: var(--spacing-2); + padding: var(--spacing-1-5) var(--spacing-2); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color var(--transition-normal); + /* Override default label styles */ + margin: 0; + padding: var(--spacing-1-5) var(--spacing-2); +} + +.filterCheckboxItem:hover { + background-color: var(--color-bg-secondary); +} + +.filterCheckboxItem:has(input:focus-visible) { + outline: none; + box-shadow: var(--shadow-focus-subtle); +} + +.filterCheckbox { + width: 18px; + height: 18px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-sm); + cursor: pointer; + accent-color: var(--color-primary); +} + +.filterCheckbox:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.filterCheckboxLabel { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + flex: 1; + cursor: pointer; +} + +.filterCheckboxIndented { + padding-left: calc(var(--spacing-2) + 24px); + font-weight: var(--font-weight-normal); + opacity: 0.9; +} + +.filterQuickActions { + display: flex; + gap: var(--spacing-2); + margin-bottom: var(--spacing-1); +} + +.filterQuickActionButton { + flex: 1; + padding: var(--spacing-1) var(--spacing-2); + background-color: var(--color-bg-secondary); + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--font-size-xs); + cursor: pointer; + transition: + background-color var(--transition-normal), + color var(--transition-normal); +} + +.filterQuickActionButton:hover { + background-color: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.filterQuickActionButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.filterSegmentedControl { + display: flex; + gap: 0; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + overflow: hidden; +} + +.filterSegmentedButton { + flex: 1; + padding: var(--spacing-2) var(--spacing-3); + background-color: var(--color-bg-primary); + color: var(--color-text-secondary); + border: none; + border-right: 1px solid var(--color-border-strong); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: + background-color var(--transition-normal), + color var(--transition-normal); +} + +.filterSegmentedButton:last-child { + border-right: none; +} + +.filterSegmentedButton:hover:not(.filterSegmentedButtonActive) { + background-color: var(--color-bg-secondary); +} + +.filterSegmentedButtonActive { + background-color: var(--color-primary); + color: var(--color-primary-text); +} + +.filterSegmentedButton:focus-visible { + outline: none; + box-shadow: inset var(--shadow-focus-subtle); +} + +.filterRangeSlider { + width: 100%; + accent-color: var(--color-primary); + cursor: pointer; + height: 4px; + margin: var(--spacing-1) 0; +} + +.filterRangeSlider:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +@media (prefers-reduced-motion: reduce) { + .filterInput, + .filterDateInput, + .filterRangeInput, + .filterButton, + .filterButtonSecondary, + .filterCheckboxItem, + .filterSegmentedButton, + .filterQuickActionButton { + transition: none; + } +} diff --git a/client/src/components/DataTable/filters/NumberFilter.test.tsx b/client/src/components/DataTable/filters/NumberFilter.test.tsx new file mode 100644 index 000000000..6904d24f8 --- /dev/null +++ b/client/src/components/DataTable/filters/NumberFilter.test.tsx @@ -0,0 +1,198 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { NumberFilter } from './NumberFilter.js'; + +describe('NumberFilter', () => { + describe('rendering', () => { + it('renders min and max number inputs', () => { + render(); + const inputs = screen.getAllByRole('spinbutton'); + expect(inputs).toHaveLength(2); + }); + + it('renders min label', () => { + render(); + expect(screen.getByText(/min/i)).toBeInTheDocument(); + }); + + it('renders max label', () => { + render(); + expect(screen.getByText(/max/i)).toBeInTheDocument(); + }); + + it('parses existing min value from "min:100,max:500" format', () => { + render(); + const inputs = screen.getAllByRole('spinbutton'); + expect(inputs[0]).toHaveValue(100); + }); + + it('parses existing max value from "min:100,max:500" format', () => { + render(); + const inputs = screen.getAllByRole('spinbutton'); + expect(inputs[1]).toHaveValue(500); + }); + + it('initializes with empty inputs when value is empty string', () => { + render(); + const inputs = screen.getAllByRole('spinbutton'); + expect(inputs[0]).toHaveValue(null); + expect(inputs[1]).toHaveValue(null); + }); + + it('does not render an Apply button', () => { + render(); + expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument(); + }); + + it('does not render a Clear button', () => { + render(); + expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument(); + }); + }); + + describe('auto-apply on input change', () => { + it('calls onChange immediately when min input changes', () => { + const mockOnChange = jest.fn(); + render(); + const [minInput] = screen.getAllByRole('spinbutton'); + fireEvent.change(minInput, { target: { value: '100' } }); + expect(mockOnChange).toHaveBeenCalledWith('min:100'); + }); + + it('calls onChange immediately when max input changes', () => { + const mockOnChange = jest.fn(); + render(); + const [, maxInput] = screen.getAllByRole('spinbutton'); + fireEvent.change(maxInput, { target: { value: '500' } }); + expect(mockOnChange).toHaveBeenCalledWith('max:500'); + }); + + it('calls onChange with "min:X,max:Y" when both values are set', () => { + const mockOnChange = jest.fn(); + render(); + const [minInput, maxInput] = screen.getAllByRole('spinbutton'); + fireEvent.change(minInput, { target: { value: '100' } }); + mockOnChange.mockClear(); + fireEvent.change(maxInput, { target: { value: '500' } }); + expect(mockOnChange).toHaveBeenCalledWith('min:100,max:500'); + }); + + it('calls onChange with empty string when min input is cleared', () => { + const mockOnChange = jest.fn(); + render(); + const [minInput] = screen.getAllByRole('spinbutton'); + fireEvent.change(minInput, { target: { value: '' } }); + expect(mockOnChange).toHaveBeenCalledWith(''); + }); + + it('calls onChange without requiring a button click', () => { + const mockOnChange = jest.fn(); + render(); + const [minInput] = screen.getAllByRole('spinbutton'); + fireEvent.change(minInput, { target: { value: '42' } }); + expect(mockOnChange).toHaveBeenCalledTimes(1); + expect(mockOnChange).toHaveBeenCalledWith('min:42'); + }); + }); + + describe('input interaction', () => { + it('updates min input as user types', async () => { + const user = userEvent.setup(); + render(); + const [minInput] = screen.getAllByRole('spinbutton'); + await user.clear(minInput); + await user.type(minInput, '50'); + expect(minInput).toHaveValue(50); + }); + + it('updates max input as user types', async () => { + const user = userEvent.setup(); + render(); + const [, maxInput] = screen.getAllByRole('spinbutton'); + await user.clear(maxInput); + await user.type(maxInput, '999'); + expect(maxInput).toHaveValue(999); + }); + }); + + describe('configurable min/max bounds (#1139)', () => { + it('applies custom min to both number inputs and range sliders', () => { + const { container } = render( + , + ); + const sliders = container.querySelectorAll('input[type="range"]'); + const spinbuttons = screen.getAllByRole('spinbutton'); + + expect(sliders[0]).toHaveAttribute('min', '100'); + expect(sliders[1]).toHaveAttribute('min', '100'); + expect(spinbuttons[0]).toHaveAttribute('min', '100'); + expect(spinbuttons[1]).toHaveAttribute('min', '100'); + }); + + it('applies custom max to both number inputs and range sliders', () => { + const { container } = render( + , + ); + const sliders = container.querySelectorAll('input[type="range"]'); + const spinbuttons = screen.getAllByRole('spinbutton'); + + expect(sliders[0]).toHaveAttribute('max', '10000'); + expect(sliders[1]).toHaveAttribute('max', '10000'); + expect(spinbuttons[0]).toHaveAttribute('max', '10000'); + expect(spinbuttons[1]).toHaveAttribute('max', '10000'); + }); + + it('uses custom min as placeholder for the min number input', () => { + render(); + const [minInput] = screen.getAllByRole('spinbutton'); + expect(minInput).toHaveAttribute('placeholder', '100'); + }); + + it('uses custom max as placeholder for the max number input', () => { + render(); + const [, maxInput] = screen.getAllByRole('spinbutton'); + expect(maxInput).toHaveAttribute('placeholder', '10000'); + }); + + it('defaults min to 0 when no min prop is provided', () => { + const { container } = render(); + const sliders = container.querySelectorAll('input[type="range"]'); + const [minInput] = screen.getAllByRole('spinbutton'); + + expect(sliders[0]).toHaveAttribute('min', '0'); + expect(minInput).toHaveAttribute('min', '0'); + expect(minInput).toHaveAttribute('placeholder', '0'); + }); + + it('defaults max to 999999 when no max prop is provided', () => { + const { container } = render(); + const sliders = container.querySelectorAll('input[type="range"]'); + const [, maxInput] = screen.getAllByRole('spinbutton'); + + expect(sliders[1]).toHaveAttribute('max', '999999'); + expect(maxInput).toHaveAttribute('max', '999999'); + expect(maxInput).toHaveAttribute('placeholder', '999999'); + }); + + it('applies custom step to both number inputs and range sliders', () => { + const { container } = render(); + const sliders = container.querySelectorAll('input[type="range"]'); + const spinbuttons = screen.getAllByRole('spinbutton'); + + expect(sliders[0]).toHaveAttribute('step', '0.01'); + expect(sliders[1]).toHaveAttribute('step', '0.01'); + expect(spinbuttons[0]).toHaveAttribute('step', '0.01'); + expect(spinbuttons[1]).toHaveAttribute('step', '0.01'); + }); + + it('defaults step to 1 when no step prop is provided', () => { + const { container } = render(); + const sliders = container.querySelectorAll('input[type="range"]'); + const [minInput] = screen.getAllByRole('spinbutton'); + + expect(sliders[0]).toHaveAttribute('step', '1'); + expect(minInput).toHaveAttribute('step', '1'); + }); + }); +}); diff --git a/client/src/components/DataTable/filters/NumberFilter.tsx b/client/src/components/DataTable/filters/NumberFilter.tsx new file mode 100644 index 000000000..5b21d7c5b --- /dev/null +++ b/client/src/components/DataTable/filters/NumberFilter.tsx @@ -0,0 +1,118 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './Filter.module.css'; + +export interface NumberFilterProps { + value: string; + onChange: (value: string) => void; + min?: number; + max?: number; + step?: number; +} + +/** + * Min/max number range filter for DataTable + * Stores as "min:X,max:Y" format + * Auto-applies on input/slider change + */ +export function NumberFilter({ + value, + onChange, + min: minBound, + max: maxBound, + step, +}: NumberFilterProps) { + const { t } = useTranslation('common'); + + const parseValue = (v: string) => { + const min = v.match(/min:([\d.]+)/)?.[1] || ''; + const max = v.match(/max:([\d.]+)/)?.[1] || ''; + return { min, max }; + }; + + const { min, max } = parseValue(value); + const [localMin, setLocalMin] = useState(min); + const [localMax, setLocalMax] = useState(max); + + const emitChange = useCallback( + (newMin: string, newMax: string) => { + const parts = []; + if (newMin) parts.push(`min:${newMin}`); + if (newMax) parts.push(`max:${newMax}`); + onChange(parts.join(',')); + }, + [onChange], + ); + + const handleMinChange = useCallback( + (newMin: string) => { + setLocalMin(newMin); + emitChange(newMin, localMax); + }, + [localMax, emitChange], + ); + + const handleMaxChange = useCallback( + (newMax: string) => { + setLocalMax(newMax); + emitChange(localMin, newMax); + }, + [localMin, emitChange], + ); + + const defaultMin = minBound ?? 0; + const defaultMax = maxBound ?? 999999; + const defaultStep = step ?? 1; + + return ( +
+
+ + handleMinChange(e.target.value)} + placeholder={String(defaultMin)} + className={styles.filterRangeInput} + min={defaultMin} + max={defaultMax} + step={defaultStep} + autoFocus + /> +
+ handleMinChange(e.target.value)} + className={styles.filterRangeSlider} + min={defaultMin} + max={defaultMax} + step={defaultStep} + aria-label={t('dataTable.filter.min')} + /> +
+ + handleMaxChange(e.target.value)} + placeholder={String(defaultMax)} + className={styles.filterRangeInput} + min={defaultMin} + max={defaultMax} + step={defaultStep} + /> +
+ handleMaxChange(e.target.value)} + className={styles.filterRangeSlider} + min={defaultMin} + max={defaultMax} + step={defaultStep} + aria-label={t('dataTable.filter.max')} + /> +
+ ); +} diff --git a/client/src/components/DataTable/filters/StringFilter.test.tsx b/client/src/components/DataTable/filters/StringFilter.test.tsx new file mode 100644 index 000000000..85d27f9d4 --- /dev/null +++ b/client/src/components/DataTable/filters/StringFilter.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, jest } from '@jest/globals'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { StringFilter } from './StringFilter.js'; + +describe('StringFilter', () => { + describe('rendering', () => { + it('renders a text input with the given initial value', () => { + render(); + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + expect(input).toHaveValue('hello'); + }); + + it('renders with empty value when no initial value provided', () => { + render(); + expect(screen.getByRole('textbox')).toHaveValue(''); + }); + + it('renders with custom placeholder when provided', () => { + render(); + expect(screen.getByPlaceholderText('Search names...')).toBeInTheDocument(); + }); + + it('does not render an Apply button', () => { + render(); + expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument(); + }); + + it('does not render a Clear button', () => { + render(); + expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument(); + }); + }); + + describe('auto-apply on change', () => { + it('calls onChange immediately when input value changes', () => { + const mockOnChange = jest.fn(); + render(); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'foo' } }); + expect(mockOnChange).toHaveBeenCalledWith('foo'); + }); + + it('calls onChange on each keystroke without requiring a button click', () => { + const mockOnChange = jest.fn(); + render(); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.change(input, { target: { value: 'ab' } }); + fireEvent.change(input, { target: { value: 'abc' } }); + expect(mockOnChange).toHaveBeenCalledTimes(3); + expect(mockOnChange).toHaveBeenLastCalledWith('abc'); + }); + + it('calls onChange with empty string when input is cleared', () => { + const mockOnChange = jest.fn(); + render(); + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: '' } }); + expect(mockOnChange).toHaveBeenCalledWith(''); + }); + + it('calls onChange with the full current input value on change', () => { + const mockOnChange = jest.fn(); + render(); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'foo bar' } }); + expect(mockOnChange).toHaveBeenCalledWith('foo bar'); + }); + }); +}); diff --git a/client/src/components/DataTable/filters/StringFilter.tsx b/client/src/components/DataTable/filters/StringFilter.tsx new file mode 100644 index 000000000..9c2860c75 --- /dev/null +++ b/client/src/components/DataTable/filters/StringFilter.tsx @@ -0,0 +1,38 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './Filter.module.css'; + +export interface StringFilterProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +/** + * Text input filter for DataTable + * Auto-applies on input change + */ +export function StringFilter({ value, onChange, placeholder = 'Filter...' }: StringFilterProps) { + const { t } = useTranslation('common'); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + // Auto-apply on every keystroke + onChange(e.target.value); + }, + [onChange], + ); + + return ( +
+ +
+ ); +} diff --git a/client/src/components/DataTable/index.ts b/client/src/components/DataTable/index.ts new file mode 100644 index 000000000..6b8c479b6 --- /dev/null +++ b/client/src/components/DataTable/index.ts @@ -0,0 +1,38 @@ +export { DataTable } from './DataTable.js'; +export type { + DataTableProps, + ColumnDef, + TableState, + TableApiParams, + FilterType, + EnumOption, + ActiveFilter, +} from './DataTable.js'; + +export { DataTableHeader } from './DataTableHeader.js'; +export { DataTableRow } from './DataTableRow.js'; +export { DataTableCard } from './DataTableCard.js'; +export { DataTablePagination } from './DataTablePagination.js'; +export { DataTableColumnSettings } from './DataTableColumnSettings.js'; +export { DataTableFilterPopover } from './DataTableFilterPopover.js'; + +// Filters +export { StringFilter } from './filters/StringFilter.js'; +export { NumberFilter } from './filters/NumberFilter.js'; +export { DateFilter } from './filters/DateFilter.js'; +export { EnumFilter } from './filters/EnumFilter.js'; +export { BooleanFilter } from './filters/BooleanFilter.js'; +export { EntityFilter } from './filters/EntityFilter.js'; + +export type { StringFilterProps } from './filters/StringFilter.js'; +export type { NumberFilterProps } from './filters/NumberFilter.js'; +export type { DateFilterProps } from './filters/DateFilter.js'; +export type { EnumFilterProps } from './filters/EnumFilter.js'; +export type { BooleanFilterProps } from './filters/BooleanFilter.js'; +export type { EntityFilterProps } from './filters/EntityFilter.js'; + +// Hooks +export { useTableState } from '../../hooks/useTableState.js'; +export { useColumnPreferences } from '../../hooks/useColumnPreferences.js'; +export type { UseTableStateResult, UseTableStateOptions } from '../../hooks/useTableState.js'; +export type { UseColumnPreferencesResult } from '../../hooks/useColumnPreferences.js'; diff --git a/client/src/components/DateRangePicker/DateRangePicker.module.css b/client/src/components/DateRangePicker/DateRangePicker.module.css new file mode 100644 index 000000000..fb22995e6 --- /dev/null +++ b/client/src/components/DateRangePicker/DateRangePicker.module.css @@ -0,0 +1,241 @@ +/* ============================================================ + * DateRangePicker — calendar-based date range selection + * ============================================================ */ + +.picker { + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +/* ---- Navigation toolbar ---- */ + +.nav { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-2); +} + +.navButton { + width: var(--spacing-8); + height: var(--spacing-8); + display: flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-bg-primary); + color: var(--color-text-secondary); + cursor: pointer; + transition: + background-color var(--transition-fast), + color var(--transition-fast); + padding: 0; +} + +.navButton:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); +} + +.navButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.navButton:active { + background: var(--color-bg-tertiary); +} + +.monthLabel { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + text-align: center; + flex: 1; + white-space: nowrap; +} + +/* ---- Day headers (Sun–Sat) ---- */ + +.dayHeader { + text-align: center; + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-muted); + padding: var(--spacing-1) 0; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +/* ---- Calendar grid ---- */ + +.grid { + display: flex; + flex-direction: column; + gap: 0; +} + +.weekRow { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0; +} + +.dayCell { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-0-5); + aspect-ratio: 1; + /* Range bar background behind button */ + background: transparent; +} + +.dayInRange { + background: var(--color-primary-bg); +} + +/* Range endpoint half-backgrounds using linear-gradient */ +.dayRangeStart { + background: linear-gradient(to right, transparent 50%, var(--color-primary-bg) 50%); +} + +.dayRangeEnd { + background: linear-gradient(to left, transparent 50%, var(--color-primary-bg) 50%); +} + +.dayButton { + width: var(--spacing-7); + height: var(--spacing-7); + min-height: var(--spacing-7); + min-width: var(--spacing-7); + border-radius: var(--radius-circle); + border: none; + background: transparent; + color: var(--color-text-primary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-normal); + cursor: pointer; + transition: background-color var(--transition-fast); + display: flex; + align-items: center; + justify-content: center; + padding: 0; + flex-shrink: 0; +} + +.dayButton:hover:not(.daySelected):not(.dayDisabled) { + background: var(--color-bg-hover); +} + +.dayButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.dayButton:active:not(.dayDisabled) { + background: var(--color-bg-tertiary); +} + +/* Today highlight — bold text */ +.dayToday { + font-weight: var(--font-weight-bold); +} + +/* Days outside current month — dimmed */ +.dayOtherMonth { + color: var(--color-text-placeholder); + opacity: 0.6; +} + +/* Disabled days (before start date when selecting end) */ +.dayDisabled { + opacity: 0.4; + cursor: not-allowed; +} + +.dayDisabled:hover { + background: transparent; +} + +/* Selected day (start or end) — blue background */ +.daySelected { + background: var(--color-primary); + color: var(--color-primary-text); + font-weight: var(--font-weight-bold); +} + +.daySelected:hover { + background: var(--color-primary-hover); +} + +.daySelected:focus-visible { + box-shadow: var(--shadow-focus); +} + +.daySelected:active { + background: var(--color-primary-active); +} + +/* ---- Phase indicator label ---- */ + +.phaseLabel { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + text-align: center; + padding: var(--spacing-1) 0; + font-weight: var(--font-weight-normal); +} + +/* ---- Responsive ---- */ + +@media (max-width: 1023px) { + .navButton { + width: var(--spacing-10); + height: var(--spacing-10); + } + + .dayButton { + width: 36px; + height: 36px; + min-height: 36px; + min-width: 36px; + } +} + +@media (max-width: 767px) { + .navButton { + width: var(--spacing-10); + height: var(--spacing-10); + } + + .dayButton { + width: 36px; + height: 36px; + min-height: 36px; + min-width: 36px; + } + + .dayCell { + padding: var(--spacing-1); + } + + .monthLabel { + font-size: var(--font-size-base); + } + + .dayHeader { + font-size: var(--font-size-2xs); + } +} + +/* ---- Reduced motion ---- */ + +@media (prefers-reduced-motion: reduce) { + .navButton, + .dayButton { + transition: none; + } +} diff --git a/client/src/components/DateRangePicker/DateRangePicker.test.tsx b/client/src/components/DateRangePicker/DateRangePicker.test.tsx new file mode 100644 index 000000000..52f9fdb3a --- /dev/null +++ b/client/src/components/DateRangePicker/DateRangePicker.test.tsx @@ -0,0 +1,629 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import type { ReactNode } from 'react'; +import { render as rtlRender, screen, fireEvent, within, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DateRangePicker } from './DateRangePicker.js'; +import { LocaleProvider } from '../../contexts/LocaleContext.js'; +import styles from './DateRangePicker.module.css'; + +/** + * Custom render function that wraps the component with LocaleProvider + */ +function render(component: ReactNode, options?: Parameters[1]) { + return rtlRender({component}, options); +} + +/** + * Helper to get month label text from the picker + */ +function getMonthLabel(container: HTMLElement): string { + const monthLabel = container.querySelector('.monthLabel'); + return monthLabel?.textContent || ''; +} + +/** + * Helper to get all day buttons + */ +function getDayButtons(container: HTMLElement): HTMLButtonElement[] { + return Array.from(container.querySelectorAll(`.${styles.dayButton}`)); +} + +/** + * Helper to find a day button by its text content + */ +function findDayButton(container: HTMLElement, dayNumber: number): HTMLButtonElement | null { + return getDayButtons(container).find((btn) => btn.textContent === String(dayNumber)) || null; +} + +/** + * Helper to check if element has a specific class + */ +function hasClass(element: Element, className: string): boolean { + return element.className.includes(className); +} + +/** + * Helper to get the phase label text + */ +function getPhaseLabel(container: HTMLElement): string { + const phaseLabel = container.querySelector(`.${styles.phaseLabel}`); + return phaseLabel?.textContent || ''; +} + +describe('DateRangePicker', () => { + describe('rendering', () => { + it('renders a calendar grid with 7 day header columns', () => { + const { container } = render( + , + ); + const dayHeaders = container.querySelectorAll(`.${styles.dayHeader}`); + expect(dayHeaders).toHaveLength(7); + }); + + it('renders navigation buttons for previous and next month', () => { + const { container } = render( + , + ); + const navButtons = container.querySelectorAll(`.${styles.navButton}`); + expect(navButtons).toHaveLength(2); + }); + + it('renders the month/year heading', () => { + const { container } = render( + , + ); + const monthLabel = container.querySelector(`.${styles.monthLabel}`); + expect(monthLabel).toBeInTheDocument(); + expect(monthLabel?.textContent).toMatch(/^\w+\s\d{4}$/); // "Month YYYY" + }); + + it('renders day buttons for the month grid (approximately 40-42 cells for 6-row layout)', () => { + const { container } = render( + , + ); + const dayButtons = getDayButtons(container); + expect(dayButtons.length).toBeGreaterThanOrEqual(35); // Minimum 5 full weeks + expect(dayButtons.length).toBeLessThanOrEqual(42); // Maximum 6 full weeks + }); + + it('renders with no selected day when startDate="" and endDate=""', () => { + const { container } = render( + , + ); + const selectedButtons = getDayButtons(container).filter((btn) => + hasClass(btn, styles.daySelected), + ); + expect(selectedButtons).toHaveLength(0); + }); + + it('applies selected styling to the start date day button', () => { + const { container } = render( + , + ); + const dayBtn = findDayButton(container, 15); + expect(dayBtn).toHaveClass(styles.daySelected); + }); + + it('applies selected styling to both start and end date buttons when both are set', () => { + const { container } = render( + , + ); + const dayBtn15 = findDayButton(container, 15); + const dayBtn25 = findDayButton(container, 25); + expect(dayBtn15).toHaveClass(styles.daySelected); + expect(dayBtn25).toHaveClass(styles.daySelected); + }); + + it('applies in-range styling to days strictly between start and end dates', () => { + const { container } = render( + , + ); + const dayBtn20 = findDayButton(container, 20); + const dayCellContainer = dayBtn20?.closest(`.${styles.dayCell}`); + expect(dayCellContainer).toHaveClass(styles.dayInRange); + }); + + it('applies other-month styling to days outside current month', () => { + const { container } = render( + , + ); + const otherMonthBtns = getDayButtons(container).filter((btn) => + hasClass(btn, styles.dayOtherMonth), + ); + expect(otherMonthBtns.length).toBeGreaterThan(0); + }); + + it("applies today styling to today's date", () => { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + const todayStr = `${year}-${month}-${day}`; + + const { container } = render( + , + ); + const todayBtn = findDayButton(container, today.getDate()); + expect(todayBtn).toHaveClass(styles.dayToday); + }); + }); + + describe('selection phase', () => { + it('shows phase label "Select start date" when startDate=""', () => { + const { container } = render( + , + ); + const phaseLabel = getPhaseLabel(container); + expect(phaseLabel.toLowerCase()).toContain('start'); + }); + + it('shows phase label "Select end date" when startDate is set', () => { + const { container } = render( + , + ); + const phaseLabel = getPhaseLabel(container); + expect(phaseLabel.toLowerCase()).toContain('end'); + }); + + it('clicking a day when startDate="" calls onChange with the clicked date and empty end', () => { + const mockOnChange = jest.fn(); + const { container } = render( + , + ); + const dayBtn = findDayButton(container, 15); + expect(dayBtn).toBeTruthy(); + fireEvent.click(dayBtn!); + expect(mockOnChange).toHaveBeenCalledWith('2026-03-15', ''); + }); + + it('advances to selecting-end phase after clicking start date', () => { + const mockOnChange = jest.fn(); + const { container } = render( + , + ); + const dayBtn = findDayButton(container, 15); + fireEvent.click(dayBtn!); + expect(mockOnChange).toHaveBeenCalledWith('2026-03-15', ''); + + // Now render with startDate set to verify phase changes + const { container: container2 } = render( + , + ); + const phaseLabel = getPhaseLabel(container2); + expect(phaseLabel.toLowerCase()).toContain('end'); + }); + }); + + describe('phase: selecting-end', () => { + it('with startDate set, hovering a later day shows range highlight', () => { + const { container } = render( + , + ); + // A day strictly between start and hover should show in-range + const dayBtn20 = findDayButton(container, 20); + fireEvent.mouseEnter(dayBtn20!); + // When hovering 2026-03-20 with start=2026-03-15, day 18 is between them + const dayBtn18 = findDayButton(container, 18); + const dayCellContainer = dayBtn18?.closest(`.${styles.dayCell}`); + expect(dayCellContainer).toHaveClass(styles.dayInRange); + }); + + it('clicking a day after start calls onChange with start and clicked day', () => { + const mockOnChange = jest.fn(); + const { container } = render( + , + ); + const dayBtn25 = findDayButton(container, 25); + fireEvent.click(dayBtn25!); + expect(mockOnChange).toHaveBeenCalledWith('2026-03-15', '2026-03-25'); + }); + + it('days before start date have dayDisabled class during selecting-end phase', () => { + const { container } = render( + , + ); + // Day 10 is before day 20, so it should have the dayDisabled CSS class but remain clickable + const dayBtn10 = findDayButton(container, 10); + expect(dayBtn10).toBeTruthy(); + expect(dayBtn10).not.toBeDisabled(); + expect(dayBtn10).toHaveClass(styles.dayDisabled); + }); + + it('clicking the startDate day again calls onChange with empty start and end (clear both)', () => { + const mockOnChange = jest.fn(); + const { container } = render( + , + ); + const dayBtn15 = findDayButton(container, 15); + fireEvent.click(dayBtn15!); + expect(mockOnChange).toHaveBeenCalledWith('', ''); + }); + + it('mouseleave clears hover preview', () => { + const { container } = render( + , + ); + const dayBtn20 = findDayButton(container, 20); + fireEvent.mouseEnter(dayBtn20!); + // Day 18 is between 15 and 20, should have in-range class during hover + const dayBtn18 = findDayButton(container, 18); + let dayCellContainer = dayBtn18?.closest(`.${styles.dayCell}`); + expect(dayCellContainer).toHaveClass(styles.dayInRange); + + // After mouse leave, should not have in-range + fireEvent.mouseLeave(dayBtn20!); + dayCellContainer = dayBtn18?.closest(`.${styles.dayCell}`); + expect(dayCellContainer).not.toHaveClass(styles.dayInRange); + }); + + it('days before start date have dayDisabled CSS class (visually styled, not disabled)', () => { + const { container } = render( + , + ); + const dayBtn10 = findDayButton(container, 10); + expect(dayBtn10).toHaveClass(styles.dayDisabled); + expect(dayBtn10).not.toBeDisabled(); + }); + }); + + describe('navigation', () => { + it('clicking previous month button shows previous month', () => { + const { container } = render( + , + ); + const initialLabel = getMonthLabel(container); + expect(initialLabel).toContain('March'); + + const prevBtn = container.querySelector(`.${styles.navButton}`); + fireEvent.click(prevBtn!); + + const newLabel = getMonthLabel(container); + expect(newLabel).toContain('February'); + }); + + it('clicking next month button shows next month', () => { + const { container } = render( + , + ); + const initialLabel = getMonthLabel(container); + expect(initialLabel).toContain('March'); + + const navButtons = container.querySelectorAll(`.${styles.navButton}`); + const nextBtn = navButtons[navButtons.length - 1]; + fireEvent.click(nextBtn); + + const newLabel = getMonthLabel(container); + expect(newLabel).toContain('April'); + }); + + it('month/year heading updates when navigating months', () => { + const { container } = render( + , + ); + const nextBtn = container.querySelectorAll(`.${styles.navButton}`)[1]; + + fireEvent.click(nextBtn); + const monthLabel = getMonthLabel(container); + expect(monthLabel).toMatch(/^\w+\s\d{4}$/); + }); + }); + + describe('keyboard navigation', () => { + it('ArrowRight moves focus to next day', () => { + const { container } = render( + , + ); + const grid = container.querySelector(`.${styles.grid}`); + + // Initial focused button should be on startDate (2026-03-15) + const initialFocused = container.querySelector('button[tabindex="0"]'); + expect(initialFocused).toHaveAttribute('aria-label', expect.stringContaining('15')); + + // Fire ArrowRight keyboard event on grid + fireEvent.keyDown(grid!, { key: 'ArrowRight' }); + + // Now focused button should be on 2026-03-16 + const newFocused = container.querySelector('button[tabindex="0"]'); + expect(newFocused).not.toBe(initialFocused); + expect(newFocused).toHaveAttribute('aria-label', expect.stringContaining('16')); + }); + + it('ArrowLeft moves focus to previous day', () => { + const { container } = render( + , + ); + const grid = container.querySelector(`.${styles.grid}`); + + // Initial focused button should be on startDate (2026-03-15) + const initialFocused = container.querySelector('button[tabindex="0"]'); + expect(initialFocused).toHaveAttribute('aria-label', expect.stringContaining('15')); + + // Fire ArrowLeft keyboard event on grid + fireEvent.keyDown(grid!, { key: 'ArrowLeft' }); + + // Now focused button should be on 2026-03-14 + const newFocused = container.querySelector('button[tabindex="0"]'); + expect(newFocused).not.toBe(initialFocused); + expect(newFocused).toHaveAttribute('aria-label', expect.stringContaining('14')); + }); + + it('ArrowDown moves focus 7 days forward', () => { + const { container } = render( + , + ); + const grid = container.querySelector(`.${styles.grid}`); + + // Initial focused button should be on startDate (2026-03-15) + const initialFocused = container.querySelector('button[tabindex="0"]'); + expect(initialFocused).toHaveAttribute('aria-label', expect.stringContaining('15')); + + // Fire ArrowDown keyboard event on grid + fireEvent.keyDown(grid!, { key: 'ArrowDown' }); + + // Now focused button should be on 2026-03-22 (7 days later) + const newFocused = container.querySelector('button[tabindex="0"]'); + expect(newFocused).not.toBe(initialFocused); + expect(newFocused).toHaveAttribute('aria-label', expect.stringContaining('22')); + }); + + it('ArrowUp moves focus 7 days backward', () => { + const { container } = render( + , + ); + const grid = container.querySelector(`.${styles.grid}`); + + // Initial focused button should be on startDate (2026-03-15) + const initialFocused = container.querySelector('button[tabindex="0"]'); + expect(initialFocused).toHaveAttribute('aria-label', expect.stringContaining('15')); + + // Fire ArrowUp keyboard event on grid + fireEvent.keyDown(grid!, { key: 'ArrowUp' }); + + // Now focused button should be on 2026-03-08 (7 days earlier) + const newFocused = container.querySelector('button[tabindex="0"]'); + expect(newFocused).not.toBe(initialFocused); + expect(newFocused).toHaveAttribute('aria-label', expect.stringContaining('8')); + }); + + it('Enter on a focused day triggers selection', () => { + const mockOnChange = jest.fn(); + const { container } = render( + , + ); + const grid = container.querySelector(`.${styles.grid}`); + + // Fire Enter on the grid (keyboard navigation handler) + fireEvent.keyDown(grid!, { key: 'Enter' }); + + expect(mockOnChange).toHaveBeenCalled(); + }); + + it('Space on a focused day triggers selection', () => { + const mockOnChange = jest.fn(); + const { container } = render( + , + ); + const grid = container.querySelector(`.${styles.grid}`); + + // Fire Space on the grid (keyboard navigation handler) + fireEvent.keyDown(grid!, { key: ' ' }); + + expect(mockOnChange).toHaveBeenCalled(); + }); + + it('Escape during selecting-end phase cancels selection and resets both dates', () => { + const mockOnChange = jest.fn(); + const { container } = render( + , + ); + const grid = container.querySelector('[role="grid"]'); + expect(grid).toBeTruthy(); + + // Fire Escape key on grid + fireEvent.keyDown(grid!, { key: 'Escape' }); + + // Expect onChange to be called with both dates cleared + expect(mockOnChange).toHaveBeenCalledWith('', ''); + }); + + it('Escape during selecting-start phase does not call onChange', () => { + const mockOnChange = jest.fn(); + const { container } = render( + , + ); + const grid = container.querySelector('[role="grid"]'); + expect(grid).toBeTruthy(); + + // Fire Escape key on grid + fireEvent.keyDown(grid!, { key: 'Escape' }); + + // Expect onChange to NOT have been called + expect(mockOnChange).not.toHaveBeenCalled(); + }); + }); + + describe('props sync', () => { + it('when startDate prop changes from a date to "" externally, phase resets to selecting-start', () => { + const { container: container1 } = render( + , + ); + const phaseLabel1 = getPhaseLabel(container1); + expect(phaseLabel1.toLowerCase()).toContain('end'); + + const { container: container2 } = render( + , + ); + const phaseLabel2 = getPhaseLabel(container2); + expect(phaseLabel2.toLowerCase()).toContain('start'); + }); + + it('when endDate prop is set externally, phase remains at selecting-end', () => { + const { container: container1 } = render( + , + ); + const phaseLabel1 = getPhaseLabel(container1); + expect(phaseLabel1.toLowerCase()).toContain('end'); + + const { container: container2 } = render( + , + ); + const phaseLabel2 = getPhaseLabel(container2); + expect(phaseLabel2.toLowerCase()).toContain('end'); + }); + }); + + describe('accessibility', () => { + it('calendar grid has role="grid" with aria-label', () => { + const { container } = render( + , + ); + const grid = container.querySelector(`.${styles.grid}`); + expect(grid).toHaveAttribute('role', 'grid'); + }); + + it('week rows have role="row"', () => { + const { container } = render( + , + ); + const weekRows = container.querySelectorAll(`.${styles.weekRow}`); + weekRows.forEach((row) => { + expect(row).toHaveAttribute('role', 'row'); + }); + }); + + it('day cells have role="gridcell"', () => { + const { container } = render( + , + ); + const dayCells = container.querySelectorAll(`.${styles.dayCell}`); + expect(dayCells.length).toBeGreaterThan(0); + dayCells.forEach((cell) => { + expect(cell).toHaveAttribute('role', 'gridcell'); + }); + }); + + it('day buttons have aria-label with formatted date', () => { + const { container } = render( + , + ); + const dayBtn15 = findDayButton(container, 15); + expect(dayBtn15).toHaveAttribute('aria-label'); + const label = dayBtn15?.getAttribute('aria-label'); + expect(label).toMatch(/\d+/); // Should contain a number + }); + + it('selected day has aria-pressed="true"', () => { + const { container } = render( + , + ); + const dayBtn15 = findDayButton(container, 15); + expect(dayBtn15).toHaveAttribute('aria-pressed', 'true'); + }); + + it('unselected day has aria-pressed="false"', () => { + const { container } = render( + , + ); + const dayBtn10 = findDayButton(container, 10); + expect(dayBtn10).toHaveAttribute('aria-pressed', 'false'); + }); + + it('nav buttons have aria-label', () => { + const { container } = render( + , + ); + const navButtons = container.querySelectorAll(`.${styles.navButton}`); + navButtons.forEach((btn) => { + expect(btn).toHaveAttribute('aria-label'); + }); + }); + }); + + describe('edge cases', () => { + it('handles year transitions when navigating backwards from January', () => { + const { container } = render( + , + ); + const prevBtn = container.querySelector(`.${styles.navButton}`); + fireEvent.click(prevBtn!); + const monthLabel = getMonthLabel(container); + expect(monthLabel).toContain('2025'); + expect(monthLabel).toContain('December'); + }); + + it('handles year transitions when navigating forward from December', () => { + const { container } = render( + , + ); + const navButtons = container.querySelectorAll(`.${styles.navButton}`); + const nextBtn = navButtons[navButtons.length - 1]; + fireEvent.click(nextBtn); + const monthLabel = getMonthLabel(container); + expect(monthLabel).toContain('2027'); + expect(monthLabel).toContain('January'); + }); + + it('renders correctly for leap year dates', () => { + const { container } = render( + , + ); + const monthLabel = getMonthLabel(container); + expect(monthLabel).toContain('February'); + expect(monthLabel).toContain('2024'); + }); + + it('handles range selection from end-of-month to next month', () => { + const mockOnChange = jest.fn(); + const { container } = render( + , + ); + const navButtons = container.querySelectorAll(`.${styles.navButton}`); + const nextBtn = navButtons[navButtons.length - 1]; + fireEvent.click(nextBtn); + const dayBtn1 = findDayButton(container, 1); + fireEvent.click(dayBtn1!); + expect(mockOnChange).toHaveBeenCalledWith('2026-03-31', '2026-04-01'); + }); + }); + + describe('range styling', () => { + it('applies dayRangeStart styling to the start date with proper background gradient', () => { + const { container } = render( + , + ); + const dayBtn15 = findDayButton(container, 15); + const dayCellContainer = dayBtn15?.closest(`.${styles.dayCell}`); + expect(dayCellContainer).toHaveClass(styles.dayRangeStart); + }); + + it('applies dayRangeEnd styling to the end date with proper background gradient', () => { + const { container } = render( + , + ); + const dayBtn25 = findDayButton(container, 25); + const dayCellContainer = dayBtn25?.closest(`.${styles.dayCell}`); + expect(dayCellContainer).toHaveClass(styles.dayRangeEnd); + }); + + it('does not apply range styling when only start date is selected', () => { + const { container } = render( + , + ); + const dayBtn20 = findDayButton(container, 20); + const dayCellContainer = dayBtn20?.closest(`.${styles.dayCell}`); + expect(dayCellContainer).not.toHaveClass(styles.dayInRange); + }); + + it('shows hover preview when hovering over day after start date during selecting-end phase', () => { + const { container } = render( + , + ); + const dayBtn25 = findDayButton(container, 25); + fireEvent.mouseEnter(dayBtn25!); + // Day 20 is between 15 (start) and 25 (hover), should show in-range + const dayBtn20 = findDayButton(container, 20); + const dayCellContainer = dayBtn20?.closest(`.${styles.dayCell}`); + expect(dayCellContainer).toHaveClass(styles.dayInRange); + }); + }); +}); diff --git a/client/src/components/DateRangePicker/DateRangePicker.tsx b/client/src/components/DateRangePicker/DateRangePicker.tsx new file mode 100644 index 000000000..39a2bd1ba --- /dev/null +++ b/client/src/components/DateRangePicker/DateRangePicker.tsx @@ -0,0 +1,411 @@ +/** + * DateRangePicker — calendar-based date range selection component. + * + * Replaces native date inputs with a calendar view supporting: + * - Two-phase selection (start date, then end date) + * - Range highlighting with hover preview + * - Auto-advance from start to end date selection + * - Keyboard navigation (arrow keys, Enter, Space) + * - Proper ARIA labels and keyboard accessibility + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + parseIsoDate, + formatIsoDate, + getTodayStr, + getMonthGrid, + getMonthName, + getDayNameNarrow, + formatDateForAria, + prevMonth, + nextMonth, +} from '../calendar/calendarUtils.js'; +import { useLocale } from '../../contexts/LocaleContext.js'; +import styles from './DateRangePicker.module.css'; + +export interface DateRangePickerProps { + startDate: string; // YYYY-MM-DD or '' + endDate: string; // YYYY-MM-DD or '' + onChange: (startDate: string, endDate: string) => void; + ariaLabel?: string; +} + +type SelectionPhase = 'selecting-start' | 'selecting-end'; + +/** + * ChevronLeftIcon — navigation arrow for previous month + */ +function ChevronLeftIcon() { + return ( + + ); +} + +/** + * ChevronRightIcon — navigation arrow for next month + */ +function ChevronRightIcon() { + return ( + + ); +} + +export function DateRangePicker({ startDate, endDate, onChange, ariaLabel }: DateRangePickerProps) { + const { t } = useTranslation('common'); + const { locale } = useLocale(); + + // Initialize phase based on startDate + const [phase, setPhase] = useState( + startDate ? 'selecting-end' : 'selecting-start', + ); + + // Hover preview for range (only during end date selection) + const [hoverDate, setHoverDate] = useState(''); + + // View state (month/year to display) + const today = getTodayStr(); + const todayDate = parseIsoDate(today); + const startDateDate = startDate ? parseIsoDate(startDate) : null; + const initYear = startDateDate ? startDateDate.getUTCFullYear() : todayDate.getUTCFullYear(); + const initMonth = startDateDate ? startDateDate.getUTCMonth() + 1 : todayDate.getUTCMonth() + 1; + + const [viewYear, setViewYear] = useState(initYear); + const [viewMonth, setViewMonth] = useState(initMonth); + + // Keyboard focus state + const [focusedDate, setFocusedDate] = useState(startDate || today); + const gridContainerRef = useRef(null); + const focusedButtonRef = useRef(null); + const hasMountedRef = useRef(false); + + // Sync focusedDate ref for focus management (only after initial mount) + useEffect(() => { + if (!hasMountedRef.current) { + hasMountedRef.current = true; + return; + } + if (focusedButtonRef.current) { + focusedButtonRef.current.focus(); + } + }, [focusedDate]); + + // Sync phase when incoming props change + useEffect(() => { + if (startDate === '') { + setPhase('selecting-start'); + } else if (endDate === '') { + setPhase('selecting-end'); + } + }, [startDate, endDate]); + + // Get the effective end date for range display (hover preview or confirmed end) + const effectiveEnd = phase === 'selecting-end' && hoverDate ? hoverDate : endDate; + + // Check if a day is in the highlighted range (strictly between start and end) + const isInRange = useCallback( + (dateStr: string): boolean => { + return !!(startDate && effectiveEnd && dateStr > startDate && dateStr < effectiveEnd); + }, + [startDate, effectiveEnd], + ); + + // Check if a day is the range start + const isRangeStart = useCallback( + (dateStr: string): boolean => { + return ( + dateStr === startDate && (endDate !== '' || (phase === 'selecting-end' && hoverDate !== '')) + ); + }, + [startDate, endDate, phase, hoverDate], + ); + + // Check if a day is the range end + const isRangeEnd = useCallback( + (dateStr: string): boolean => { + return dateStr === effectiveEnd && startDate !== ''; + }, + [startDate, effectiveEnd], + ); + + // Handle day cell click + const handleDayClick = useCallback( + (dateStr: string) => { + if (phase === 'selecting-start') { + // First selection: emit start date, switch to end selection + setPhase('selecting-end'); + setHoverDate(''); + onChange(dateStr, ''); + setFocusedDate(dateStr); + } else { + // Second selection (selecting-end) + if (dateStr < startDate!) { + // Clicking before start date: reset to new start + setPhase('selecting-end'); + setHoverDate(''); + onChange(dateStr, ''); + setFocusedDate(dateStr); + } else if (dateStr === startDate!) { + // Clicking on start date again: clear everything + setPhase('selecting-start'); + setHoverDate(''); + onChange('', ''); + setFocusedDate(dateStr); + } else { + // Normal end date selection + setHoverDate(''); + onChange(startDate!, dateStr); + setFocusedDate(dateStr); + } + } + }, + [phase, startDate, onChange], + ); + + // Handle hover during end date selection + const handleDayMouseEnter = useCallback( + (dateStr: string) => { + if (phase === 'selecting-end' && startDate) { + setHoverDate(dateStr); + } + }, + [phase, startDate], + ); + + const handleDayMouseLeave = useCallback(() => { + if (phase === 'selecting-end') { + setHoverDate(''); + } + }, [phase]); + + // Keyboard navigation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (!gridContainerRef.current) return; + + const focusedDateObj = parseIsoDate(focusedDate); + let newDate: Date | null = null; + + switch (e.key) { + case 'ArrowRight': + newDate = new Date( + Date.UTC( + focusedDateObj.getUTCFullYear(), + focusedDateObj.getUTCMonth(), + focusedDateObj.getUTCDate() + 1, + ), + ); + e.preventDefault(); + break; + case 'ArrowLeft': + newDate = new Date( + Date.UTC( + focusedDateObj.getUTCFullYear(), + focusedDateObj.getUTCMonth(), + focusedDateObj.getUTCDate() - 1, + ), + ); + e.preventDefault(); + break; + case 'ArrowDown': + newDate = new Date( + Date.UTC( + focusedDateObj.getUTCFullYear(), + focusedDateObj.getUTCMonth(), + focusedDateObj.getUTCDate() + 7, + ), + ); + e.preventDefault(); + break; + case 'ArrowUp': + newDate = new Date( + Date.UTC( + focusedDateObj.getUTCFullYear(), + focusedDateObj.getUTCMonth(), + focusedDateObj.getUTCDate() - 7, + ), + ); + e.preventDefault(); + break; + case 'Enter': + case ' ': + // Select focused date + handleDayClick(focusedDate); + e.preventDefault(); + break; + case 'Escape': + if (phase === 'selecting-end' && endDate === '') { + setPhase('selecting-start'); + setHoverDate(''); + onChange('', ''); + } + e.preventDefault(); + break; + default: + return; + } + + if (newDate) { + const newDateStr = formatIsoDate(newDate); + setFocusedDate(newDateStr); + + // Auto-navigate month if needed + const newYear = newDate.getUTCFullYear(); + const newMonth = newDate.getUTCMonth() + 1; + if (newYear !== viewYear || newMonth !== viewMonth) { + setViewYear(newYear); + setViewMonth(newMonth); + } + } + }, + [focusedDate, viewYear, viewMonth, handleDayClick, phase, endDate, onChange], + ); + + // Month navigation + const handlePrevMonth = useCallback(() => { + const prev = prevMonth(viewYear, viewMonth); + setViewYear(prev.year); + setViewMonth(prev.month); + }, [viewYear, viewMonth]); + + const handleNextMonth = useCallback(() => { + const next = nextMonth(viewYear, viewMonth); + setViewYear(next.year); + setViewMonth(next.month); + }, [viewYear, viewMonth]); + + // Render the calendar grid + const weeks = getMonthGrid(viewYear, viewMonth); + const monthName = getMonthName(viewMonth, locale); + + return ( +
+ {/* Month navigation */} +
+ +
+ {monthName} {viewYear} +
+ +
+ + {/* Week rows with day buttons */} +
+ {/* Header row with day names */} +
+ {[0, 1, 2, 3, 4, 5, 6].map((dayIndex) => ( +
+ {getDayNameNarrow(dayIndex, locale)} +
+ ))} +
+ + {weeks.map((week, weekIndex) => ( +
+ {week.map((day) => { + const isSelected = day.dateStr === startDate || day.dateStr === endDate; + const inRange = isInRange(day.dateStr); + const isStart = isRangeStart(day.dateStr); + const isEnd = isRangeEnd(day.dateStr); + const isBeforeStart = phase === 'selecting-end' && day.dateStr < startDate!; + const isFocused = day.dateStr === focusedDate; + + return ( +
+ +
+ ); + })} +
+ ))} +
+ + {/* Phase indicator */} +
+ {phase === 'selecting-start' + ? t('dateRangePicker.selectStart') + : t('dateRangePicker.selectEnd')} +
+
+ ); +} diff --git a/client/src/components/DateRangePicker/index.ts b/client/src/components/DateRangePicker/index.ts new file mode 100644 index 000000000..a5eaeeb65 --- /dev/null +++ b/client/src/components/DateRangePicker/index.ts @@ -0,0 +1,2 @@ +export { DateRangePicker } from './DateRangePicker.js'; +export type { DateRangePickerProps } from './DateRangePicker.js'; diff --git a/client/src/components/Header/Header.module.css b/client/src/components/Header/Header.module.css deleted file mode 100644 index abf7110c3..000000000 --- a/client/src/components/Header/Header.module.css +++ /dev/null @@ -1,47 +0,0 @@ -.header { - height: 60px; - border-bottom: 1px solid var(--color-border); - display: flex; - align-items: center; - padding: 0 1rem; - background-color: var(--color-bg-primary); -} - -.menuButton { - min-width: 44px; - min-height: 44px; - border: none; - background: transparent; - font-size: 1.5rem; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - border-radius: 0.25rem; - transition: background-color 0.2s ease; -} - -.menuButton:hover { - background-color: var(--color-bg-tertiary); -} - -.menuButton:focus-visible { - outline: 2px solid var(--color-primary); - outline-offset: 2px; -} - -.titleArea { - margin-left: 1rem; - flex: 1; -} - -/* Desktop: hide menu button */ -@media (min-width: 1025px) { - .menuButton { - display: none; - } - - .titleArea { - margin-left: 0; - } -} diff --git a/client/src/components/Header/Header.test.tsx b/client/src/components/Header/Header.test.tsx deleted file mode 100644 index 42dd42e0c..000000000 --- a/client/src/components/Header/Header.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { jest } from '@jest/globals'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { Header } from './Header'; - -describe('Header', () => { - it('renders menu toggle button with "Open menu" aria-label when sidebar is closed', () => { - const mockToggle = jest.fn(); - render(
); - - const button = screen.getByRole('button', { name: /open menu/i }); - expect(button).toBeInTheDocument(); - }); - - it('calls onToggleSidebar when menu button is clicked', async () => { - const mockToggle = jest.fn(); - const user = userEvent.setup(); - - render(
); - - const button = screen.getByRole('button', { name: /open menu/i }); - await user.click(button); - - expect(mockToggle).toHaveBeenCalledTimes(1); - }); - - it('calls onToggleSidebar multiple times on repeated clicks', async () => { - const mockToggle = jest.fn(); - const user = userEvent.setup(); - - render(
); - - const button = screen.getByRole('button', { name: /open menu/i }); - await user.click(button); - await user.click(button); - await user.click(button); - - expect(mockToggle).toHaveBeenCalledTimes(3); - }); - - it('renders title area with correct data-testid', () => { - const mockToggle = jest.fn(); - render(
); - - const titleArea = screen.getByTestId('page-title'); - expect(titleArea).toBeInTheDocument(); - }); - - it('menu button has type="button" to prevent form submission', () => { - const mockToggle = jest.fn(); - render(
); - - const button = screen.getByRole('button', { name: /open menu/i }); - expect(button).toHaveAttribute('type', 'button'); - }); - - it('shows ☰ icon when sidebar is closed', () => { - const mockToggle = jest.fn(); - render(
); - - const button = screen.getByRole('button', { name: /open menu/i }); - expect(button).toHaveTextContent('☰'); - }); - - it('shows ✕ icon when sidebar is open', () => { - const mockToggle = jest.fn(); - render(
); - - const button = screen.getByRole('button', { name: /close menu/i }); - expect(button).toHaveTextContent('✕'); - }); - - it('has "Open menu" aria-label when sidebar is closed', () => { - const mockToggle = jest.fn(); - render(
); - - const button = screen.getByRole('button', { name: /open menu/i }); - expect(button).toHaveAttribute('aria-label', 'Open menu'); - }); - - it('has "Close menu" aria-label when sidebar is open', () => { - const mockToggle = jest.fn(); - render(
); - - const button = screen.getByRole('button', { name: /close menu/i }); - expect(button).toHaveAttribute('aria-label', 'Close menu'); - }); -}); diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx deleted file mode 100644 index c437210f0..000000000 --- a/client/src/components/Header/Header.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import styles from './Header.module.css'; - -interface HeaderProps { - onToggleSidebar: () => void; - isSidebarOpen: boolean; -} - -export function Header({ onToggleSidebar, isSidebarOpen }: HeaderProps) { - const { t } = useTranslation('common'); - - return ( -
- -
-
- ); -} diff --git a/client/src/components/PageLayout/PageLayout.module.css b/client/src/components/PageLayout/PageLayout.module.css new file mode 100644 index 000000000..98096d2d3 --- /dev/null +++ b/client/src/components/PageLayout/PageLayout.module.css @@ -0,0 +1,78 @@ +.container { + max-width: 1400px; + margin: 0 auto; + padding: var(--spacing-8); + display: flex; + flex-direction: column; +} + +.containerNarrow { + max-width: 1200px; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-6); +} + +.title { + font-size: var(--font-size-4xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: 0; +} + +.action { + flex-shrink: 0; +} + +.subNav { + margin-bottom: var(--spacing-4); +} + +.content { + display: flex; + flex-direction: column; +} + +@media (max-width: 767px) { + .container { + padding: var(--spacing-4); + } + + .header { + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-3); + margin-bottom: var(--spacing-4); + } + + .title { + font-size: var(--font-size-2xl); + } + + .action { + width: 100%; + } + + .action > * { + width: 100%; + min-height: 44px; + } + + .subNav { + margin-bottom: var(--spacing-3); + } +} + +@media (min-width: 768px) and (max-width: 1023px) { + .action > * { + min-height: 44px; + } + + .header { + margin-bottom: var(--spacing-4); + } +} diff --git a/client/src/components/PageLayout/PageLayout.test.tsx b/client/src/components/PageLayout/PageLayout.test.tsx new file mode 100644 index 000000000..9ee039452 --- /dev/null +++ b/client/src/components/PageLayout/PageLayout.test.tsx @@ -0,0 +1,166 @@ +/** + * @jest-environment jsdom + */ +import { describe, it, expect } from '@jest/globals'; +import { render, screen } from '@testing-library/react'; +import { PageLayout } from './PageLayout.js'; + +// CSS modules are mocked via identity-obj-proxy (classNames returned as-is) + +describe('PageLayout', () => { + // ── title prop ──────────────────────────────────────────────────────────── + + it('renders h1 with the given title', () => { + render( + +

content

+
, + ); + + expect(screen.getByRole('heading', { level: 1, name: 'Work Items' })).toBeInTheDocument(); + }); + + // ── children ────────────────────────────────────────────────────────────── + + it('renders children in the DOM', () => { + render( + +

Hello

+
, + ); + + expect(screen.getByTestId('child-content')).toBeInTheDocument(); + expect(screen.getByTestId('child-content')).toHaveTextContent('Hello'); + }); + + // ── action prop ─────────────────────────────────────────────────────────── + + it('renders action content when action prop is provided', () => { + render( + New}> +

content

+
, + ); + + expect(screen.getByRole('button', { name: 'New' })).toBeInTheDocument(); + }); + + it('does not render the action wrapper div when action is undefined', () => { + const { container } = render( + +

content

+
, + ); + + // identity-obj-proxy returns CSS module class names as-is + expect(container.querySelector('.action')).toBeNull(); + }); + + // ── subNav prop ─────────────────────────────────────────────────────────── + + it('renders subNav content when subNav prop is provided', () => { + render( + nav}> +

content

+
, + ); + + expect(screen.getByTestId('sub-nav')).toBeInTheDocument(); + }); + + it('does not render the subNav wrapper div when subNav is undefined', () => { + const { container } = render( + +

content

+
, + ); + + expect(container.querySelector('.subNav')).toBeNull(); + }); + + // ── maxWidth prop ───────────────────────────────────────────────────────── + + it('does not apply containerNarrow class by default (wide)', () => { + const { container } = render( + +

content

+
, + ); + + const outer = container.firstElementChild as HTMLElement; + expect(outer.className).not.toContain('containerNarrow'); + }); + + it('applies containerNarrow class when maxWidth is "narrow"', () => { + const { container } = render( + +

content

+
, + ); + + const outer = container.firstElementChild as HTMLElement; + expect(outer.className).toContain('containerNarrow'); + }); + + it('does not apply containerNarrow class when maxWidth is "wide"', () => { + const { container } = render( + +

content

+
, + ); + + const outer = container.firstElementChild as HTMLElement; + expect(outer.className).not.toContain('containerNarrow'); + }); + + // ── testId prop ─────────────────────────────────────────────────────────── + + it('applies data-testid to the container when testId prop is provided', () => { + const { container } = render( + +

content

+
, + ); + + const outer = container.firstElementChild as HTMLElement; + expect(outer).toHaveAttribute('data-testid', 'my-page'); + }); + + it('does not add data-testid attribute when testId is omitted', () => { + const { container } = render( + +

content

+
, + ); + + const outer = container.firstElementChild as HTMLElement; + expect(outer).not.toHaveAttribute('data-testid'); + }); + + // ── combined props ──────────────────────────────────────────────────────── + + it('renders title, action, subNav, and children together correctly', () => { + render( + Add} + subNav={} + > + + + + + + +
Row 1
+
, + ); + + expect(screen.getByRole('heading', { level: 1, name: 'Budget' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument(); + expect(screen.getByRole('navigation', { name: 'Budget nav' })).toBeInTheDocument(); + expect(screen.getByRole('cell', { name: 'Row 1' })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/PageLayout/PageLayout.tsx b/client/src/components/PageLayout/PageLayout.tsx new file mode 100644 index 000000000..98341e549 --- /dev/null +++ b/client/src/components/PageLayout/PageLayout.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from 'react'; +import styles from './PageLayout.module.css'; + +export interface PageLayoutProps { + title: string; + maxWidth?: 'narrow' | 'wide'; + action?: ReactNode; + subNav?: ReactNode; + children: ReactNode; + testId?: string; +} + +/** + * PageLayout — shared page structure for consistent headers, navigation, and content layout. + * + * Provides a standard container with optional sub-navigation tabs, title heading, and action button. + * Handles responsive layout with proper spacing and alignment. + */ +export function PageLayout({ + title, + maxWidth = 'wide', + action, + subNav, + children, + testId, +}: PageLayoutProps) { + return ( +
+
+

{title}

+ {action &&
{action}
} +
+ {subNav &&
{subNav}
} +
{children}
+
+ ); +} + +export default PageLayout; diff --git a/client/src/components/PageLayout/index.ts b/client/src/components/PageLayout/index.ts new file mode 100644 index 000000000..1fbbc9695 --- /dev/null +++ b/client/src/components/PageLayout/index.ts @@ -0,0 +1 @@ +export { PageLayout, type PageLayoutProps } from './PageLayout.js'; diff --git a/client/src/components/ProjectSubNav/ProjectSubNav.module.css b/client/src/components/ProjectSubNav/ProjectSubNav.module.css deleted file mode 100644 index 566d64816..000000000 --- a/client/src/components/ProjectSubNav/ProjectSubNav.module.css +++ /dev/null @@ -1,78 +0,0 @@ -/* ============================================================ - * ProjectSubNav — horizontal tab navigation for the Project section - * ============================================================ */ - -.subNav { - width: 100%; -} - -/* Scrollable tab row — scrolls horizontally when tabs overflow on mobile */ -.tabList { - display: flex; - flex-direction: row; - gap: 0; - border-bottom: 2px solid var(--color-border); - overflow-x: auto; - -webkit-overflow-scrolling: touch; - /* Hide scrollbar on browsers that support it — tabs still scroll on touch */ - scrollbar-width: none; /* Firefox */ -} - -.tabList::-webkit-scrollbar { - display: none; /* Chrome / Safari */ -} - -/* Individual tab link */ -.tab { - display: inline-flex; - align-items: center; - padding: var(--spacing-2-5) var(--spacing-4); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - text-decoration: none; - white-space: nowrap; - border-bottom: 2px solid transparent; - /* Pull the tab border down so it overlaps the container border */ - margin-bottom: -2px; - transition: - color var(--transition-normal), - border-color var(--transition-normal), - background-color var(--transition-normal); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; -} - -.tab:hover { - color: var(--color-text-primary); - background-color: var(--color-bg-secondary); -} - -.tab:focus-visible { - outline: none; - box-shadow: var(--shadow-focus-subtle); - border-radius: var(--radius-sm); -} - -/* Active tab */ -.tabActive { - color: var(--color-primary); - border-bottom-color: var(--color-primary); - font-weight: var(--font-weight-semibold); -} - -.tabActive:hover { - color: var(--color-primary-hover); - background-color: var(--color-bg-secondary); - border-bottom-color: var(--color-primary-hover); -} - -/* ============================================================ - * RESPONSIVE — Mobile (max 767px): reduce padding for smaller screens - * ============================================================ */ - -@media (max-width: 767px) { - .tab { - padding: var(--spacing-2) var(--spacing-3); - font-size: var(--font-size-xs); - } -} diff --git a/client/src/components/ProjectSubNav/ProjectSubNav.tsx b/client/src/components/ProjectSubNav/ProjectSubNav.tsx deleted file mode 100644 index 85ec237aa..000000000 --- a/client/src/components/ProjectSubNav/ProjectSubNav.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { NavLink } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import styles from './ProjectSubNav.module.css'; - -const PROJECT_TABS = [ - { labelKey: 'subnav.project.overview', to: '/project/overview' }, - { labelKey: 'subnav.project.workItems', to: '/project/work-items' }, - { labelKey: 'subnav.project.householdItems', to: '/project/household-items' }, - { labelKey: 'subnav.project.milestones', to: '/project/milestones' }, -] as const; - -/** - * ProjectSubNav — horizontal tab-style navigation for the Project section. - * - * Renders a scrollable row of tab links for all project sub-pages. - * The currently active tab is highlighted using the primary design token. - * On mobile the row scrolls horizontally so all tabs remain reachable. - */ -export function ProjectSubNav() { - const { t } = useTranslation('common'); - - return ( - - ); -} - -export default ProjectSubNav; diff --git a/client/src/components/ScheduleSubNav/ScheduleSubNav.module.css b/client/src/components/ScheduleSubNav/ScheduleSubNav.module.css deleted file mode 100644 index 47bb9734d..000000000 --- a/client/src/components/ScheduleSubNav/ScheduleSubNav.module.css +++ /dev/null @@ -1,78 +0,0 @@ -/* ============================================================ - * ScheduleSubNav — horizontal tab navigation for the Schedule section - * ============================================================ */ - -.subNav { - width: 100%; -} - -/* Scrollable tab row — scrolls horizontally when tabs overflow on mobile */ -.tabList { - display: flex; - flex-direction: row; - gap: 0; - border-bottom: 2px solid var(--color-border); - overflow-x: auto; - -webkit-overflow-scrolling: touch; - /* Hide scrollbar on browsers that support it — tabs still scroll on touch */ - scrollbar-width: none; /* Firefox */ -} - -.tabList::-webkit-scrollbar { - display: none; /* Chrome / Safari */ -} - -/* Individual tab link */ -.tab { - display: inline-flex; - align-items: center; - padding: var(--spacing-2-5) var(--spacing-4); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - text-decoration: none; - white-space: nowrap; - border-bottom: 2px solid transparent; - /* Pull the tab border down so it overlaps the container border */ - margin-bottom: -2px; - transition: - color var(--transition-normal), - border-color var(--transition-normal), - background-color var(--transition-normal); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; -} - -.tab:hover { - color: var(--color-text-primary); - background-color: var(--color-bg-secondary); -} - -.tab:focus-visible { - outline: none; - box-shadow: var(--shadow-focus-subtle); - border-radius: var(--radius-sm); -} - -/* Active tab */ -.tabActive { - color: var(--color-primary); - border-bottom-color: var(--color-primary); - font-weight: var(--font-weight-semibold); -} - -.tabActive:hover { - color: var(--color-primary-hover); - background-color: var(--color-bg-secondary); - border-bottom-color: var(--color-primary-hover); -} - -/* ============================================================ - * RESPONSIVE — Mobile (max 767px): reduce padding for smaller screens - * ============================================================ */ - -@media (max-width: 767px) { - .tab { - padding: var(--spacing-2) var(--spacing-3); - font-size: var(--font-size-xs); - } -} diff --git a/client/src/components/ScheduleSubNav/ScheduleSubNav.test.tsx b/client/src/components/ScheduleSubNav/ScheduleSubNav.test.tsx index f55b34df0..f7d666efa 100644 --- a/client/src/components/ScheduleSubNav/ScheduleSubNav.test.tsx +++ b/client/src/components/ScheduleSubNav/ScheduleSubNav.test.tsx @@ -24,10 +24,12 @@ describe('ScheduleSubNav', () => { expect(screen.getByText('Calendar')).toBeInTheDocument(); }); - it('renders nav with aria-label="Schedule view navigation"', () => { + it('renders nav with aria-label="Schedule section navigation"', () => { renderNav(); - expect(screen.getByRole('navigation', { name: 'Gantt' })).toBeInTheDocument(); + expect( + screen.getByRole('navigation', { name: 'Schedule section navigation' }), + ).toBeInTheDocument(); }); it('renders data-testid="schedule-view-gantt" on Gantt tab', () => { diff --git a/client/src/components/ScheduleSubNav/ScheduleSubNav.tsx b/client/src/components/ScheduleSubNav/ScheduleSubNav.tsx index e6c1ce748..a1f17e544 100644 --- a/client/src/components/ScheduleSubNav/ScheduleSubNav.tsx +++ b/client/src/components/ScheduleSubNav/ScheduleSubNav.tsx @@ -1,36 +1,25 @@ -import { NavLink } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import styles from './ScheduleSubNav.module.css'; +import { SubNav, type SubNavTab } from '../SubNav/SubNav.js'; export function ScheduleSubNav() { const { t } = useTranslation('schedule'); - const scheduleTabs = [ - { labelKey: 'schedule.navigation.gantt', to: '/schedule/gantt' }, - { labelKey: 'schedule.navigation.calendar', to: '/schedule/calendar' }, - ] as const; + const scheduleTabs: SubNavTab[] = [ + { + labelKey: 'schedule.navigation.gantt', + to: '/schedule/gantt', + ns: 'schedule', + testId: 'schedule-view-gantt', + }, + { + labelKey: 'schedule.navigation.calendar', + to: '/schedule/calendar', + ns: 'schedule', + testId: 'schedule-view-calendar', + }, + ]; - return ( - - ); + return ; } export default ScheduleSubNav; diff --git a/client/src/components/SettingsSubNav/SettingsSubNav.module.css b/client/src/components/SettingsSubNav/SettingsSubNav.module.css deleted file mode 100644 index be8b38e13..000000000 --- a/client/src/components/SettingsSubNav/SettingsSubNav.module.css +++ /dev/null @@ -1,78 +0,0 @@ -/* ============================================================ - * SettingsSubNav — horizontal tab navigation for the Settings section - * ============================================================ */ - -.subNav { - width: 100%; -} - -/* Scrollable tab row — scrolls horizontally when tabs overflow on mobile */ -.tabList { - display: flex; - flex-direction: row; - gap: 0; - border-bottom: 2px solid var(--color-border); - overflow-x: auto; - -webkit-overflow-scrolling: touch; - /* Hide scrollbar on browsers that support it — tabs still scroll on touch */ - scrollbar-width: none; /* Firefox */ -} - -.tabList::-webkit-scrollbar { - display: none; /* Chrome / Safari */ -} - -/* Individual tab link */ -.tab { - display: inline-flex; - align-items: center; - padding: var(--spacing-2-5) var(--spacing-4); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - text-decoration: none; - white-space: nowrap; - border-bottom: 2px solid transparent; - /* Pull the tab border down so it overlaps the container border */ - margin-bottom: -2px; - transition: - color var(--transition-normal), - border-color var(--transition-normal), - background-color var(--transition-normal); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; -} - -.tab:hover { - color: var(--color-text-primary); - background-color: var(--color-bg-secondary); -} - -.tab:focus-visible { - outline: none; - box-shadow: var(--shadow-focus-subtle); - border-radius: var(--radius-sm); -} - -/* Active tab */ -.tabActive { - color: var(--color-primary); - border-bottom-color: var(--color-primary); - font-weight: var(--font-weight-semibold); -} - -.tabActive:hover { - color: var(--color-primary-hover); - background-color: var(--color-bg-secondary); - border-bottom-color: var(--color-primary-hover); -} - -/* ============================================================ - * RESPONSIVE — Mobile (max 767px): reduce padding for smaller screens - * ============================================================ */ - -@media (max-width: 767px) { - .tab { - padding: var(--spacing-2) var(--spacing-3); - font-size: var(--font-size-xs); - } -} diff --git a/client/src/components/SettingsSubNav/SettingsSubNav.tsx b/client/src/components/SettingsSubNav/SettingsSubNav.tsx deleted file mode 100644 index b9e783a13..000000000 --- a/client/src/components/SettingsSubNav/SettingsSubNav.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { NavLink } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { useAuth } from '../../contexts/AuthContext.js'; -import styles from './SettingsSubNav.module.css'; - -const SETTINGS_TABS = [ - { labelKey: 'subnav.settings.profile', to: '/settings/profile' }, - { labelKey: 'subnav.settings.manage', to: '/settings/manage' }, -] as const; - -/** - * SettingsSubNav — horizontal tab-style navigation for the Settings section. - * - * Renders a scrollable row of tab links for all settings sub-pages. - * The User Management tab is only visible to admins. - * On mobile the row scrolls horizontally so all tabs remain reachable. - */ -export function SettingsSubNav() { - const { t } = useTranslation('common'); - const { user } = useAuth(); - - return ( - - ); -} - -export default SettingsSubNav; diff --git a/client/src/components/Sidebar/Sidebar.module.css b/client/src/components/Sidebar/Sidebar.module.css index 7155b8ed5..eb886e7de 100644 --- a/client/src/components/Sidebar/Sidebar.module.css +++ b/client/src/components/Sidebar/Sidebar.module.css @@ -5,6 +5,7 @@ display: flex; flex-direction: column; height: 100vh; + height: 100dvh; } .logoArea { @@ -127,10 +128,6 @@ background-color: var(--color-sidebar-active); } -.sidebarHeader { - display: none; -} - /* Mobile and tablet: fixed position, hidden by default */ @media (max-width: 1024px) { .sidebar { @@ -138,6 +135,7 @@ top: 0; left: 0; bottom: 0; + height: auto; width: 240px; z-index: var(--z-sidebar); transform: translateX(-100%); @@ -149,41 +147,6 @@ transform: translateX(0); } - .sidebarHeader { - display: flex; - justify-content: flex-start; - padding: var(--spacing-2); - } - - .closeButton { - min-width: 44px; - min-height: 44px; - border: none; - background: transparent; - color: var(--color-sidebar-text); - font-size: var(--font-size-2xl); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--radius-sm); - opacity: 0; - transition: opacity var(--transition-fast) 0.2s; - } - - .closeButton:hover { - background-color: var(--color-sidebar-hover); - } - - .closeButton:focus-visible { - outline: 2px solid var(--color-sidebar-focus-ring); - outline-offset: 2px; - } - - .sidebar.open .closeButton { - opacity: 1; - } - .navLink { min-height: 44px; display: flex; diff --git a/client/src/components/Sidebar/Sidebar.test.tsx b/client/src/components/Sidebar/Sidebar.test.tsx index 3c4ddce21..1ae03e95d 100644 --- a/client/src/components/Sidebar/Sidebar.test.tsx +++ b/client/src/components/Sidebar/Sidebar.test.tsx @@ -166,23 +166,6 @@ describe('Sidebar', () => { expect(activeLinks[0]).toHaveTextContent(/^budget$/i); }); - it('renders a close button with correct aria-label', () => { - renderWithRouter(); - - const closeButton = screen.getByRole('button', { name: /close menu/i }); - expect(closeButton).toBeInTheDocument(); - }); - - it('clicking close button calls onClose', async () => { - const user = userEvent.setup(); - renderWithRouter(); - - const closeButton = screen.getByRole('button', { name: /close menu/i }); - await user.click(closeButton); - - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - it('sidebar has .open class when isOpen is true', () => { renderWithRouter(); @@ -312,10 +295,10 @@ describe('Sidebar', () => { // 4 main nav links (Project, Budget, Schedule, Diary) + 1 logo link + 1 GitHub link // (Settings is now a button, not a link) expect(links).toHaveLength(6); - // 4 buttons: close button + theme toggle + settings button + logout button - expect(buttons).toHaveLength(4); - expect(buttons[0]).toHaveAttribute('aria-label', 'Close menu'); - expect(buttons[3]).toHaveTextContent(/logout/i); + // 3 buttons: theme toggle + settings button + logout button + expect(buttons).toHaveLength(3); + expect(buttons[0]).toHaveAttribute('aria-label', expect.stringMatching(/switch to .+ mode/i)); + expect(buttons[2]).toHaveTextContent(/logout/i); }); it('Settings button appears immediately before the Logout button in the footer', () => { diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index 9d39bdec2..5208a46a9 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -23,16 +23,6 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) { {t('appName')} -
- -