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 35eccecb2..70acf4be9 100644 --- a/apps/web/src/components/ColorTagCell/ColorTagCell.tsx +++ b/apps/web/src/components/ColorTagCell/ColorTagCell.tsx @@ -1,25 +1,48 @@ -type ColorTagCellProps = { - 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, 'data-testid': testId, label }: 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. */} +const ColorTag = ({ color, label, mode, ...dataAttributes }: ColorTagProps) => ( + + {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..c00f2f10b --- /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: 'Disabled', es: 'Desactivado', fr: 'Désactivé' }), + tags: t({ en: 'Tags', es: 'Etiquetas', fr: 'Étiquettes' }), + 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 new file mode 100644 index 000000000..b9dd5620d --- /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('Coloured 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/UserPermissionsEditor.tsx b/apps/web/src/components/UserPermissionsEditor/UserPermissionsEditor.tsx index c520fa038..79ddc55a0 100644 --- a/apps/web/src/components/UserPermissionsEditor/UserPermissionsEditor.tsx +++ b/apps/web/src/components/UserPermissionsEditor/UserPermissionsEditor.tsx @@ -1,5 +1,5 @@ import { Button, Card, Select, Table } from '@douglasneuroinformatics/libui/components'; -import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import { cn } from '@douglasneuroinformatics/libui/utils'; import { $AppAction, @@ -10,9 +10,11 @@ import { import type { AppAction, AppSubjectName, Permissions } from '@opendatacapture/schemas/core'; import type { Group } from '@opendatacapture/schemas/group'; import type { User } from '@opendatacapture/schemas/user'; -import { GlobeIcon, PlusIcon, Trash2Icon, TriangleAlertIcon } from 'lucide-react'; +import { PlusIcon, Trash2Icon, TriangleAlertIcon } from 'lucide-react'; import { Chip } from '@/components/Chip'; +import { ColorTagCell } from '@/components/ColorTagCell'; +import { resolveTheme } from '@/utils/chart-theme'; import { ALL_GROUPS, grantableActions, @@ -21,6 +23,7 @@ import { withoutPermission } from '@/utils/permissions'; import type { PermissionDraft } from '@/utils/permissions'; +import { getAllGroupsScopeColor } from '@/utils/tag-colors'; type CellSelectProps = { disabled?: boolean; @@ -107,6 +110,7 @@ export const UserPermissionsEditor = ({ subject: t({ en: 'Resource', es: 'Recurso', fr: 'Ressource' }) }; + const [theme] = useTheme(); const allGroupsLabel = t({ en: 'All Groups', es: 'Todos los grupos', fr: 'Tous les groupes' }); const placeholder = t({ en: 'Choose…', es: 'Elija…', fr: 'Choisir…' }); @@ -190,10 +194,7 @@ export const UserPermissionsEditor = ({ {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..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(); @@ -149,6 +151,17 @@ 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 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', () => { 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..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()]; @@ -293,6 +315,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 = 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 = within(screen.getByTestId('series-status')).getByTestId('color-tag-dot'); + 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/__tests__/settings.test.tsx b/apps/web/src/routes/_app/admin/__tests__/settings.test.tsx index 493070c06..8dab30549 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('Disabled'); + }); + + 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..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 @@ -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('#c026d3'); + }); + + 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 6d543d66b..541a99881 100644 --- a/apps/web/src/routes/_app/admin/instruments.tsx +++ b/apps/web/src/routes/_app/admin/instruments.tsx @@ -1,28 +1,32 @@ import { useState } from 'react'; -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 { cn } from '@douglasneuroinformatics/libui/utils'; +import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; 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 } 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; @@ -31,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} ); @@ -85,7 +89,7 @@ type InstrumentRow = { edition: number; groupNames: string[]; id: string; - kind: string; + kind: InstrumentKind; preview: InstrumentPreviewItem; sourceLabel: string; title: string; @@ -112,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) @@ -167,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 @@ -229,6 +226,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 +301,11 @@ const SeriesInstrumentsView = ({ scalarInstruments }: InstrumentViewProps) => { cell: (ctx) => { const row = ctx.row.original; return ( - ); }, 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 36fb96561..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}, @@ -126,6 +127,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 = 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 = within(screen.getByTestId('user-status-archived')).getByTestId('color-tag-dot'); + 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/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 b9835ea1e..42c16fe5d 100644 --- a/apps/web/src/utils/tag-colors.ts +++ b/apps/web/src/utils/tag-colors.ts @@ -1,58 +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 FUCHSIA = { + dark: { fill: '#d946ef', text: '#f0abfc' }, + light: { fill: '#c026d3', text: '#a21caf' } +} 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 } }; - -const NEUTRAL_COLOR = { - dark: '#a3a3a3', - light: '#525252' -} as const; - -function getSexColor(sex: null | string, mode: Mode): string { +const INSTRUMENT_KIND_HUES = { + FILE: GREEN, + FORM: BLUE, + INTERACTIVE: RED, + SERIES: YELLOW +} as const satisfies { [K in InstrumentKind]: TagHue }; + +const SYNC_STATUS_HUES = { + FAILED: RED, + PARTIAL: AMBER, + SUCCESS: GREEN +} as const satisfies { [K in InstrumentRepoSyncStatus]: TagHue }; + +const AUDIT_ACTION_HUES = { + ARCHIVE: AMBER, + CREATE: GREEN, + DELETE: RED, + LOGIN: VIOLET, + SEND_EMAIL: FUCHSIA, + UNARCHIVE: TEAL, + UPDATE: BLUE +} as const satisfies { [K in $AuditLogAction]: TagHue }; + +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 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 getAuditActionColor(action: $AuditLogAction, mode: Mode): TagColor { + return AUDIT_ACTION_HUES[action][mode]; } -function getInstrumentKindColor(kind: InstrumentKind, mode: Mode): string { - return INSTRUMENT_KIND_COLORS[kind][mode]; +function getAssignmentStatusColor(status: 'EXPIRED' | 'OUTSTANDING', mode: Mode): TagColor { + return (status === 'EXPIRED' ? AMBER : BLUE)[mode]; } -function getSyncStatusColor(status: InstrumentRepoSyncStatus, mode: Mode): string { - return SYNC_STATUS_COLORS[status][mode]; +function getGroupTypeColor(type: GroupType, mode: Mode): TagColor { + return GROUP_TYPE_HUES[type][mode]; } -export { 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 092de17db..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'); diff --git a/testing/src/specs/admin-management.spec.ts b/testing/src/specs/admin-management.spec.ts index f82942fd9..4e8b69e7c 100644 --- a/testing/src/specs/admin-management.spec.ts +++ b/testing/src/specs/admin-management.spec.ts @@ -100,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(); 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.