From 834ad1beb51e018d7b05749c924cb4b2972d190f Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Mon, 2 Nov 2026 23:19:07 -0500 Subject: [PATCH 1/3] feat(web): show status in tables as colour tags instead of tinted text Co-Authored-By: Claude Opus 5.5 --- .../components/ColorTagCell/ColorTagCell.tsx | 6 ++--- .../UserPermissionsEditor.tsx | 13 ++++++----- .../__tests__/UserPermissionsEditor.test.tsx | 12 ++++++++++ .../_app/admin/__tests__/instruments.test.tsx | 14 ++++++++++++ .../web/src/routes/_app/admin/instruments.tsx | 12 ++++++---- .../_app/admin/users/__tests__/index.test.tsx | 12 ++++++++++ .../web/src/routes/_app/admin/users/index.tsx | 22 +++++++++++++------ apps/web/src/utils/tag-colors.ts | 22 ++++++++++++++++++- testing/src/specs/admin-instruments.spec.ts | 4 ++++ testing/src/specs/admin-management.spec.ts | 7 ++++++ 10 files changed, 103 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ColorTagCell/ColorTagCell.tsx b/apps/web/src/components/ColorTagCell/ColorTagCell.tsx index 35eccecb2..f079f2ece 100644 --- a/apps/web/src/components/ColorTagCell/ColorTagCell.tsx +++ b/apps/web/src/components/ColorTagCell/ColorTagCell.tsx @@ -1,6 +1,6 @@ type ColorTagCellProps = { + [dataAttribute: `data-${string}`]: string | undefined; color: string; - 'data-testid'?: string; label: string; }; @@ -12,8 +12,8 @@ type ColorTagCellProps = { * colourblind user and why the text itself is never tinted. The label clips rather than wrapping, so * one long value cannot make every row in the table taller. */ -export const ColorTagCell = ({ color, 'data-testid': testId, label }: ColorTagCellProps) => ( - +export const ColorTagCell = ({ color, label, ...dataAttributes }: ColorTagCellProps) => ( + {/* The hairline ring is what lets a pastel fill stay visible against a light surface, where a bare dot of it would sit near 1.5:1 and effectively disappear. */} {permission.groupId === null ? ( - - - {allGroupsLabel} - + ) : ( (groups.find((group) => group.id === permission.groupId)?.name ?? permission.groupId) )} diff --git a/apps/web/src/components/UserPermissionsEditor/__tests__/UserPermissionsEditor.test.tsx b/apps/web/src/components/UserPermissionsEditor/__tests__/UserPermissionsEditor.test.tsx index ad7f34c98..80491530f 100644 --- a/apps/web/src/components/UserPermissionsEditor/__tests__/UserPermissionsEditor.test.tsx +++ b/apps/web/src/components/UserPermissionsEditor/__tests__/UserPermissionsEditor.test.tsx @@ -149,6 +149,18 @@ describe('UserPermissionsEditor', () => { expect(screen.getByTestId('user-permission-scope').textContent).toBe('group-9'); }); + it('should tag a grant across all groups with an amber dot, so its wider reach stands out', () => { + render( + + ); + const tag = screen.getByTestId('user-permission-scope').firstElementChild!; + expect(tag.textContent).toBe('All Groups'); + expect((tag.firstElementChild as HTMLElement).style.backgroundColor).toBe('#d97706'); + }); + it('should remove a draft without changing existing grants', () => { render(); fireEvent.click(screen.getByTestId('permission-draft-remove')); diff --git a/apps/web/src/routes/_app/admin/__tests__/instruments.test.tsx b/apps/web/src/routes/_app/admin/__tests__/instruments.test.tsx index a15dbbfb5..2ebda6ace 100644 --- a/apps/web/src/routes/_app/admin/__tests__/instruments.test.tsx +++ b/apps/web/src/routes/_app/admin/__tests__/instruments.test.tsx @@ -293,6 +293,20 @@ describe('Route', () => { expect(screen.getByTestId('series-status').getAttribute('data-archived')).toBe('true'); }); + it('should tag an active series with a green dot, so the status scans without tinting the label', () => { + mocks.seriesOverview = [overviewFixture()]; + renderPage('series'); + const dot = screen.getByTestId('series-status').firstElementChild as HTMLElement; + expect(dot.style.backgroundColor).toBe('#16a34a'); + }); + + it('should tag an archived series with a red dot, so the status scans without tinting the label', () => { + mocks.seriesOverview = [overviewFixture({ archivedAt: new Date('2026-09-02T12:00:00Z') })]; + renderPage('series'); + const dot = screen.getByTestId('series-status').firstElementChild as HTMLElement; + expect(dot.style.backgroundColor).toBe('#dc2626'); + }); + it('should list active series before archived ones', () => { mocks.seriesOverview = [ overviewFixture({ archivedAt: new Date('2026-09-02'), id: 'series-a', title: 'Alpha' }), diff --git a/apps/web/src/routes/_app/admin/instruments.tsx b/apps/web/src/routes/_app/admin/instruments.tsx index 6d543d66b..6dca95c88 100644 --- a/apps/web/src/routes/_app/admin/instruments.tsx +++ b/apps/web/src/routes/_app/admin/instruments.tsx @@ -3,12 +3,13 @@ import type { ComponentProps } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { Button, DataTable, Dialog, Heading } from '@douglasneuroinformatics/libui/components'; -import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { cn } from '@douglasneuroinformatics/libui/utils'; import { translateInstrumentInfo } from '@opendatacapture/instrument-utils'; import type { InstrumentInfo, TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; import { createFileRoute } from '@tanstack/react-router'; +import { ColorTagCell } from '@/components/ColorTagCell'; import { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog'; import type { InstrumentPreviewItem, InstrumentSource } from '@/components/InstrumentPreviewDialog'; import { PageHeader } from '@/components/PageHeader'; @@ -20,9 +21,11 @@ import { } from '@/hooks/useSeriesInstrumentsOverviewQuery'; import { useUpdateSeriesInstrumentArchiveMutation } from '@/hooks/useUpdateSeriesInstrumentArchiveMutation'; import { $AdminInstrumentsSearch } from '@/utils/admin-instruments-search'; +import { resolveTheme } from '@/utils/chart-theme'; import { selectLatestEditions } from '@/utils/instrument-editions'; import { sortSeriesOverviewRows } from '@/utils/series-overview'; import type { SeriesOverviewRow } from '@/utils/series-overview'; +import { getArchiveStatusColor } from '@/utils/tag-colors'; type SeriesRow = SeriesOverviewRow & { createdAt: Date | null; @@ -229,6 +232,7 @@ const FormInstrumentsView = ({ scalarInstruments }: InstrumentViewProps) => { const SeriesInstrumentsView = ({ scalarInstruments }: InstrumentViewProps) => { const { resolvedLanguage, t } = useTranslation(); + const [theme] = useTheme(); const overviewQuery = useSeriesInstrumentsOverviewQuery(); const unarchiveMutation = useUpdateSeriesInstrumentArchiveMutation(); const toSource = useInstrumentSource(); @@ -303,11 +307,11 @@ const SeriesInstrumentsView = ({ scalarInstruments }: InstrumentViewProps) => { cell: (ctx) => { const row = ctx.row.original; return ( - ); }, diff --git a/apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx b/apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx index 36fb96561..f31ca396a 100644 --- a/apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx @@ -126,6 +126,18 @@ describe('Route', () => { expect(screen.getByTestId('user-status-archived').textContent).toBe('Archived on 2026-10-01'); }); + it('should tag an active user with a green dot, so the status scans without tinting the label', () => { + renderTable([userFixture()]); + const dot = screen.getByTestId('user-status-active').firstElementChild as HTMLElement; + expect(dot.style.backgroundColor).toBe('#16a34a'); + }); + + it('should tag an archived user with a red dot, so the status scans without tinting the label', () => { + renderTable([userFixture({ archivedAt: new Date('2026-10-01T00:00:00Z') })]); + const dot = screen.getByTestId('user-status-archived').firstElementChild as HTMLElement; + expect(dot.style.backgroundColor).toBe('#dc2626'); + }); + it('should label a user without a base permission level as having none', () => { renderTable([userFixture({ basePermissionLevel: null })]); expect(within(rows()[0]!).getByText('None')).toBeTruthy(); diff --git a/apps/web/src/routes/_app/admin/users/index.tsx b/apps/web/src/routes/_app/admin/users/index.tsx index b4bbbe042..df28fd62b 100644 --- a/apps/web/src/routes/_app/admin/users/index.tsx +++ b/apps/web/src/routes/_app/admin/users/index.tsx @@ -3,17 +3,20 @@ import React, { useState } from 'react'; import { snakeToCamelCase, toBasicISOString } from '@douglasneuroinformatics/libjs'; import { Button, DataTable, Dialog, Heading } from '@douglasneuroinformatics/libui/components'; import type { TanstackTable } from '@douglasneuroinformatics/libui/components'; -import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { cn } from '@douglasneuroinformatics/libui/utils'; import type { User } from '@opendatacapture/schemas/user'; import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'; import { ChevronDownIcon, ChevronsUpDownIcon, ChevronUpIcon } from 'lucide-react'; +import { ColorTagCell } from '@/components/ColorTagCell'; import { PageHeader } from '@/components/PageHeader'; import { useArchiveUserMutation } from '@/hooks/useArchiveUserMutation'; import { useUnarchiveUserMutation } from '@/hooks/useUnarchiveUserMutation'; import { usersQueryOptions, useUsersQuery } from '@/hooks/useUsersQuery'; import { useAppStore } from '@/store'; +import { resolveTheme } from '@/utils/chart-theme'; +import { getArchiveStatusColor } from '@/utils/tag-colors'; type ArchiveAction = { kind: 'archive'; user: User }; @@ -36,6 +39,7 @@ const SortableHeader = ({ column, label }: { column: TanstackTable.Column; const RouteComponent = () => { const { t } = useTranslation(); + const [theme] = useTheme(); const navigate = useNavigate(); const usersQuery = useUsersQuery(); const archiveUserMutation = useArchiveUserMutation(); @@ -113,19 +117,23 @@ const RouteComponent = () => { const user = ctx.row.original; if (user.archivedAt) { return ( - - {t({ + + /> ); } return ( - - {t({ en: 'Active', es: 'Activo', fr: 'Actif' })} - + ); }, header: ({ column }) => ( diff --git a/apps/web/src/utils/tag-colors.ts b/apps/web/src/utils/tag-colors.ts index b9835ea1e..b683d5d81 100644 --- a/apps/web/src/utils/tag-colors.ts +++ b/apps/web/src/utils/tag-colors.ts @@ -35,6 +35,18 @@ const SYNC_STATUS_COLORS = { SUCCESS: { dark: '#22c55e', light: '#16a34a' } } as const satisfies { [K in InstrumentRepoSyncStatus]: { dark: string; light: string } }; +/** The same hues as a sync status: green for in use, red for archived. */ +const ARCHIVE_STATUS_COLORS = { + ACTIVE: { dark: '#22c55e', light: '#16a34a' }, + ARCHIVED: { dark: '#ef4444', light: '#dc2626' } +} as const; + +/** Amber, because a grant across all groups is wider than a grant to one and worth noticing. */ +const ALL_GROUPS_SCOPE_COLOR = { + dark: '#f59e0b', + light: '#d97706' +} as const; + const NEUTRAL_COLOR = { dark: '#a3a3a3', light: '#525252' @@ -55,4 +67,12 @@ function getSyncStatusColor(status: InstrumentRepoSyncStatus, mode: Mode): strin return SYNC_STATUS_COLORS[status][mode]; } -export { getInstrumentKindColor, getSexColor, getSyncStatusColor }; +function getArchiveStatusColor(isArchived: boolean, mode: Mode): string { + return ARCHIVE_STATUS_COLORS[isArchived ? 'ARCHIVED' : 'ACTIVE'][mode]; +} + +function getAllGroupsScopeColor(mode: Mode): string { + return ALL_GROUPS_SCOPE_COLOR[mode]; +} + +export { getAllGroupsScopeColor, getArchiveStatusColor, getInstrumentKindColor, getSexColor, getSyncStatusColor }; diff --git a/testing/src/specs/admin-instruments.spec.ts b/testing/src/specs/admin-instruments.spec.ts index 092de17db..d32d620f3 100644 --- a/testing/src/specs/admin-instruments.spec.ts +++ b/testing/src/specs/admin-instruments.spec.ts @@ -59,6 +59,10 @@ test.describe('admin instruments', () => { await expect(seriesPage.archiveDialog).toContainText(`SeriesArchive${uniqueId}`); await seriesPage.confirmArchive.click(); await expect(seriesPage.seriesStatus(title)).toHaveAttribute('data-archived', 'true'); + await expect(seriesPage.seriesStatus(title).locator('span').first()).toHaveCSS( + 'background-color', + 'rgb(220, 38, 38)' + ); const assignment = await apiRequestContext.post(`${API}/assignments`, { data: { expiresAt: new Date(Date.now() + 86_400_000), groupId: group.id, instrumentId: seriesId, subjectId }, diff --git a/testing/src/specs/admin-management.spec.ts b/testing/src/specs/admin-management.spec.ts index f82942fd9..bc1c0c1c0 100644 --- a/testing/src/specs/admin-management.spec.ts +++ b/testing/src/specs/admin-management.spec.ts @@ -64,6 +64,10 @@ test.describe('admin management', () => { await search.fill(enabled.username); await expect(page.getByTestId('user-login-status')).toHaveText('Enabled'); await expect(page.getByTestId('user-status-active')).toBeVisible(); + await expect(page.getByTestId('user-status-active').locator('span').first()).toHaveCSS( + 'background-color', + 'rgb(22, 163, 74)' + ); await search.fill(disabled.username); await expect(page.getByTestId('user-login-status')).toHaveText('Disabled'); await expect(page.getByTestId('user-status-active')).toBeVisible(); @@ -321,6 +325,9 @@ test.describe('admin management', () => { await expect(userPage.permissionRows.first()).toContainText('Read'); await expect(userPage.permissionRows.first()).toContainText('Subject'); await expect(userPage.permissionRows.first().getByTestId('user-permission-scope')).toContainText('All Groups'); + await expect( + userPage.permissionRows.first().getByTestId('user-permission-scope').locator('span span').first() + ).toHaveCSS('background-color', 'rgb(217, 119, 6)'); }); test('should save a user whose new password was typed and then cleared', async ({ api, getPageModel, uniqueId }) => { From 0444a7d3a0c920d7caf1819ee548f9aec94e615c Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Tue, 3 Nov 2026 00:41:59 -0500 Subject: [PATCH 2/3] feat: add an instance-wide table color mode and tag more table columns Admins choose tags, colored text or no color under UI Preferences in the application settings, and every ColorTagCell draws in that mode. The forms view Kind, audit log Action, group Type and remote assignment Status columns become tags, so they follow the setting too. Co-Authored-By: Claude Opus 5.5 --- apps/api/prisma/schema.prisma | 7 + .../src/setup/__tests__/setup.service.test.ts | 21 ++ apps/api/src/setup/setup.service.ts | 3 +- apps/web/AGENTS.md | 5 + .../BulkRemoteAssignmentWizard.tsx | 9 +- .../BulkRemoteAssignmentWizard.test.tsx | 19 +- .../components/ColorTagCell/ColorTagCell.tsx | 55 ++++-- .../__tests__/ColorTagCell.test.tsx | 57 ++++++ .../DeleteRemoteAssignments.tsx | 28 +-- .../DeleteRemoteAssignments.test.tsx | 12 ++ .../InstrumentKindCell/InstrumentKindCell.tsx | 24 +++ .../components/InstrumentKindCell/index.ts | 1 + .../InstrumentRepoSyncLogTable.test.tsx | 1 + .../InstrumentTable/InstrumentTable.tsx | 19 +- .../__tests__/InstrumentTable.test.tsx | 1 + .../LoginPageEditor/__tests__/hooks.test.tsx | 1 + .../fields/__tests__/SectionHeader.test.tsx | 1 + .../__tests__/SectionCards.harness.tsx | 1 + .../SelectTableColorMode.tsx | 55 ++++++ .../__tests__/SelectTableColorMode.test.tsx | 67 +++++++ .../components/SelectTableColorMode/index.ts | 1 + .../__tests__/UserPermissionsEditor.test.tsx | 9 +- .../_app/admin/__tests__/instruments.test.tsx | 28 ++- .../_app/admin/__tests__/settings.test.tsx | 22 ++- .../_app/admin/audit/__tests__/logs.test.tsx | 13 ++ apps/web/src/routes/_app/admin/audit/logs.tsx | 14 +- .../admin/groups/__tests__/index.test.tsx | 20 +- .../src/routes/_app/admin/groups/index.tsx | 23 ++- .../instrument-repos/__tests__/index.test.tsx | 1 + .../web/src/routes/_app/admin/instruments.tsx | 24 +-- apps/web/src/routes/_app/admin/settings.tsx | 20 +- .../admin/users/__tests__/$userId.test.tsx | 1 + .../_app/admin/users/__tests__/index.test.tsx | 5 +- .../$instrumentId/__tests__/table.test.tsx | 1 + .../instruments/__tests__/index.test.tsx | 1 + .../datahub/subjects/__tests__/index.test.tsx | 1 + .../src/utils/__tests__/tag-colors.test.ts | 120 +++++++++++ apps/web/src/utils/tag-colors.ts | 186 +++++++++++++----- .../schemas/src/setup/__tests__/setup.test.ts | 10 + packages/schemas/src/setup/setup.ts | 12 +- testing/src/pages/_app/admin/settings.page.ts | 8 + testing/src/specs/admin-audit-logs.spec.ts | 1 + testing/src/specs/admin-instruments.spec.ts | 11 +- testing/src/specs/admin-management.spec.ts | 8 +- testing/src/specs/admin-settings.spec.ts | 27 +++ 45 files changed, 789 insertions(+), 165 deletions(-) create mode 100644 apps/web/src/components/ColorTagCell/__tests__/ColorTagCell.test.tsx create mode 100644 apps/web/src/components/InstrumentKindCell/InstrumentKindCell.tsx create mode 100644 apps/web/src/components/InstrumentKindCell/index.ts create mode 100644 apps/web/src/components/SelectTableColorMode/SelectTableColorMode.tsx create mode 100644 apps/web/src/components/SelectTableColorMode/__tests__/SelectTableColorMode.test.tsx create mode 100644 apps/web/src/components/SelectTableColorMode/index.ts create mode 100644 apps/web/src/utils/__tests__/tag-colors.test.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index a2d8a705b..c23295192 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -405,6 +405,12 @@ model Session { // Setup +enum TableColorMode { + none + tags + text +} + // Mirrors LANGUAGES in @opendatacapture/schemas/core. Values are the language codes themselves, // so a document read back through Prisma is already a `Language` with nothing to narrow. enum Language { @@ -493,6 +499,7 @@ model SetupState { isSetup Boolean mailConfig MailConfig? newUserEmailTemplate MailTemplate? + tableColorMode TableColorMode? @@map("SetupStateModel") } diff --git a/apps/api/src/setup/__tests__/setup.service.test.ts b/apps/api/src/setup/__tests__/setup.service.test.ts index e2ac96c40..e968b8021 100644 --- a/apps/api/src/setup/__tests__/setup.service.test.ts +++ b/apps/api/src/setup/__tests__/setup.service.test.ts @@ -67,6 +67,27 @@ describe('SetupService', () => { }); }); + describe('tableColorMode', () => { + it('should return the saved mode', async () => { + setupStateModel.findFirst.mockResolvedValue({ isDemo: false, isSetup: true, tableColorMode: 'text' }); + await expect(setupService.getState()).resolves.toMatchObject({ tableColorMode: 'text' }); + }); + + it('should fall back to tags for a document written before the setting existed', async () => { + setupStateModel.findFirst.mockResolvedValue({ isDemo: false, isSetup: true }); + await expect(setupService.getState()).resolves.toMatchObject({ tableColorMode: 'tags' }); + }); + + it('should persist the chosen mode', async () => { + setupStateModel.findFirst.mockResolvedValue({ id: '1', isDemo: false, isSetup: true }); + await setupService.updateState({ tableColorMode: 'none' }); + expect(setupStateModel.update).toHaveBeenCalledWith({ + data: { tableColorMode: 'none' }, + where: { id: '1' } + }); + }); + }); + describe('getState', () => { it('should return the saved defaultAssignmentDurationDays', async () => { setupStateModel.findFirst.mockResolvedValue({ defaultAssignmentDurationDays: 45, isDemo: false, isSetup: true }); diff --git a/apps/api/src/setup/setup.service.ts b/apps/api/src/setup/setup.service.ts index bc5236104..f5d0675bc 100644 --- a/apps/api/src/setup/setup.service.ts +++ b/apps/api/src/setup/setup.service.ts @@ -9,7 +9,7 @@ import { } from '@nestjs/common'; import { DEFAULT_ACTIVE_LANGUAGES } from '@opendatacapture/schemas/core'; import { isMailEnabled } from '@opendatacapture/schemas/mail'; -import { $BrandingConfig } from '@opendatacapture/schemas/setup'; +import { $BrandingConfig, DEFAULT_TABLE_COLOR_MODE } from '@opendatacapture/schemas/setup'; import type { $InitAppOptions, $UpdateSetupStateData, @@ -79,6 +79,7 @@ export class SetupService { isMailEnabled: isMailEnabled(savedOptions?.mailConfig), isSetup: Boolean(savedOptions?.isSetup), release: __RELEASE__, + tableColorMode: savedOptions?.tableColorMode ?? DEFAULT_TABLE_COLOR_MODE, uptime: Math.round(process.uptime()) } satisfies SetupState; } diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index fa3030633..6c94eb836 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -205,6 +205,11 @@ viewport's: measure it in the browser, or from a spec with the sidebar and the card's padding are taken out, which is what `testing/src/specs/admin-instrument-repos.spec.ts` holds both of their tables to. +**A coloured value in a `DataTable` column goes through `ColorTagCell`, never a tinted class.** It draws in the instance-wide +`tableColorMode` an admin picks in `/admin/settings` (dot, tinted text or no colour), so a cell that +sets its own colour ignores that setting. Its colours come from `src/utils/tag-colors.ts`. It reads the +setting through `useSetupStateQuery`, so a unit test rendering one has to mock that hook. + `initialState` is where pinning goes. libui typed it as `never` in `mode="server"` until 6.19.0 ([libui#129](https://github.com/DouglasNeuroInformatics/libui/pull/129)), so the catalog cannot go back below that release. diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx index df8042620..87f77ca66 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/BulkRemoteAssignmentWizard.tsx @@ -49,6 +49,9 @@ type ResultTableProps = { */ const ResultTable = ({ assignments, emails, statuses, subjectIdDisplayLength, withEmail }: ResultTableProps) => { const { t } = useTranslation(); + const { + data: { tableColorMode } + } = useSetupStateQuery(); return ( - {withEmail && {status ? STATUS_ICONS[status] : null}} + {withEmail && ( + + {status ? STATUS_ICONS[status] : null} + + )} ); })} diff --git a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx index 2a0e4475f..510beb806 100644 --- a/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx +++ b/apps/web/src/components/BulkRemoteAssignmentWizard/__tests__/BulkRemoteAssignmentWizard.test.tsx @@ -5,7 +5,7 @@ import { i18n } from '@douglasneuroinformatics/libui/i18n'; import type { BulkAssignmentFailure } from '@opendatacapture/schemas/assignment'; import type { Language } from '@opendatacapture/schemas/core'; import type { Group } from '@opendatacapture/schemas/group'; -import type { SetupState } from '@opendatacapture/schemas/setup'; +import type { SetupState, TableColorMode } from '@opendatacapture/schemas/setup'; import type { Subject } from '@opendatacapture/schemas/subject'; import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, assert, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -168,9 +168,10 @@ const mockGroup = (group: GroupStub | undefined, { isLoading = false }: { isLoad const setupMocks = ({ activeLanguages = ['en', 'es', 'fr'], - isMailEnabled = false -}: { activeLanguages?: Language[]; isMailEnabled?: boolean } = {}) => { - mockSetupState({ activeLanguages: activeLanguages as SetupState['activeLanguages'], isMailEnabled }); + isMailEnabled = false, + tableColorMode = 'tags' +}: { activeLanguages?: Language[]; isMailEnabled?: boolean; tableColorMode?: TableColorMode } = {}) => { + mockSetupState({ activeLanguages: activeLanguages as SetupState['activeLanguages'], isMailEnabled, tableColorMode }); mockGroup({}); vi.mocked(useSendBulkAssignmentEmailMutation).mockReturnValue({ mutateAsync: mockMutateAsync @@ -508,6 +509,16 @@ describe('BulkRemoteAssignmentWizard', () => { ); }); + it('should drop the color of the status icons when the instance turned table colors off', async () => { + setupMocks({ isMailEnabled: true, tableColorMode: 'none' }); + mockMutateAsync.mockResolvedValue({ status: 'FAILED' }); + mockWizard('DONE', { assignments: batteryAssignments, emails }); + renderWizard(); + sendAll(); + await waitFor(() => expect(lastNotification()).toBeTruthy()); + expect(screen.getByTestId('bulk-email-table').querySelector('td.grayscale svg')).not.toBeNull(); + }); + it('should mark every row of a subject with the outcome of their one email', async () => { setupMocks({ isMailEnabled: true }); mockMutateAsync.mockResolvedValue({ status: 'FAILED' }); diff --git a/apps/web/src/components/ColorTagCell/ColorTagCell.tsx b/apps/web/src/components/ColorTagCell/ColorTagCell.tsx index f079f2ece..70acf4be9 100644 --- a/apps/web/src/components/ColorTagCell/ColorTagCell.tsx +++ b/apps/web/src/components/ColorTagCell/ColorTagCell.tsx @@ -1,25 +1,48 @@ -type ColorTagCellProps = { - [dataAttribute: `data-${string}`]: string | undefined; - color: string; +import type { TableColorMode } from '@opendatacapture/schemas/setup'; + +import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; +import type { TagColor } from '@/utils/tag-colors'; + +type ColorTagProps = { + color: TagColor; + 'data-archived'?: 'false' | 'true'; + 'data-testid'?: string; label: string; + mode: TableColorMode; }; /** - * A table cell tagging a nominal value with a colour: a dot carrying the colour, and the name beside - * it in ordinary ink. - * - * The colour is always redundant — the label is right there — which is what keeps it readable for a - * colourblind user and why the text itself is never tinted. The label clips rather than wrapping, so - * one long value cannot make every row in the table taller. + * A nominal value drawn in one of the instance's table colour modes: a dot beside the name in ordinary + * ink, the name itself tinted, or the name alone. The label clips rather than wrapping, so one long + * value cannot make every row in a table taller. */ -export const ColorTagCell = ({ color, label, ...dataAttributes }: ColorTagCellProps) => ( +const ColorTag = ({ color, label, mode, ...dataAttributes }: ColorTagProps) => ( - {/* The hairline ring is what lets a pastel fill stay visible against a light surface, where a - bare dot of it would sit near 1.5:1 and effectively disappear. */} + {mode === 'tags' && ( + // The hairline ring is what lets a pastel fill stay visible against a light surface, where a + // bare dot of it would sit near 1.5:1 and effectively disappear. + + )} - {label} + className="truncate" + data-testid="color-tag-label" + style={mode === 'text' ? { color: color.text } : undefined} + > + {label} + ); + +type ColorTagCellProps = Omit; + +/** A table cell tagging a nominal value with a colour, drawn in the mode the admin chose. */ +const ColorTagCell = (props: ColorTagCellProps) => { + const { data } = useSetupStateQuery(); + return ; +}; + +export { ColorTag, ColorTagCell }; diff --git a/apps/web/src/components/ColorTagCell/__tests__/ColorTagCell.test.tsx b/apps/web/src/components/ColorTagCell/__tests__/ColorTagCell.test.tsx new file mode 100644 index 000000000..022e048b1 --- /dev/null +++ b/apps/web/src/components/ColorTagCell/__tests__/ColorTagCell.test.tsx @@ -0,0 +1,57 @@ +import type { TableColorMode } from '@opendatacapture/schemas/setup'; +import { cleanup, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { ColorTag, ColorTagCell } from '../ColorTagCell'; + +const mocks = vi.hoisted((): { tableColorMode: TableColorMode } => ({ tableColorMode: 'tags' })); + +vi.mock('@/hooks/useSetupStateQuery', () => ({ + useSetupStateQuery: () => ({ data: { tableColorMode: mocks.tableColorMode } }) +})); + +const COLOR = { fill: '#16a34a', text: '#15803d' }; + +const tag = () => screen.getByTestId('tag'); + +const dots = () => within(tag()).queryAllByTestId('color-tag-dot'); + +const label = () => screen.getByText('Active'); + +describe('ColorTag', () => { + afterEach(cleanup); + + it('should draw a filled dot and leave the label in ordinary ink in tags mode', () => { + render(); + expect(dots()[0]!.style.backgroundColor).toBe('#16a34a'); + expect(label().style.color).toBe(''); + }); + + it('should tint the label and draw no dot in text mode', () => { + render(); + expect(dots()).toHaveLength(0); + expect(label().style.color).toBe('#15803d'); + }); + + it('should draw neither a dot nor a tint in none mode', () => { + render(); + expect(dots()).toHaveLength(0); + expect(label().style.color).toBe(''); + }); + + it('should forward data attributes, so a spec can read the state a cell carries', () => { + render(); + expect(tag().getAttribute('data-archived')).toBe('true'); + }); +}); + +describe('ColorTagCell', () => { + afterEach(cleanup); + + it('should draw in the mode the instance is set to, so one setting governs every table', () => { + mocks.tableColorMode = 'text'; + render(); + expect(dots()).toHaveLength(0); + expect(label().style.color).toBe('#15803d'); + }); +}); diff --git a/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx b/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx index 08484df9a..01c6dc6c9 100644 --- a/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx +++ b/apps/web/src/components/DeleteRemoteAssignments/DeleteRemoteAssignments.tsx @@ -2,16 +2,18 @@ import React, { useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { Button, Card, Checkbox, DataTable, Dialog } from '@douglasneuroinformatics/libui/components'; -import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { Assignment } from '@opendatacapture/schemas/assignment'; import type { UnilingualInstrumentInfo } from '@opendatacapture/schemas/instrument'; import { removeSubjectIdScope } from '@opendatacapture/subject-utils'; -import { CircleAlertIcon, CircleDotIcon } from 'lucide-react'; +import { ColorTagCell } from '@/components/ColorTagCell'; import { SortableHeader } from '@/components/SortableHeader'; import { useAssignmentsQuery } from '@/hooks/useAssignmentsQuery'; import { useDeleteBulkAssignmentsMutation } from '@/hooks/useDeleteBulkAssignmentsMutation'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; +import { resolveTheme } from '@/utils/chart-theme'; +import { getAssignmentStatusColor } from '@/utils/tag-colors'; type AssignmentRow = { createdAt: string; @@ -24,19 +26,17 @@ type AssignmentRow = { const StatusCell = ({ status }: { status: 'EXPIRED' | 'OUTSTANDING' }) => { const { t } = useTranslation(); - if (status === 'OUTSTANDING') { - return ( - - - {t({ en: 'Outstanding', es: 'Pendiente', fr: 'En attente' })} - - ); - } + const [theme] = useTheme(); + const labels = { + EXPIRED: t({ en: 'Expired', es: 'Expirado', fr: 'Expiré' }), + OUTSTANDING: t({ en: 'Outstanding', es: 'Pendiente', fr: 'En attente' }) + }; return ( - - - {t({ en: 'Expired', es: 'Expirado', fr: 'Expiré' })} - + ); }; diff --git a/apps/web/src/components/DeleteRemoteAssignments/__tests__/DeleteRemoteAssignments.test.tsx b/apps/web/src/components/DeleteRemoteAssignments/__tests__/DeleteRemoteAssignments.test.tsx index a73dc08d1..18cab1d18 100644 --- a/apps/web/src/components/DeleteRemoteAssignments/__tests__/DeleteRemoteAssignments.test.tsx +++ b/apps/web/src/components/DeleteRemoteAssignments/__tests__/DeleteRemoteAssignments.test.tsx @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => { return state; }); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@/hooks/useAssignmentsQuery', () => ({ ASSIGNMENTS_QUERY_KEY_PREFIX: 'assignments', assignmentsQueryOptions: () => ({}), @@ -285,4 +286,15 @@ describe('DeleteRemoteAssignments', () => { expect(confirmButton().disabled).toBe(true); expect(confirmButton().textContent).toBe('Deleting…'); }); + + it.each([ + ['OUTSTANDING', 'Outstanding', '#2563eb'], + ['EXPIRED', 'Expired', '#d97706'] + ] as const)('should tag a %s assignment with its color', (status, label, fill) => { + mocks.assignments = [makeAssignment({ id: 'a-1', status })]; + renderComponent(); + const cell = screen.getByTestId('assignment-status'); + expect(cell.textContent).toBe(label); + expect(cell.querySelector('[data-testid="color-tag-dot"]')!.style.backgroundColor).toBe(fill); + }); }); diff --git a/apps/web/src/components/InstrumentKindCell/InstrumentKindCell.tsx b/apps/web/src/components/InstrumentKindCell/InstrumentKindCell.tsx new file mode 100644 index 000000000..639322027 --- /dev/null +++ b/apps/web/src/components/InstrumentKindCell/InstrumentKindCell.tsx @@ -0,0 +1,24 @@ +import { useTheme } from '@douglasneuroinformatics/libui/hooks'; +import type { InstrumentKind } from '@opendatacapture/runtime-core'; + +import { ColorTagCell } from '@/components/ColorTagCell'; +import { useInstrumentKindLabels } from '@/hooks/useInstrumentKindLabels'; +import { resolveTheme } from '@/utils/chart-theme'; +import { getInstrumentKindColor } from '@/utils/tag-colors'; + +type InstrumentKindCellProps = { + kind: InstrumentKind; +}; + +/** A kind tag, matching the treatment the subject table gives sex. */ +export const InstrumentKindCell = ({ kind }: InstrumentKindCellProps) => { + const [theme] = useTheme(); + const labels = useInstrumentKindLabels(); + return ( + + ); +}; diff --git a/apps/web/src/components/InstrumentKindCell/index.ts b/apps/web/src/components/InstrumentKindCell/index.ts new file mode 100644 index 000000000..ce16cb773 --- /dev/null +++ b/apps/web/src/components/InstrumentKindCell/index.ts @@ -0,0 +1 @@ +export * from './InstrumentKindCell'; diff --git a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx index ef12d0fd9..22f915c85 100644 --- a/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx +++ b/apps/web/src/components/InstrumentRepoSyncLogTable/__tests__/InstrumentRepoSyncLogTable.test.tsx @@ -8,6 +8,7 @@ import '@/services/i18n'; const theme = vi.hoisted(() => ({ current: 'light' })); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@douglasneuroinformatics/libui/hooks', async (importOriginal) => ({ ...(await importOriginal()), useTheme: () => [theme.current, vi.fn()] diff --git a/apps/web/src/components/InstrumentTable/InstrumentTable.tsx b/apps/web/src/components/InstrumentTable/InstrumentTable.tsx index a226bd768..c1f62f150 100644 --- a/apps/web/src/components/InstrumentTable/InstrumentTable.tsx +++ b/apps/web/src/components/InstrumentTable/InstrumentTable.tsx @@ -3,24 +3,22 @@ import React, { useMemo, useState } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { Button, DataTable, DropdownMenu } from '@douglasneuroinformatics/libui/components'; import type { TanstackTable } from '@douglasneuroinformatics/libui/components'; -import { useDownload, useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { InstrumentKind } from '@opendatacapture/runtime-core'; import { ChevronDownIcon } from 'lucide-react'; import { unparse } from 'papaparse'; import { CollectedFilterMenu } from '@/components/CollectedFilterMenu'; -import { ColorTagCell } from '@/components/ColorTagCell'; +import { InstrumentKindCell } from '@/components/InstrumentKindCell'; import { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog'; import type { InstrumentPreviewItem } from '@/components/InstrumentPreviewDialog'; import { SortableHeader } from '@/components/SortableHeader'; import { TruncatedCell } from '@/components/TruncatedCell'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; import { useInstrumentKindLabels } from '@/hooks/useInstrumentKindLabels'; -import { resolveTheme } from '@/utils/chart-theme'; import { DEFAULT_COLLECTED_FILTER, matchesCollectedFilter } from '@/utils/collected-filter'; import type { CollectedFilter } from '@/utils/collected-filter'; import { downloadSubjectTableExcel } from '@/utils/excel'; -import { getInstrumentKindColor } from '@/utils/tag-colors'; /** Listed in the order a reader is most likely to meet them, not the enum's own order. */ const INSTRUMENT_KINDS = ['FORM', 'INTERACTIVE', 'FILE', 'SERIES'] as const satisfies readonly InstrumentKind[]; @@ -58,19 +56,6 @@ type InstrumentRow = { type KindFilter = InstrumentKind[]; -/** A kind tag, matching the treatment the subject table gives sex. */ -const InstrumentKindCell = ({ kind }: { kind: InstrumentKind }) => { - const [theme] = useTheme(); - const labels = useInstrumentKindLabels(); - return ( - - ); -}; - /** * The toolbar every instrument listing shares: filter by kind and by how much was collected, then * export exactly the rows left on screen. diff --git a/apps/web/src/components/InstrumentTable/__tests__/InstrumentTable.test.tsx b/apps/web/src/components/InstrumentTable/__tests__/InstrumentTable.test.tsx index 002d0051e..51ee0aabc 100644 --- a/apps/web/src/components/InstrumentTable/__tests__/InstrumentTable.test.tsx +++ b/apps/web/src/components/InstrumentTable/__tests__/InstrumentTable.test.tsx @@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({ preview: { current: null as null | { item: unknown; items: unknown; onClose: () => void } } })); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@douglasneuroinformatics/libui/hooks', async (importOriginal) => ({ ...(await importOriginal()), useDownload: () => mocks.download, diff --git a/apps/web/src/components/LoginPageEditor/__tests__/hooks.test.tsx b/apps/web/src/components/LoginPageEditor/__tests__/hooks.test.tsx index e057b9b76..34e0463f2 100644 --- a/apps/web/src/components/LoginPageEditor/__tests__/hooks.test.tsx +++ b/apps/web/src/components/LoginPageEditor/__tests__/hooks.test.tsx @@ -87,6 +87,7 @@ function createSetupState(branding: BrandingConfig | null): SetupState { isMailEnabled: false, isSetup: true, release: { buildTime: 0, type: 'production', version: '1.0.0' }, + tableColorMode: 'tags', uptime: 0 }; } diff --git a/apps/web/src/components/LoginPageEditor/fields/__tests__/SectionHeader.test.tsx b/apps/web/src/components/LoginPageEditor/fields/__tests__/SectionHeader.test.tsx index b7ef586db..7473c5439 100644 --- a/apps/web/src/components/LoginPageEditor/fields/__tests__/SectionHeader.test.tsx +++ b/apps/web/src/components/LoginPageEditor/fields/__tests__/SectionHeader.test.tsx @@ -26,6 +26,7 @@ const SETUP_STATE: SetupState = { isMailEnabled: false, isSetup: true, release: { buildTime: 0, type: 'production', version: '1.0.0' }, + tableColorMode: 'tags', uptime: 0 }; diff --git a/apps/web/src/components/LoginPageEditor/sections/__tests__/SectionCards.harness.tsx b/apps/web/src/components/LoginPageEditor/sections/__tests__/SectionCards.harness.tsx index b441161db..c3adddf0b 100644 --- a/apps/web/src/components/LoginPageEditor/sections/__tests__/SectionCards.harness.tsx +++ b/apps/web/src/components/LoginPageEditor/sections/__tests__/SectionCards.harness.tsx @@ -42,6 +42,7 @@ async function renderWithBrandingEditor( isMailEnabled: false, isSetup: true, release: { buildTime: 0, type: 'production', version: '1.0.0' }, + tableColorMode: 'tags', uptime: 0 }; const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false }, queries: { retry: false } } }); diff --git a/apps/web/src/components/SelectTableColorMode/SelectTableColorMode.tsx b/apps/web/src/components/SelectTableColorMode/SelectTableColorMode.tsx new file mode 100644 index 000000000..5f7ff396f --- /dev/null +++ b/apps/web/src/components/SelectTableColorMode/SelectTableColorMode.tsx @@ -0,0 +1,55 @@ +import { Select } from '@douglasneuroinformatics/libui/components'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { $TableColorMode } from '@opendatacapture/schemas/setup'; +import type { TableColorMode } from '@opendatacapture/schemas/setup'; + +import { ColorTag } from '@/components/ColorTagCell'; +import { resolveTheme } from '@/utils/chart-theme'; +import { getArchiveStatusColor } from '@/utils/tag-colors'; + +type SelectTableColorModeProps = { + onSelect: (mode: TableColorMode) => void; + value: TableColorMode; +}; + +/** Each option draws real table values in its mode, so the choice is visible rather than named. */ +export const SelectTableColorMode = ({ onSelect, value }: SelectTableColorModeProps) => { + const { t } = useTranslation(); + const [theme] = useTheme(); + const resolvedTheme = resolveTheme(theme); + + const modeLabels: { [K in TableColorMode]: string } = { + none: t({ en: 'No color', es: 'Sin color', fr: 'Sans couleur' }), + tags: t({ en: 'Tags', es: 'Etiquetas', fr: 'Étiquettes' }), + text: t({ en: 'Colored text', es: 'Texto de color', fr: 'Texte coloré' }) + }; + + return ( + + ); +}; diff --git a/apps/web/src/components/SelectTableColorMode/__tests__/SelectTableColorMode.test.tsx b/apps/web/src/components/SelectTableColorMode/__tests__/SelectTableColorMode.test.tsx new file mode 100644 index 000000000..2195ab3d7 --- /dev/null +++ b/apps/web/src/components/SelectTableColorMode/__tests__/SelectTableColorMode.test.tsx @@ -0,0 +1,67 @@ +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SelectTableColorMode } from '../SelectTableColorMode'; + +import '@/services/i18n'; + +const trigger = () => screen.getByTestId('table-color-mode-select'); + +const open = () => fireEvent.keyDown(trigger(), { key: 'Enter' }); + +const option = (mode: string) => screen.getByTestId(`table-color-mode-option-${mode}`); + +describe('SelectTableColorMode', () => { + beforeEach(() => { + i18n.changeLanguage('en'); + }); + + afterEach(() => { + cleanup(); + window.localStorage.clear(); + }); + + it('should show the selected mode on the closed control', () => { + render(); + expect(trigger().textContent).toContain('Colored text'); + }); + + it('should offer every mode with the default first', () => { + render(); + open(); + expect(screen.getAllByRole('option').map((item) => item.getAttribute('data-testid'))).toStrictEqual([ + 'table-color-mode-option-tags', + 'table-color-mode-option-text', + 'table-color-mode-option-none' + ]); + }); + + it('should draw dots only in the tags example, so each option previews its own mode', () => { + render(); + open(); + const dots = (mode: string) => within(option(mode)).queryAllByTestId('color-tag-dot'); + expect(dots('tags')).toHaveLength(2); + expect(dots('text')).toHaveLength(0); + expect(dots('none')).toHaveLength(0); + }); + + it('should tint the labels only in the text example, so each option previews its own mode', () => { + render(); + open(); + const tinted = (mode: string) => { + return within(option(mode)) + .getAllByTestId('color-tag-label') + .filter((label) => label.style.color).length; + }; + expect([tinted('tags'), tinted('text'), tinted('none')]).toStrictEqual([0, 2, 0]); + }); + + it('should report the chosen mode', () => { + const onSelect = vi.fn(); + render(); + open(); + fireEvent.click(option('none')); + expect(onSelect).toHaveBeenCalledWith('none'); + }); +}); diff --git a/apps/web/src/components/SelectTableColorMode/index.ts b/apps/web/src/components/SelectTableColorMode/index.ts new file mode 100644 index 000000000..7141bb474 --- /dev/null +++ b/apps/web/src/components/SelectTableColorMode/index.ts @@ -0,0 +1 @@ +export * from './SelectTableColorMode'; diff --git a/apps/web/src/components/UserPermissionsEditor/__tests__/UserPermissionsEditor.test.tsx b/apps/web/src/components/UserPermissionsEditor/__tests__/UserPermissionsEditor.test.tsx index 80491530f..8650b5a4c 100644 --- a/apps/web/src/components/UserPermissionsEditor/__tests__/UserPermissionsEditor.test.tsx +++ b/apps/web/src/components/UserPermissionsEditor/__tests__/UserPermissionsEditor.test.tsx @@ -3,7 +3,7 @@ import type { ComponentProps } from 'react'; import type { Permissions } from '@opendatacapture/schemas/core'; import type { User } from '@opendatacapture/schemas/user'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { UserPermissionsEditor } from '@/components/UserPermissionsEditor/UserPermissionsEditor'; @@ -11,6 +11,8 @@ import type { PermissionDraft } from '@/utils/permissions'; import '@/services/i18n'; +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); + const onPermissionsChange = vi.fn(); const onDraftsChange = vi.fn(); @@ -156,9 +158,8 @@ describe('UserPermissionsEditor', () => { user={{ ...user, additionalPermissions: [{ action: 'read', groupId: null, subject: 'Subject' }] }} /> ); - const tag = screen.getByTestId('user-permission-scope').firstElementChild!; - expect(tag.textContent).toBe('All Groups'); - expect((tag.firstElementChild as HTMLElement).style.backgroundColor).toBe('#d97706'); + const scope = within(screen.getByTestId('user-permission-scope')); + expect(scope.getByTestId('color-tag-dot').style.backgroundColor).toBe('#d97706'); }); it('should remove a draft without changing existing grants', () => { diff --git a/apps/web/src/routes/_app/admin/__tests__/instruments.test.tsx b/apps/web/src/routes/_app/admin/__tests__/instruments.test.tsx index 2ebda6ace..4d8b74b0c 100644 --- a/apps/web/src/routes/_app/admin/__tests__/instruments.test.tsx +++ b/apps/web/src/routes/_app/admin/__tests__/instruments.test.tsx @@ -1,7 +1,7 @@ import type { Group } from '@opendatacapture/schemas/group'; import type { SeriesInstrumentOverview, TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; import { QueryClient } from '@tanstack/react-query'; -import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { InstrumentPreviewItem } from '@/components/InstrumentPreviewDialog'; @@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => ({ seriesOverview: [] as SeriesInstrumentOverview[] })); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@/components/InstrumentPreviewDialog', () => ({ InstrumentPreviewDialog: mocks.InstrumentPreviewDialog })); vi.mock('@/hooks/useGroupsQuery', () => ({ groupsQueryOptions: mocks.groupsQueryOptions, @@ -272,6 +273,27 @@ describe('Route', () => { }); }); + describe('admin instruments forms view kind', () => { + it('should tag the kind with its color, matching the datahub instrument table', () => { + mocks.instrumentInfo = [scalarFixture({ id: 'task-1', kind: 'INTERACTIVE', title: 'Reaction Task' })]; + renderPage('forms'); + const dot = within(screen.getByTestId('instrument-cell-kind')).getByTestId('color-tag-dot'); + expect(dot.style.backgroundColor).toBe('#dc2626'); + }); + + it('should find an instrument by its translated kind, so searching still sees the tagged column', async () => { + mocks.instrumentInfo = [ + scalarFixture({ id: 'form-1', title: 'Happiness' }), + scalarFixture({ id: 'task-1', kind: 'INTERACTIVE', title: 'Reaction Task' }) + ]; + renderPage('forms'); + const search = within(screen.getByTestId('data-table-search-bar')).getByRole('searchbox'); + fireEvent.change(search, { target: { value: 'Interactive' } }); + await waitFor(() => expect(rows()).toHaveLength(1)); + expect(cellTexts()[0]).toBe('Reaction Task'); + }); + }); + describe('admin instruments series view', () => { it('should describe an active series shared across every group', () => { mocks.seriesOverview = [overviewFixture()]; @@ -296,14 +318,14 @@ describe('Route', () => { it('should tag an active series with a green dot, so the status scans without tinting the label', () => { mocks.seriesOverview = [overviewFixture()]; renderPage('series'); - const dot = screen.getByTestId('series-status').firstElementChild as HTMLElement; + const dot = within(screen.getByTestId('series-status')).getByTestId('color-tag-dot'); expect(dot.style.backgroundColor).toBe('#16a34a'); }); it('should tag an archived series with a red dot, so the status scans without tinting the label', () => { mocks.seriesOverview = [overviewFixture({ archivedAt: new Date('2026-09-02T12:00:00Z') })]; renderPage('series'); - const dot = screen.getByTestId('series-status').firstElementChild as HTMLElement; + const dot = within(screen.getByTestId('series-status')).getByTestId('color-tag-dot'); expect(dot.style.backgroundColor).toBe('#dc2626'); }); diff --git a/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx b/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx index 493070c06..947ec78e3 100644 --- a/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx +++ b/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx @@ -1,5 +1,5 @@ import type { ActiveLanguages } from '@opendatacapture/schemas/core'; -import type { $UpdateSetupStateData } from '@opendatacapture/schemas/setup'; +import type { $UpdateSetupStateData, TableColorMode } from '@opendatacapture/schemas/setup'; import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -13,6 +13,7 @@ type SetupStateData = { defaultAssignmentDurationDays?: null | number; isBulkRemoteAssignmentsEnabled?: boolean | null; isExperimentalFeaturesEnabled?: boolean | null; + tableColorMode: TableColorMode; }; type MutateCallbacks = { onError: () => void; onSuccess: () => void }; @@ -20,7 +21,7 @@ type MutateCallbacks = { onError: () => void; onSuccess: () => void }; const mocks = vi.hoisted(() => { const state: { groupSwitcherPosition: GroupSwitcherPosition; setupState: SetupStateData } = { groupSwitcherPosition: 'sidebar', - setupState: { activeLanguages: ['en'] } + setupState: { activeLanguages: ['en'], tableColorMode: 'tags' } }; return { config: { setup: { isGatewayEnabled: true } }, @@ -72,7 +73,7 @@ describe('Route', () => { vi.clearAllMocks(); mocks.config.setup.isGatewayEnabled = true; mocks.state.groupSwitcherPosition = 'sidebar'; - mocks.state.setupState = { activeLanguages: ['en'] }; + mocks.state.setupState = { activeLanguages: ['en'], tableColorMode: 'tags' }; }); afterEach(() => { @@ -340,6 +341,21 @@ describe('Route', () => { }); }); + describe('table colors', () => { + it('should show the saved mode', () => { + mocks.state.setupState.tableColorMode = 'none'; + renderPage(); + expect(screen.getByTestId('table-color-mode-select').textContent).toContain('No color'); + }); + + it('should autosave the chosen mode, since the setting has no submit button', () => { + renderPage(); + fireEvent.keyDown(screen.getByTestId('table-color-mode-select'), { key: 'Enter' }); + fireEvent.click(screen.getByTestId('table-color-mode-option-text')); + expect(savedData()).toEqual([{ tableColorMode: 'text' }]); + }); + }); + describe('group switcher position', () => { it('should show the position this browser has stored', () => { mocks.state.groupSwitcherPosition = 'topbar'; diff --git a/apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx b/apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx index 055841cc7..29d065a22 100644 --- a/apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx +++ b/apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx @@ -42,6 +42,7 @@ const mocks = vi.hoisted(() => ({ users: [] as User[] })); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@tanstack/react-router', async (importOriginal) => ({ ...(await importOriginal()), createFileRoute: () => { @@ -345,6 +346,18 @@ describe('Route', () => { expect(cellTexts().slice(1)).toEqual(['jane', 'Depression Clinic', 'Send Email', 'Instrument Record']); }); + it('should tag the action with its color', () => { + renderPage(); + const action = within(screen.getByTestId('audit-log-action')); + expect(action.getByTestId('color-tag-dot').style.backgroundColor).toBe('#0891b2'); + }); + + it('should leave the entity as plain text', () => { + renderPage(); + expect(screen.getByText('Instrument Record').querySelector('[data-testid="color-tag-dot"]')).toBeNull(); + expect(screen.getByText('Instrument Record').closest('[data-testid="color-tag-label"]')).toBeNull(); + }); + it('should show N/A for a log with no user or group', () => { mocks.auditLogsPage = { data: [logFixture({ group: null, user: null })], pageCount: 1, total: 1 }; renderPage(); diff --git a/apps/web/src/routes/_app/admin/audit/logs.tsx b/apps/web/src/routes/_app/admin/audit/logs.tsx index 82749696c..de78ff129 100644 --- a/apps/web/src/routes/_app/admin/audit/logs.tsx +++ b/apps/web/src/routes/_app/admin/audit/logs.tsx @@ -2,7 +2,7 @@ import React, { Fragment, useCallback, useMemo, useState } from 'react'; import { snakeToCamelCase, toLowerCase } from '@douglasneuroinformatics/libjs'; import { Button, DataTable, DropdownMenu, Heading, TanstackTable } from '@douglasneuroinformatics/libui/components'; -import { useDownload, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useDownload, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { $AuditLogsQuerySearchParams } from '@opendatacapture/schemas/audit'; import type { $AuditLog, @@ -13,11 +13,14 @@ import type { import { createFileRoute } from '@tanstack/react-router'; import { ArrowDownIcon, ArrowUpIcon, CalendarIcon, DownloadIcon, XIcon } from 'lucide-react'; +import { ColorTagCell } from '@/components/ColorTagCell'; import { FilterMenu } from '@/components/FilterMenu'; import { PageHeader } from '@/components/PageHeader'; import { auditLogsQueryOptions, fetchAllAuditLogs, useAuditLogsQuery } from '@/hooks/useAuditLogsQuery'; import { groupsQueryOptions, useGroupsQuery } from '@/hooks/useGroupsQuery'; import { usersQueryOptions, useUsersQuery } from '@/hooks/useUsersQuery'; +import { resolveTheme } from '@/utils/chart-theme'; +import { getAuditActionColor } from '@/utils/tag-colors'; type DateFormat = 'iso' | 'local'; @@ -194,6 +197,7 @@ const AuditLogsTable: React.FC<{ search: AuditLogsSearchParams }> = ({ search }) const auditLogsQuery = useAuditLogsQuery({ params: { ...search, page, sortOrder } }); const { resolvedLanguage, t } = useTranslation(); + const [theme] = useTheme(); const [dateFormat, setDateFormat] = useState('local'); @@ -257,9 +261,11 @@ const AuditLogsTable: React.FC<{ search: AuditLogsSearchParams }> = ({ search }) cell: (ctx) => { const value = ctx.getValue() as $AuditLogAction; return ( - - {t(`common.${snakeToCamelCase(toLowerCase(value))}`)} - + ); }, header: t('common.action') diff --git a/apps/web/src/routes/_app/admin/groups/__tests__/index.test.tsx b/apps/web/src/routes/_app/admin/groups/__tests__/index.test.tsx index aa6804190..ef333d0a3 100644 --- a/apps/web/src/routes/_app/admin/groups/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/admin/groups/__tests__/index.test.tsx @@ -14,16 +14,14 @@ import { Route } from '@/routes/_app/admin/groups/index'; import '@/services/i18n'; -/** A group as the table receives it, with `type` widened so a value the client does not know can be rendered. */ -type GroupRow = Omit & { type: string }; - const mocks = vi.hoisted(() => ({ deleteGroup: vi.fn(), - groups: [] as GroupRow[], + groups: [] as Group[], patch: vi.fn(), repos: [] as InstrumentRepo[] })); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('axios', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, default: { isAxiosError: actual.isAxiosError, patch: mocks.patch } }; @@ -44,7 +42,7 @@ vi.mock('@/hooks/useInstrumentReposQuery', async (importOriginal) => ({ useInstrumentReposQuery: () => ({ data: mocks.repos }) })); -const groupFixture = (overrides: Partial = {}): GroupRow => ({ +const groupFixture = (overrides: Partial = {}): Group => ({ accessibleInstrumentIds: [], createdAt: new Date('2026-01-01'), id: 'group-1', @@ -145,16 +143,20 @@ describe('Route', () => { it.each([ ['CLINICAL', 'Clinical'], ['RESEARCH', 'Research'] - ])('should translate the %s group type into its display name', (type, label) => { + ] as const)('should translate the %s group type into its display name', (type, label) => { mocks.groups = [groupFixture({ type })]; renderPage(); expect(within(rows()[0]!).getByText(label)).toBeTruthy(); }); - it('should show a group type the client does not know verbatim rather than hide it', () => { - mocks.groups = [groupFixture({ type: 'COMMUNITY' })]; + it.each([ + ['CLINICAL', '#2563eb'], + ['RESEARCH', '#7c3aed'] + ] as const)('should tag a %s group with its own color', (type, fill) => { + mocks.groups = [groupFixture({ type })]; renderPage(); - expect(within(rows()[0]!).getByText('COMMUNITY')).toBeTruthy(); + const dot = within(within(rows()[0]!).getByTestId('group-cell-type')).getByTestId('color-tag-dot'); + expect(dot.style.backgroundColor).toBe(fill); }); it('should highlight only the row that was clicked', () => { diff --git a/apps/web/src/routes/_app/admin/groups/index.tsx b/apps/web/src/routes/_app/admin/groups/index.tsx index 07c75c626..7f8fa33e3 100644 --- a/apps/web/src/routes/_app/admin/groups/index.tsx +++ b/apps/web/src/routes/_app/admin/groups/index.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from 'react'; import { Button, Checkbox, DataTable, Dialog, Heading, Sheet } from '@douglasneuroinformatics/libui/components'; -import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useNotificationsStore, useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { $Group } from '@opendatacapture/schemas/group'; import type { Group } from '@opendatacapture/schemas/group'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { createFileRoute, Link } from '@tanstack/react-router'; import axios from 'axios'; +import { ColorTagCell } from '@/components/ColorTagCell'; import { PageHeader } from '@/components/PageHeader'; import { useDeleteGroupMutation } from '@/hooks/useDeleteGroupMutation'; import { GROUPS_QUERY_KEY, groupsQueryOptions, useGroupsQuery } from '@/hooks/useGroupsQuery'; @@ -16,10 +17,13 @@ import { instrumentReposQueryOptions, useInstrumentReposQuery } from '@/hooks/useInstrumentReposQuery'; +import { resolveTheme } from '@/utils/chart-theme'; import { getApiErrorMessage } from '@/utils/error'; +import { getGroupTypeColor } from '@/utils/tag-colors'; const RouteComponent = () => { const { t } = useTranslation(); + const [theme] = useTheme(); const queryClient = useQueryClient(); const addNotification = useNotificationsStore((store) => store.addNotification); const groupsQuery = useGroupsQuery(); @@ -121,12 +125,17 @@ const RouteComponent = () => { accessorKey: 'type', cell: (ctx) => { const type = ctx.getValue() as Group['type']; - if (type === 'CLINICAL') { - return t('common.clinical'); - } else if (type === 'RESEARCH') { - return t('common.research'); - } - return type satisfies never; + const typeLabels: { [K in Group['type']]: string } = { + CLINICAL: t('common.clinical'), + RESEARCH: t('common.research') + }; + return ( + + ); }, header: t('common.groupType') } diff --git a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx index 68f9fdfc7..b025effbf 100644 --- a/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/admin/instrument-repos/__tests__/index.test.tsx @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ syncRepo: vi.fn() })); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@/hooks/useInstrumentReposQuery', () => ({ instrumentReposQueryOptions: mocks.instrumentReposQueryOptions, useInstrumentReposQuery: () => ({ data: mocks.repos }) diff --git a/apps/web/src/routes/_app/admin/instruments.tsx b/apps/web/src/routes/_app/admin/instruments.tsx index 6dca95c88..541a99881 100644 --- a/apps/web/src/routes/_app/admin/instruments.tsx +++ b/apps/web/src/routes/_app/admin/instruments.tsx @@ -1,20 +1,21 @@ import { useState } from 'react'; -import type { ComponentProps } from 'react'; import { toBasicISOString } from '@douglasneuroinformatics/libjs'; import { Button, DataTable, Dialog, Heading } from '@douglasneuroinformatics/libui/components'; import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; -import { cn } from '@douglasneuroinformatics/libui/utils'; import { translateInstrumentInfo } from '@opendatacapture/instrument-utils'; +import type { InstrumentKind } from '@opendatacapture/runtime-core'; import type { InstrumentInfo, TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument'; import { createFileRoute } from '@tanstack/react-router'; import { ColorTagCell } from '@/components/ColorTagCell'; +import { InstrumentKindCell } from '@/components/InstrumentKindCell'; import { InstrumentPreviewDialog } from '@/components/InstrumentPreviewDialog'; import type { InstrumentPreviewItem, InstrumentSource } from '@/components/InstrumentPreviewDialog'; import { PageHeader } from '@/components/PageHeader'; import { groupsQueryOptions, useGroupsQuery } from '@/hooks/useGroupsQuery'; import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery'; +import { useInstrumentKindLabels } from '@/hooks/useInstrumentKindLabels'; import { seriesInstrumentsOverviewQueryOptions, useSeriesInstrumentsOverviewQuery @@ -34,8 +35,8 @@ type SeriesRow = SeriesOverviewRow & { }; /** One line cut off with an ellipsis, with the whole text shown on hover. */ -const CellText = ({ className, text, ...props }: ComponentProps<'span'> & { text: string }) => ( - +const CellText = ({ text }: { text: string }) => ( + {text} ); @@ -88,7 +89,7 @@ type InstrumentRow = { edition: number; groupNames: string[]; id: string; - kind: string; + kind: InstrumentKind; preview: InstrumentPreviewItem; sourceLabel: string; title: string; @@ -115,6 +116,7 @@ const FormInstrumentsView = ({ scalarInstruments }: InstrumentViewProps) => { const { t } = useTranslation(); const groupsQuery = useGroupsQuery(); const toSource = useInstrumentSource(); + const kindLabels = useInstrumentKindLabels(); const [previewItem, setPreviewItem] = useState(null); const rows: InstrumentRow[] = selectLatestEditions(scalarInstruments) @@ -170,16 +172,8 @@ const FormInstrumentsView = ({ scalarInstruments }: InstrumentViewProps) => { id: 'groups' }, { - accessorFn: (row: InstrumentRow) => { - if (row.kind === 'INTERACTIVE') { - return t({ en: 'Interactive', es: 'Interactivo', fr: 'Interactif' }); - } - if (row.kind === 'FILE') { - return t({ en: 'File', es: 'Archivo', fr: 'Fichier' }); - } - return t({ en: 'Form', es: 'Formulario', fr: 'Formulaire' }); - }, - cell: (ctx) => ()} />, + accessorFn: (row: InstrumentRow) => kindLabels[row.kind], + cell: (ctx) => , header: t({ en: 'Kind', es: 'Tipo', fr: 'Type' }), id: 'kind', size: 130 diff --git a/apps/web/src/routes/_app/admin/settings.tsx b/apps/web/src/routes/_app/admin/settings.tsx index cd7c58e96..cd4517951 100644 --- a/apps/web/src/routes/_app/admin/settings.tsx +++ b/apps/web/src/routes/_app/admin/settings.tsx @@ -19,6 +19,7 @@ import { CircleHelpIcon } from 'lucide-react'; import { PageHeader } from '@/components/PageHeader'; import { SaveStatus } from '@/components/SaveStatus'; +import { SelectTableColorMode } from '@/components/SelectTableColorMode'; import { config } from '@/config'; import { useSetupStateQuery } from '@/hooks/useSetupStateQuery'; import { useUpdateSetupStateMutation } from '@/hooks/useUpdateSetupStateMutation'; @@ -175,7 +176,7 @@ const RouteComponent = () => { })} -
+
@@ -319,7 +320,9 @@ const RouteComponent = () => {
- +

{t({ @@ -347,6 +350,19 @@ const RouteComponent = () => {

+
+

+ {t({ + en: 'Coloured Text In Tables', + es: 'Texto de color en las tablas', + fr: 'Texte coloré dans les tableaux' + })} +

+ autosave({ tableColorMode })} + /> +
diff --git a/apps/web/src/routes/_app/admin/users/__tests__/$userId.test.tsx b/apps/web/src/routes/_app/admin/users/__tests__/$userId.test.tsx index 98cc2e692..bf3da6778 100644 --- a/apps/web/src/routes/_app/admin/users/__tests__/$userId.test.tsx +++ b/apps/web/src/routes/_app/admin/users/__tests__/$userId.test.tsx @@ -51,6 +51,7 @@ const mocks = vi.hoisted(() => { }; }); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@tanstack/react-router', async (importOriginal) => ({ ...(await importOriginal()), Link: ({ children, to, ...props }: PropsWithChildren<{ 'data-testid': string; to: string }>) => ( diff --git a/apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx b/apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx index f31ca396a..b9e875051 100644 --- a/apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/admin/users/__tests__/index.test.tsx @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ usersQueryOptions: vi.fn(() => ({ queryKey: ['users'] })) })); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@tanstack/react-router', async (importOriginal) => ({ ...(await importOriginal()), Link: ({ children, to }: PropsWithChildren<{ to: string }>) => {children}, @@ -128,13 +129,13 @@ describe('Route', () => { it('should tag an active user with a green dot, so the status scans without tinting the label', () => { renderTable([userFixture()]); - const dot = screen.getByTestId('user-status-active').firstElementChild as HTMLElement; + const dot = within(screen.getByTestId('user-status-active')).getByTestId('color-tag-dot'); expect(dot.style.backgroundColor).toBe('#16a34a'); }); it('should tag an archived user with a red dot, so the status scans without tinting the label', () => { renderTable([userFixture({ archivedAt: new Date('2026-10-01T00:00:00Z') })]); - const dot = screen.getByTestId('user-status-archived').firstElementChild as HTMLElement; + const dot = within(screen.getByTestId('user-status-archived')).getByTestId('color-tag-dot'); expect(dot.style.backgroundColor).toBe('#dc2626'); }); diff --git a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/__tests__/table.test.tsx b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/__tests__/table.test.tsx index 28157cd64..466d799d7 100644 --- a/apps/web/src/routes/_app/datahub/instruments/$instrumentId/__tests__/table.test.tsx +++ b/apps/web/src/routes/_app/datahub/instruments/$instrumentId/__tests__/table.test.tsx @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({ subjectIdDisplayLength: undefined as number | undefined })); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@/hooks/useInstrumentHubRecords', () => ({ useInstrumentHubRecords: () => ({ dl: vi.fn(), records: mocks.records }) })); diff --git a/apps/web/src/routes/_app/datahub/instruments/__tests__/index.test.tsx b/apps/web/src/routes/_app/datahub/instruments/__tests__/index.test.tsx index 5ab83d323..095869f9d 100644 --- a/apps/web/src/routes/_app/datahub/instruments/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/datahub/instruments/__tests__/index.test.tsx @@ -27,6 +27,7 @@ const mocks = vi.hoisted(() => ({ summaries: [] as InstrumentRecordSummary[] })); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@tanstack/react-router', async (importOriginal) => ({ ...(await importOriginal()), useNavigate: () => mocks.navigate diff --git a/apps/web/src/routes/_app/datahub/subjects/__tests__/index.test.tsx b/apps/web/src/routes/_app/datahub/subjects/__tests__/index.test.tsx index 43bb30067..bea2cfdca 100644 --- a/apps/web/src/routes/_app/datahub/subjects/__tests__/index.test.tsx +++ b/apps/web/src/routes/_app/datahub/subjects/__tests__/index.test.tsx @@ -36,6 +36,7 @@ const mocks = vi.hoisted(() => ({ theme: 'light' })); +vi.mock('@/hooks/useSetupStateQuery', () => ({ useSetupStateQuery: () => ({ data: { tableColorMode: 'tags' } }) })); vi.mock('@tanstack/react-router', async (importOriginal) => ({ ...(await importOriginal()), useNavigate: () => mocks.navigate diff --git a/apps/web/src/utils/__tests__/tag-colors.test.ts b/apps/web/src/utils/__tests__/tag-colors.test.ts new file mode 100644 index 000000000..afa9823c7 --- /dev/null +++ b/apps/web/src/utils/__tests__/tag-colors.test.ts @@ -0,0 +1,120 @@ +import { $AuditLogAction } from '@opendatacapture/schemas/audit'; +import { describe, expect, it } from 'vitest'; + +import { + getAllGroupsScopeColor, + getArchiveStatusColor, + getAssignmentStatusColor, + getAuditActionColor, + getGroupTypeColor, + getInstrumentKindColor, + getSexColor, + getSyncStatusColor +} from '../tag-colors'; + +import type { TagColor } from '../tag-colors'; + +const CARD_SURFACE = { dark: '#1e293b', light: '#ffffff' }; + +const luminance = (hex: string) => { + const [red, green, blue] = [1, 3, 5].map((start) => { + const channel = Number.parseInt(hex.slice(start, start + 2), 16) / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!; +}; + +const contrast = (first: string, second: string) => { + const [darker, lighter] = [luminance(first), luminance(second)].sort((a, b) => a - b); + return (lighter! + 0.05) / (darker! + 0.05); +}; + +const MODES = ['dark', 'light'] as const; + +const expectReadable = (colors: TagColor[], mode: (typeof MODES)[number]) => { + for (const color of colors) { + expect(contrast(color.text, CARD_SURFACE[mode])).toBeGreaterThanOrEqual(4.5); + } +}; + +describe('getSexColor', () => { + it.each(MODES)('should keep the label of every sex readable on a %s card', (mode) => { + expectReadable([getSexColor('FEMALE', mode), getSexColor('MALE', mode), getSexColor(null, mode)], mode); + }); + + it('should keep the pastel fill in light mode while darkening the text', () => { + expect(getSexColor('FEMALE', 'light')).toStrictEqual({ fill: '#f9a8d4', text: '#be185d' }); + }); +}); + +describe('getInstrumentKindColor', () => { + it.each(MODES)('should keep the label of every kind readable on a %s card', (mode) => { + const kinds = ['FILE', 'FORM', 'INTERACTIVE', 'SERIES'] as const; + expectReadable( + kinds.map((kind) => getInstrumentKindColor(kind, mode)), + mode + ); + }); +}); + +describe('getSyncStatusColor', () => { + it.each(MODES)('should keep the label of every status readable on a %s card', (mode) => { + const statuses = ['FAILED', 'PARTIAL', 'SUCCESS'] as const; + expectReadable( + statuses.map((status) => getSyncStatusColor(status, mode)), + mode + ); + }); +}); + +describe('getArchiveStatusColor', () => { + it.each(MODES)('should keep active and archived labels readable on a %s card', (mode) => { + expectReadable([getArchiveStatusColor(false, mode), getArchiveStatusColor(true, mode)], mode); + }); + + it('should draw archived and active in different colors', () => { + expect(getArchiveStatusColor(true, 'light').fill).not.toBe(getArchiveStatusColor(false, 'light').fill); + }); +}); + +describe('getAllGroupsScopeColor', () => { + it.each(MODES)('should keep the label readable on a %s card', (mode) => { + expectReadable([getAllGroupsScopeColor(mode)], mode); + }); +}); + +describe('getAuditActionColor', () => { + it.each(MODES)('should keep the label of every action readable on a %s card', (mode) => { + expectReadable( + $AuditLogAction.options.map((action) => getAuditActionColor(action, mode)), + mode + ); + }); + + it.each(MODES)('should give every action its own %s fill, so a color names one action', (mode) => { + const fills = $AuditLogAction.options.map((action) => getAuditActionColor(action, mode).fill); + expect(new Set(fills).size).toBe(fills.length); + }); +}); + +describe('getGroupTypeColor', () => { + it.each(MODES)('should keep the label of each group type readable on a %s card', (mode) => { + expectReadable([getGroupTypeColor('CLINICAL', mode), getGroupTypeColor('RESEARCH', mode)], mode); + }); + + it('should draw clinical and research groups in different colors', () => { + expect(getGroupTypeColor('CLINICAL', 'light').fill).not.toBe(getGroupTypeColor('RESEARCH', 'light').fill); + }); +}); + +describe('getAssignmentStatusColor', () => { + it.each(MODES)('should keep the label of each status readable on a %s card', (mode) => { + expectReadable([getAssignmentStatusColor('EXPIRED', mode), getAssignmentStatusColor('OUTSTANDING', mode)], mode); + }); + + it('should draw expired and outstanding assignments in different colors', () => { + expect(getAssignmentStatusColor('EXPIRED', 'light').fill).not.toBe( + getAssignmentStatusColor('OUTSTANDING', 'light').fill + ); + }); +}); diff --git a/apps/web/src/utils/tag-colors.ts b/apps/web/src/utils/tag-colors.ts index b683d5d81..5cf8fcf87 100644 --- a/apps/web/src/utils/tag-colors.ts +++ b/apps/web/src/utils/tag-colors.ts @@ -1,78 +1,162 @@ import type { InstrumentKind } from '@opendatacapture/runtime-core'; +import type { $AuditLogAction } from '@opendatacapture/schemas/audit'; +import type { Group } from '@opendatacapture/schemas/group'; +import type { Sex } from '@opendatacapture/schemas/subject'; import type { InstrumentRepoSyncStatus } from './instrument-repo-sync'; type Mode = 'dark' | 'light'; +/** `fill` colours a tag's dot; `text` colours its label, so it has to hold 4.5:1 against a card. */ +type TagColor = { + fill: string; + text: string; +}; + +type TagHue = { [K in Mode]: TagColor }; + +type GroupType = Group['type']; + +const GREEN = { + dark: { fill: '#22c55e', text: '#86efac' }, + light: { fill: '#16a34a', text: '#15803d' } +} as const satisfies TagHue; + +const RED = { + dark: { fill: '#ef4444', text: '#fca5a5' }, + light: { fill: '#dc2626', text: '#b91c1c' } +} as const satisfies TagHue; + +const AMBER = { + dark: { fill: '#f59e0b', text: '#fcd34d' }, + light: { fill: '#d97706', text: '#b45309' } +} as const satisfies TagHue; + +const BLUE = { + dark: { fill: '#3b82f6', text: '#93c5fd' }, + light: { fill: '#2563eb', text: '#1d4ed8' } +} as const satisfies TagHue; + +const YELLOW = { + dark: { fill: '#eab308', text: '#fde047' }, + light: { fill: '#ca8a04', text: '#a16207' } +} as const satisfies TagHue; + +const VIOLET = { + dark: { fill: '#8b5cf6', text: '#c4b5fd' }, + light: { fill: '#7c3aed', text: '#6d28d9' } +} as const satisfies TagHue; + +const CYAN = { + dark: { fill: '#06b6d4', text: '#67e8f9' }, + light: { fill: '#0891b2', text: '#0e7490' } +} as const satisfies TagHue; + +const TEAL = { + dark: { fill: '#14b8a6', text: '#5eead4' }, + light: { fill: '#0d9488', text: '#0f766e' } +} as const satisfies TagHue; + +const NEUTRAL = { + dark: { fill: '#a3a3a3', text: '#a3a3a3' }, + light: { fill: '#525252', text: '#525252' } +} as const satisfies TagHue; + /** - * Baby pink for female, baby blue for male, grey for a subject whose sex was never recorded. + * Baby pink for female, baby blue for male. * - * Asked for by name, so these sit outside the validated categorical palette, and one value serves - * both modes because a pastel reads well against the dark surface as-is. Against the light surface - * both are near 1.6:1, far under the 3:1 a mark would normally need — which is why `ColorTagCell` - * rings the dot, and why the label is always beside it rather than the colour carrying the value. + * Asked for by name, so these sit outside the validated categorical palette, and one fill serves both + * modes because a pastel reads well against the dark surface as-is. Against the light surface both + * are near 1.6:1, far under the 3:1 a mark would normally need — which is why `ColorTag` rings the + * dot, and why light-mode text swaps to a darker shade of the same hue. */ -const SEX_COLORS = { - FEMALE: '#f9a8d4', - MALE: '#89cff0' -} as const; +const SEX_HUES = { + FEMALE: { + dark: { fill: '#f9a8d4', text: '#f9a8d4' }, + light: { fill: '#f9a8d4', text: '#be185d' } + }, + MALE: { + dark: { fill: '#89cff0', text: '#89cff0' }, + light: { fill: '#89cff0', text: '#0369a1' } + } +} as const satisfies { [K in Sex]: TagHue }; /** - * One named colour per instrument kind, held here rather than taken from the chart palette: the - * palette is a per-user chart setting, and choosing one must not recolour a table's kind column. + * One named hue per instrument kind, held here rather than taken from the chart palette: the palette + * is a per-user chart setting, and choosing one must not recolour a table's kind column. */ -const INSTRUMENT_KIND_COLORS = { - FILE: { dark: '#22c55e', light: '#16a34a' }, - FORM: { dark: '#3b82f6', light: '#2563eb' }, - INTERACTIVE: { dark: '#ef4444', light: '#dc2626' }, - SERIES: { dark: '#eab308', light: '#ca8a04' } -} as const satisfies { [K in InstrumentKind]: { dark: string; light: string } }; - -/** Traffic-light hues for how a repository sync went, from the same scale as the kind colours. */ -const SYNC_STATUS_COLORS = { - FAILED: { dark: '#ef4444', light: '#dc2626' }, - PARTIAL: { dark: '#f59e0b', light: '#d97706' }, - SUCCESS: { dark: '#22c55e', light: '#16a34a' } -} as const satisfies { [K in InstrumentRepoSyncStatus]: { dark: string; light: string } }; - -/** The same hues as a sync status: green for in use, red for archived. */ -const ARCHIVE_STATUS_COLORS = { - ACTIVE: { dark: '#22c55e', light: '#16a34a' }, - ARCHIVED: { dark: '#ef4444', light: '#dc2626' } -} as const; +const INSTRUMENT_KIND_HUES = { + FILE: GREEN, + FORM: BLUE, + INTERACTIVE: RED, + SERIES: YELLOW +} as const satisfies { [K in InstrumentKind]: TagHue }; -/** Amber, because a grant across all groups is wider than a grant to one and worth noticing. */ -const ALL_GROUPS_SCOPE_COLOR = { - dark: '#f59e0b', - light: '#d97706' -} as const; +const SYNC_STATUS_HUES = { + FAILED: RED, + PARTIAL: AMBER, + SUCCESS: GREEN +} as const satisfies { [K in InstrumentRepoSyncStatus]: TagHue }; -const NEUTRAL_COLOR = { - dark: '#a3a3a3', - light: '#525252' -} as const; +const AUDIT_ACTION_HUES = { + ARCHIVE: AMBER, + CREATE: GREEN, + DELETE: RED, + LOGIN: VIOLET, + SEND_EMAIL: CYAN, + UNARCHIVE: TEAL, + UPDATE: BLUE +} as const satisfies { [K in $AuditLogAction]: TagHue }; -function getSexColor(sex: null | string, mode: Mode): string { +const GROUP_TYPE_HUES = { + CLINICAL: BLUE, + RESEARCH: VIOLET +} as const satisfies { [K in GroupType]: TagHue }; + +function getSexColor(sex: null | string, mode: Mode): TagColor { if (sex === 'FEMALE' || sex === 'MALE') { - return SEX_COLORS[sex]; + return SEX_HUES[sex][mode]; } - return NEUTRAL_COLOR[mode]; + return NEUTRAL[mode]; +} + +function getInstrumentKindColor(kind: InstrumentKind, mode: Mode): TagColor { + return INSTRUMENT_KIND_HUES[kind][mode]; } -function getInstrumentKindColor(kind: InstrumentKind, mode: Mode): string { - return INSTRUMENT_KIND_COLORS[kind][mode]; +function getSyncStatusColor(status: InstrumentRepoSyncStatus, mode: Mode): TagColor { + return SYNC_STATUS_HUES[status][mode]; +} + +function getArchiveStatusColor(isArchived: boolean, mode: Mode): TagColor { + return (isArchived ? RED : GREEN)[mode]; +} + +/** Amber, because a grant across all groups is wider than a grant to one and worth noticing. */ +function getAllGroupsScopeColor(mode: Mode): TagColor { + return AMBER[mode]; } -function getSyncStatusColor(status: InstrumentRepoSyncStatus, mode: Mode): string { - return SYNC_STATUS_COLORS[status][mode]; +function getAuditActionColor(action: $AuditLogAction, mode: Mode): TagColor { + return AUDIT_ACTION_HUES[action][mode]; } -function getArchiveStatusColor(isArchived: boolean, mode: Mode): string { - return ARCHIVE_STATUS_COLORS[isArchived ? 'ARCHIVED' : 'ACTIVE'][mode]; +function getAssignmentStatusColor(status: 'EXPIRED' | 'OUTSTANDING', mode: Mode): TagColor { + return (status === 'EXPIRED' ? AMBER : BLUE)[mode]; } -function getAllGroupsScopeColor(mode: Mode): string { - return ALL_GROUPS_SCOPE_COLOR[mode]; +function getGroupTypeColor(type: GroupType, mode: Mode): TagColor { + return GROUP_TYPE_HUES[type][mode]; } -export { getAllGroupsScopeColor, getArchiveStatusColor, getInstrumentKindColor, getSexColor, getSyncStatusColor }; +export type { TagColor }; +export { + getAllGroupsScopeColor, + getArchiveStatusColor, + getAssignmentStatusColor, + getAuditActionColor, + getGroupTypeColor, + getInstrumentKindColor, + getSexColor, + getSyncStatusColor +}; diff --git a/packages/schemas/src/setup/__tests__/setup.test.ts b/packages/schemas/src/setup/__tests__/setup.test.ts index cf0538fd5..62de08d9d 100644 --- a/packages/schemas/src/setup/__tests__/setup.test.ts +++ b/packages/schemas/src/setup/__tests__/setup.test.ts @@ -45,6 +45,16 @@ describe('$BrandingConfig', () => { }); describe('$UpdateSetupStateData', () => { + describe('tableColorMode', () => { + it.each(['tags', 'text', 'none'])('should accept the mode %s', (value) => { + expect($UpdateSetupStateData.safeParse({ tableColorMode: value }).success).toBe(true); + }); + + it('should reject an undeclared mode', () => { + expect($UpdateSetupStateData.safeParse({ tableColorMode: 'rainbow' }).success).toBe(false); + }); + }); + describe('defaultAssignmentDurationDays', () => { it.each([1, 30, 365, 3650])('should accept the positive whole day count %d', (value) => { expect($UpdateSetupStateData.safeParse({ defaultAssignmentDurationDays: value }).success).toBe(true); diff --git a/packages/schemas/src/setup/setup.ts b/packages/schemas/src/setup/setup.ts index 8bb0a7005..1a4a7e829 100644 --- a/packages/schemas/src/setup/setup.ts +++ b/packages/schemas/src/setup/setup.ts @@ -190,6 +190,11 @@ const MAX_ASSIGNMENT_DURATION_DAYS = 3650; /** The instance-wide default validity period (in days) applied to new remote assignments. */ const $DefaultAssignmentDurationDays = z.number().int().positive().max(MAX_ASSIGNMENT_DURATION_DAYS); +/** How a table draws a coloured value: a dot beside the label, the label tinted, or no colour at all. */ +const $TableColorMode = z.enum(['tags', 'text', 'none']); + +const DEFAULT_TABLE_COLOR_MODE = 'tags' satisfies z.infer; + const $SetupState = z.object({ activeLanguages: $ActiveLanguages, branding: $BrandingConfig.nullish(), @@ -211,6 +216,7 @@ const $SetupState = z.object({ isMailEnabled: z.boolean().default(false), isSetup: z.boolean().nullable(), release: $ReleaseInfo, + tableColorMode: $TableColorMode.default(DEFAULT_TABLE_COLOR_MODE), uptime: z.number() }); @@ -219,7 +225,8 @@ const $UpdateSetupStateData = z.object({ branding: $BrandingConfig.nullish(), defaultAssignmentDurationDays: $DefaultAssignmentDurationDays.nullish(), isBulkRemoteAssignmentsEnabled: z.boolean().nullish(), - isExperimentalFeaturesEnabled: z.boolean().nullish() + isExperimentalFeaturesEnabled: z.boolean().nullish(), + tableColorMode: $TableColorMode.optional() }); const $CreateAdminData = $CreateUserData.omit({ @@ -251,6 +258,7 @@ export type ProductionReleaseInfo = z.infer; export type ReleaseInfo = z.infer; export type ResourceLink = z.infer; export type SetupState = z.infer; +export type TableColorMode = z.infer; export type $UpdateSetupStateData = z.infer; export { @@ -260,7 +268,9 @@ export { $ProductionReleaseInfo, $ReleaseInfo, $SetupState, + $TableColorMode, $UpdateSetupStateData, + DEFAULT_TABLE_COLOR_MODE, FONT_SIZES, LOGIN_THEMES, LOGO_ALIGNMENTS, diff --git a/testing/src/pages/_app/admin/settings.page.ts b/testing/src/pages/_app/admin/settings.page.ts index 1bd018c24..384e077eb 100644 --- a/testing/src/pages/_app/admin/settings.page.ts +++ b/testing/src/pages/_app/admin/settings.page.ts @@ -1,4 +1,5 @@ import type { Language } from '@opendatacapture/schemas/core'; +import type { TableColorMode } from '@opendatacapture/schemas/setup'; import type { Locator, Page } from '@playwright/test'; import { AppPage } from '../route.page'; @@ -7,6 +8,7 @@ export class AdminSettingsPage extends AppPage { readonly defaultAssignmentDurationInput: Locator; readonly groupSwitcherPositionSelect: Locator; readonly pageHeader: Locator; + readonly tableColorModeSelect: Locator; readonly uploaderToggle: Locator; constructor(page: Page) { @@ -15,6 +17,7 @@ export class AdminSettingsPage extends AppPage { this.uploaderToggle = page.getByRole('switch', { name: 'Enable Uploader' }); this.defaultAssignmentDurationInput = page.getByTestId('default-assignment-duration-input'); this.groupSwitcherPositionSelect = page.getByTestId('group-switcher-position-select'); + this.tableColorModeSelect = page.getByTestId('table-color-mode-select'); } /** The labels beside the active-language checkboxes, in the order they are listed. */ @@ -32,6 +35,11 @@ export class AdminSettingsPage extends AppPage { await this.$ref.getByRole('menuitem', { name }).click(); } + async selectTableColorMode(mode: TableColorMode) { + await this.tableColorModeSelect.click(); + await this.$ref.getByTestId(`table-color-mode-option-${mode}`).click(); + } + async setDefaultAssignmentDuration(days: number) { await this.defaultAssignmentDurationInput.fill(String(days)); await this.defaultAssignmentDurationInput.press('Enter'); diff --git a/testing/src/specs/admin-audit-logs.spec.ts b/testing/src/specs/admin-audit-logs.spec.ts index acec20762..f93ce7dce 100644 --- a/testing/src/specs/admin-audit-logs.spec.ts +++ b/testing/src/specs/admin-audit-logs.spec.ts @@ -27,6 +27,7 @@ test.describe('admin audit logs', () => { const firstRow = auditLogsPage.dataTable.getByTestId('data-table-row').first(); await expect(firstRow).toContainText('Login'); await expect(firstRow).toContainText('User'); + await expect(firstRow.getByTestId('audit-log-action')).toHaveText('Login'); }); test('should show the chosen option on its filter button and put it in the URL', async ({ getPageModel }) => { diff --git a/testing/src/specs/admin-instruments.spec.ts b/testing/src/specs/admin-instruments.spec.ts index d32d620f3..cfc1f91d6 100644 --- a/testing/src/specs/admin-instruments.spec.ts +++ b/testing/src/specs/admin-instruments.spec.ts @@ -13,6 +13,13 @@ test.describe('admin instruments', () => { await expect(page.getByRole('dialog')).toContainText(title); }); + test('should tag the kind of an instrument in the forms view', async ({ getPageModel }) => { + const title = 'Happiness Questionnaire'; + const instrumentsPage = await getPageModel('/admin/instruments'); + await instrumentsPage.searchBar.fill(title); + await expect(instrumentsPage.row(title).getByTestId('instrument-cell-kind')).toHaveText('Form'); + }); + test('should reach the series view from the Instruments submenu in the sidebar', async ({ getPageModel, page }) => { await getPageModel('/admin/instruments'); const sidebar = page.getByTestId('sidebar'); @@ -59,10 +66,6 @@ test.describe('admin instruments', () => { await expect(seriesPage.archiveDialog).toContainText(`SeriesArchive${uniqueId}`); await seriesPage.confirmArchive.click(); await expect(seriesPage.seriesStatus(title)).toHaveAttribute('data-archived', 'true'); - await expect(seriesPage.seriesStatus(title).locator('span').first()).toHaveCSS( - 'background-color', - 'rgb(220, 38, 38)' - ); const assignment = await apiRequestContext.post(`${API}/assignments`, { data: { expiresAt: new Date(Date.now() + 86_400_000), groupId: group.id, instrumentId: seriesId, subjectId }, diff --git a/testing/src/specs/admin-management.spec.ts b/testing/src/specs/admin-management.spec.ts index bc1c0c1c0..4e8b69e7c 100644 --- a/testing/src/specs/admin-management.spec.ts +++ b/testing/src/specs/admin-management.spec.ts @@ -64,10 +64,6 @@ test.describe('admin management', () => { await search.fill(enabled.username); await expect(page.getByTestId('user-login-status')).toHaveText('Enabled'); await expect(page.getByTestId('user-status-active')).toBeVisible(); - await expect(page.getByTestId('user-status-active').locator('span').first()).toHaveCSS( - 'background-color', - 'rgb(22, 163, 74)' - ); await search.fill(disabled.username); await expect(page.getByTestId('user-login-status')).toHaveText('Disabled'); await expect(page.getByTestId('user-status-active')).toBeVisible(); @@ -104,6 +100,7 @@ test.describe('admin management', () => { const row = page.getByTestId('data-table-row'); await expect(row).toHaveCount(1); await expect(row).toContainText(group.name); + await expect(row.getByTestId('group-cell-type')).toBeVisible(); await row.getByTestId('row-actions-trigger').click(); await page.getByTestId('row-actions-dropdown').getByRole('menuitem', { name: 'Manage' }).click(); @@ -325,9 +322,6 @@ test.describe('admin management', () => { await expect(userPage.permissionRows.first()).toContainText('Read'); await expect(userPage.permissionRows.first()).toContainText('Subject'); await expect(userPage.permissionRows.first().getByTestId('user-permission-scope')).toContainText('All Groups'); - await expect( - userPage.permissionRows.first().getByTestId('user-permission-scope').locator('span span').first() - ).toHaveCSS('background-color', 'rgb(217, 119, 6)'); }); test('should save a user whose new password was typed and then cleared', async ({ api, getPageModel, uniqueId }) => { diff --git a/testing/src/specs/admin-settings.spec.ts b/testing/src/specs/admin-settings.spec.ts index d8fc84b96..c943741c6 100644 --- a/testing/src/specs/admin-settings.spec.ts +++ b/testing/src/specs/admin-settings.spec.ts @@ -1,4 +1,5 @@ import { MAX_ASSIGNMENT_DURATION_DAYS } from '@opendatacapture/schemas/setup'; +import type { TableColorMode } from '@opendatacapture/schemas/setup'; import type { Page } from '@playwright/test'; import { GroupEmailTemplatesPage } from '../pages/_app/group/email-templates.page'; @@ -47,6 +48,32 @@ test.describe('admin settings', () => { await expect(settingsPage.defaultAssignmentDurationInput).toHaveValue(String(durationDays)); }); + // Not `@smoke`, for the reason above. `tableColorMode` is one instance-wide document that every table + // reads, so it is restored at the end and no other spec may assert how a tag is drawn. + test('should redraw status cells in every table color mode', async ({ getPageModel, page }) => { + const settingsPage = await getPageModel('/admin/settings'); + + const saveModeAndOpenUsers = async (mode: TableColorMode) => { + await page.goto('/admin/settings'); + const saved = waitForSetupPatch(page); + await settingsPage.selectTableColorMode(mode); + expect((await saved).ok()).toBe(true); + await page.goto('/admin/users'); + return page.getByTestId('user-status-active').first(); + }; + + const text = await saveModeAndOpenUsers('text'); + await expect(text.getByTestId('color-tag-dot')).toHaveCount(0); + await expect(text.getByTestId('color-tag-label')).toHaveCSS('color', 'rgb(21, 128, 61)'); + + const none = await saveModeAndOpenUsers('none'); + await expect(none.getByTestId('color-tag-dot')).toHaveCount(0); + await expect(none.getByTestId('color-tag-label')).not.toHaveCSS('color', 'rgb(21, 128, 61)'); + + const tags = await saveModeAndOpenUsers('tags'); + await expect(tags.getByTestId('color-tag-dot')).toHaveCSS('background-color', 'rgb(22, 163, 74)'); + }); + test.describe('active languages', () => { // `activeLanguages` is one instance-wide document seeded with English and French, so these tests // restore it at the end and must not run concurrently with each other. From 3d503b81cc9ed60f9247b7f477d21b68d315ca9a Mon Sep 17 00:00:00 2001 From: thomasbeaudry Date: Tue, 3 Nov 2026 11:48:47 -0500 Subject: [PATCH 3/3] fix(web): separate the send email tag colour and rename colour mode labels Send Email moves from cyan to fuchsia because its dot read as Unarchive's teal in light mode. The stored mode values stay as they are, so saved settings keep working under the new Coloured text and Disabled labels. Co-Authored-By: Claude Opus 5.5 --- .../SelectTableColorMode/SelectTableColorMode.tsx | 4 ++-- .../__tests__/SelectTableColorMode.test.tsx | 2 +- .../web/src/routes/_app/admin/__tests__/settings.test.tsx | 2 +- .../src/routes/_app/admin/audit/__tests__/logs.test.tsx | 2 +- apps/web/src/utils/tag-colors.ts | 8 ++++---- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/SelectTableColorMode/SelectTableColorMode.tsx b/apps/web/src/components/SelectTableColorMode/SelectTableColorMode.tsx index 5f7ff396f..c00f2f10b 100644 --- a/apps/web/src/components/SelectTableColorMode/SelectTableColorMode.tsx +++ b/apps/web/src/components/SelectTableColorMode/SelectTableColorMode.tsx @@ -19,9 +19,9 @@ export const SelectTableColorMode = ({ onSelect, value }: SelectTableColorModePr const resolvedTheme = resolveTheme(theme); const modeLabels: { [K in TableColorMode]: string } = { - none: t({ en: 'No color', es: 'Sin color', fr: 'Sans couleur' }), + none: t({ en: 'Disabled', es: 'Desactivado', fr: 'Désactivé' }), tags: t({ en: 'Tags', es: 'Etiquetas', fr: 'Étiquettes' }), - text: t({ en: 'Colored text', es: 'Texto de color', fr: 'Texte coloré' }) + text: t({ en: 'Coloured text', es: 'Texto de color', fr: 'Texte coloré' }) }; return ( diff --git a/apps/web/src/components/SelectTableColorMode/__tests__/SelectTableColorMode.test.tsx b/apps/web/src/components/SelectTableColorMode/__tests__/SelectTableColorMode.test.tsx index 2195ab3d7..b9dd5620d 100644 --- a/apps/web/src/components/SelectTableColorMode/__tests__/SelectTableColorMode.test.tsx +++ b/apps/web/src/components/SelectTableColorMode/__tests__/SelectTableColorMode.test.tsx @@ -24,7 +24,7 @@ describe('SelectTableColorMode', () => { it('should show the selected mode on the closed control', () => { render(); - expect(trigger().textContent).toContain('Colored text'); + expect(trigger().textContent).toContain('Coloured text'); }); it('should offer every mode with the default first', () => { diff --git a/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx b/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx index 947ec78e3..8dab30549 100644 --- a/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx +++ b/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx @@ -345,7 +345,7 @@ describe('Route', () => { it('should show the saved mode', () => { mocks.state.setupState.tableColorMode = 'none'; renderPage(); - expect(screen.getByTestId('table-color-mode-select').textContent).toContain('No color'); + expect(screen.getByTestId('table-color-mode-select').textContent).toContain('Disabled'); }); it('should autosave the chosen mode, since the setting has no submit button', () => { diff --git a/apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx b/apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx index 29d065a22..293be35ee 100644 --- a/apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx +++ b/apps/web/src/routes/_app/admin/audit/__tests__/logs.test.tsx @@ -349,7 +349,7 @@ describe('Route', () => { it('should tag the action with its color', () => { renderPage(); const action = within(screen.getByTestId('audit-log-action')); - expect(action.getByTestId('color-tag-dot').style.backgroundColor).toBe('#0891b2'); + expect(action.getByTestId('color-tag-dot').style.backgroundColor).toBe('#c026d3'); }); it('should leave the entity as plain text', () => { diff --git a/apps/web/src/utils/tag-colors.ts b/apps/web/src/utils/tag-colors.ts index 5cf8fcf87..42c16fe5d 100644 --- a/apps/web/src/utils/tag-colors.ts +++ b/apps/web/src/utils/tag-colors.ts @@ -47,9 +47,9 @@ const VIOLET = { light: { fill: '#7c3aed', text: '#6d28d9' } } as const satisfies TagHue; -const CYAN = { - dark: { fill: '#06b6d4', text: '#67e8f9' }, - light: { fill: '#0891b2', text: '#0e7490' } +const FUCHSIA = { + dark: { fill: '#d946ef', text: '#f0abfc' }, + light: { fill: '#c026d3', text: '#a21caf' } } as const satisfies TagHue; const TEAL = { @@ -103,7 +103,7 @@ const AUDIT_ACTION_HUES = { CREATE: GREEN, DELETE: RED, LOGIN: VIOLET, - SEND_EMAIL: CYAN, + SEND_EMAIL: FUCHSIA, UNARCHIVE: TEAL, UPDATE: BLUE } as const satisfies { [K in $AuditLogAction]: TagHue };