Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -493,6 +499,7 @@ model SetupState {
isSetup Boolean
mailConfig MailConfig?
newUserEmailTemplate MailTemplate?
tableColorMode TableColorMode?

@@map("SetupStateModel")
}
21 changes: 21 additions & 0 deletions apps/api/src/setup/__tests__/setup.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/setup/setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ type ResultTableProps = {
*/
const ResultTable = ({ assignments, emails, statuses, subjectIdDisplayLength, withEmail }: ResultTableProps) => {
const { t } = useTranslation();
const {
data: { tableColorMode }
} = useSetupStateQuery();

return (
<WizardTable
Expand Down Expand Up @@ -79,7 +82,11 @@ const ResultTable = ({ assignments, emails, statuses, subjectIdDisplayLength, wi
<Table.Cell>
<CopyButton size="icon" text={assignment.url} variant="outline" />
</Table.Cell>
{withEmail && <Table.Cell>{status ? STATUS_ICONS[status] : null}</Table.Cell>}
{withEmail && (
<Table.Cell className={tableColorMode === 'none' ? 'grayscale' : undefined}>
{status ? STATUS_ICONS[status] : null}
</Table.Cell>
)}
</Table.Row>
);
})}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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' });
Expand Down
55 changes: 39 additions & 16 deletions apps/web/src/components/ColorTagCell/ColorTagCell.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<span className="flex items-center gap-1.5 overflow-hidden" data-testid={testId} title={label}>
{/* 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) => (
<span className="flex items-center gap-1.5 overflow-hidden" title={label} {...dataAttributes}>
{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.
<span
className="h-2 w-2 shrink-0 rounded-full ring-1 ring-black/20 dark:ring-white/25"
data-testid="color-tag-dot"
style={{ backgroundColor: color.fill }}
/>
)}
<span
className="h-2 w-2 shrink-0 rounded-full ring-1 ring-black/20 dark:ring-white/25"
style={{ backgroundColor: color }}
/>
<span className="truncate">{label}</span>
className="truncate"
data-testid="color-tag-label"
style={mode === 'text' ? { color: color.text } : undefined}
>
{label}
</span>
</span>
);

type ColorTagCellProps = Omit<ColorTagProps, 'mode'>;

/** 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 <ColorTag mode={data.tableColorMode} {...props} />;
};

export { ColorTag, ColorTagCell };
Original file line number Diff line number Diff line change
@@ -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(<ColorTag color={COLOR} data-testid="tag" label="Active" mode="tags" />);
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(<ColorTag color={COLOR} data-testid="tag" label="Active" mode="text" />);
expect(dots()).toHaveLength(0);
expect(label().style.color).toBe('#15803d');
});

it('should draw neither a dot nor a tint in none mode', () => {
render(<ColorTag color={COLOR} data-testid="tag" label="Active" mode="none" />);
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(<ColorTag color={COLOR} data-archived="true" data-testid="tag" label="Active" mode="tags" />);
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(<ColorTagCell color={COLOR} data-testid="tag" label="Active" />);
expect(dots()).toHaveLength(0);
expect(label().style.color).toBe('#15803d');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,19 +26,17 @@ type AssignmentRow = {

const StatusCell = ({ status }: { status: 'EXPIRED' | 'OUTSTANDING' }) => {
const { t } = useTranslation();
if (status === 'OUTSTANDING') {
return (
<span className="flex items-center gap-1.5">
<CircleDotIcon className="h-3.5 w-3.5 text-sky-600" />
{t({ en: 'Outstanding', es: 'Pendiente', fr: 'En attente' })}
</span>
);
}
const [theme] = useTheme();
const labels = {
EXPIRED: t({ en: 'Expired', es: 'Expirado', fr: 'Expiré' }),
OUTSTANDING: t({ en: 'Outstanding', es: 'Pendiente', fr: 'En attente' })
};
return (
<span className="flex items-center gap-1.5">
<CircleAlertIcon className="h-3.5 w-3.5 text-amber-500" />
{t({ en: 'Expired', es: 'Expirado', fr: 'Expiré' })}
</span>
<ColorTagCell
color={getAssignmentStatusColor(status, resolveTheme(theme))}
data-testid="assignment-status"
label={labels[status]}
/>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => ({}),
Expand Down Expand Up @@ -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<HTMLElement>('[data-testid="color-tag-dot"]')!.style.backgroundColor).toBe(fill);
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<ColorTagCell
color={getInstrumentKindColor(kind, resolveTheme(theme))}
data-testid="instrument-cell-kind"
label={labels[kind]}
/>
);
};
1 change: 1 addition & 0 deletions apps/web/src/components/InstrumentKindCell/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './InstrumentKindCell';
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@douglasneuroinformatics/libui/hooks')>()),
useTheme: () => [theme.current, vi.fn()]
Expand Down
Loading
Loading