Skip to content
Draft
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
26 changes: 26 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,28 @@ enum TableColorMode {

// 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 LightCardTheme {
invisible
paper
ivory
fog
silver
mist
heather
custom
}

enum DarkCardTheme {
invisible
midnight
graphite
charcoal
steel
mist
heather
custom
}

enum Language {
en
es
Expand Down Expand Up @@ -523,6 +545,10 @@ model SetupState {
id String @id @default(auto()) @map("_id") @db.ObjectId
activeLanguages Language[] @default([en, fr])
branding BrandingConfig?
darkCardTheme DarkCardTheme?
lightCardTheme LightCardTheme?
customCardColorDark String?
customCardColorLight String?
defaultAssignmentDurationDays Int?
isBulkRemoteAssignmentsEnabled Boolean?
isDemo Boolean
Expand Down
37 changes: 37 additions & 0 deletions apps/api/src/setup/__tests__/setup.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,43 @@ describe('SetupService', () => {
});
});

describe('cardColor', () => {
it("should persist each mode's color", async () => {
setupStateModel.findFirst.mockResolvedValue({ id: 'setup-1', isSetup: true });
await setupService.updateState({ darkCardTheme: 'invisible', lightCardTheme: 'mist' });
expect(setupStateModel.update.mock.lastCall?.[0]).toMatchObject({
data: { darkCardTheme: 'invisible', lightCardTheme: 'mist' },
where: { id: 'setup-1' }
});
});

it("should return each mode's saved color", async () => {
setupStateModel.findFirst.mockResolvedValue({
darkCardTheme: 'midnight',
isDemo: false,
isSetup: true,
lightCardTheme: 'mist'
});
await expect(setupService.getState()).resolves.toMatchObject({
darkCardTheme: 'midnight',
lightCardTheme: 'mist'
});
});

it("should return each mode's saved custom color", async () => {
setupStateModel.findFirst.mockResolvedValue({ customCardColorDark: '#111111', isDemo: false, isSetup: true });
await expect(setupService.getState()).resolves.toMatchObject({ customCardColorDark: '#111111' });
});

it('should report invisible cards for an instance that never chose a color', async () => {
setupStateModel.findFirst.mockResolvedValue({ isDemo: false, isSetup: true });
await expect(setupService.getState()).resolves.toMatchObject({
darkCardTheme: 'invisible',
lightCardTheme: 'invisible'
});
});
});

describe('isBulkRemoteAssignmentsEnabled', () => {
it('should persist isBulkRemoteAssignmentsEnabled', async () => {
configService.get.mockReturnValue(true);
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/setup/setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export class SetupService {
return {
activeLanguages: fallbackLanguage ? [fallbackLanguage, ...otherLanguages] : DEFAULT_ACTIVE_LANGUAGES,
branding: branding.success ? branding.data : null,
customCardColorDark: savedOptions?.customCardColorDark,
customCardColorLight: savedOptions?.customCardColorLight,
darkCardTheme: savedOptions?.darkCardTheme ?? 'invisible',
defaultAssignmentDurationDays: savedOptions?.defaultAssignmentDurationDays ?? null,
// Derived from the gateway rather than reported as stored: a bulk assignment is only a link
// the gateway serves, so an instance that later dropped the gateway must report this off
Expand All @@ -78,6 +81,7 @@ export class SetupService {
// configuration itself is never exposed here (this is a public route).
isMailEnabled: isMailEnabled(savedOptions?.mailConfig),
isSetup: Boolean(savedOptions?.isSetup),
lightCardTheme: savedOptions?.lightCardTheme ?? 'invisible',
release: __RELEASE__,
tableColorMode: savedOptions?.tableColorMode ?? DEFAULT_TABLE_COLOR_MODE,
uptime: Math.round(process.uptime())
Expand Down
20 changes: 20 additions & 0 deletions apps/web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,26 @@ Put something in `packages/react-core` instead **only if `apps/gateway` also use
`data-testid` attributes are the Playwright suite's selectors. Don't remove them; add one when you
add UI that an e2e test will drive.

## Cards

A card on an in-app page is `@/components/InstanceCard`, not libui's `Card`: every in-app page sits on
one, below its `PageHeader`, and tiles inside a page use it too. It draws nothing until an admin picks a
theme per mode in the application settings (`SetupState.lightCardTheme`, `darkCardTheme`, and a
`customCardColor*` hex for the `custom` theme). `Layout` sets each mode's CSS variables with
`getInstanceCardStyle`, and `INSTANCE_CARD_SURFACE` reads them; pass that constant to a libui component
that wraps its own `Card`. A new theme is one entry in `LIGHT_CARD_THEMES` or `DARK_CARD_THEMES` in `packages/schemas`, the matching
enum in the Prisma schema, and the maps in `utils/card-color.ts`, which fail to compile until it is added.
Removing one needs a data migration, since Prisma throws on a stored value its enum no longer has.

A page's submit button takes `PAGE_SUBMIT_BUTTON`, through `customStyles.submitBtn` on libui's `Form`, since
a full-width one reads as a bar with no card around it. A form in a dialog or sheet keeps the full width.

Libui's `Card` stays on what is not a page surface: floating UI (the walkthrough), a preview of the login
page, and pages outside `Layout` (login, password reset, setup). Two in-app pages opt out of the color: the
dashboard keeps its own tiles, and start session keeps the page's color inside a thin libui `Card`.
`/instruments/render/$id` has no card, since it is where a form is administered. Nothing here applies to
`apps/gateway`.

## Tables

A table listing records is the libui `DataTable`. libui's plain `Table` is for a small table the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from 'react';

import { Card } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { cn } from '@douglasneuroinformatics/libui/utils';
import { CheckIcon } from 'lucide-react';

import { InstanceCard } from '@/components/InstanceCard';

import type { WizardStep } from './types';

const WIZARD_STEPS = ['SUBJECTS', 'INSTRUMENTS', 'REVIEW'] as const;
Expand Down Expand Up @@ -50,7 +51,7 @@ const StepLayout = ({ aside, children, description, footer, onStepChange, step,
const currentIndex = step ? WIZARD_STEPS.indexOf(step) : -1;

return (
<Card className="overflow-hidden">
<InstanceCard className="overflow-hidden">
{currentIndex !== -1 && (
<nav
aria-label={t({ en: 'Progress', es: 'Progreso', fr: 'Progression' })}
Expand Down Expand Up @@ -113,15 +114,15 @@ const StepLayout = ({ aside, children, description, footer, onStepChange, step,
<div className="flex flex-col gap-5 px-6 py-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1.5">
<Card.Title className="text-lg">{title}</Card.Title>
<Card.Description>{description}</Card.Description>
<InstanceCard.Title className="text-lg">{title}</InstanceCard.Title>
<InstanceCard.Description>{description}</InstanceCard.Description>
</div>
{aside}
</div>
{children}
</div>
{footer && <div className="flex flex-wrap items-center justify-end gap-2 border-t px-6 py-4">{footer}</div>}
</Card>
</InstanceCard>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React, { useState } from 'react';

import { toBasicISOString } from '@douglasneuroinformatics/libjs';
import { Button, Card, Checkbox, DataTable, Dialog } from '@douglasneuroinformatics/libui/components';
import { Button, Checkbox, DataTable, Dialog } from '@douglasneuroinformatics/libui/components';
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 { ColorTagCell } from '@/components/ColorTagCell';
import { InstanceCard } from '@/components/InstanceCard';
import { SortableHeader } from '@/components/SortableHeader';
import { useAssignmentsQuery } from '@/hooks/useAssignmentsQuery';
import { useDeleteBulkAssignmentsMutation } from '@/hooks/useDeleteBulkAssignmentsMutation';
Expand Down Expand Up @@ -109,19 +110,19 @@ export const DeleteRemoteAssignments = ({ groupId, onBack, subjectIdDisplayLengt
};

return (
<Card className="overflow-hidden" data-testid="delete-remote-assignments">
<InstanceCard className="overflow-hidden" data-testid="delete-remote-assignments">
<div className="flex flex-col gap-3 px-6 py-4">
<div className="flex flex-col gap-1.5">
<Card.Title className="text-lg">
<InstanceCard.Title className="text-lg">
{t({ en: 'Delete Assignments', es: 'Eliminar tareas', fr: 'Supprimer des tâches' })}
</Card.Title>
<Card.Description>
</InstanceCard.Title>
<InstanceCard.Description>
{t({
en: 'Select outstanding or expired assignments to delete.',
es: 'Seleccione las tareas pendientes o expiradas para eliminar.',
fr: 'Sélectionnez les tâches en attente ou expirées à supprimer.'
})}
</Card.Description>
</InstanceCard.Description>
</div>

{rows.length === 0 ? (
Expand Down Expand Up @@ -277,6 +278,6 @@ export const DeleteRemoteAssignments = ({ groupId, onBack, subjectIdDisplayLengt
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</Card>
</InstanceCard>
);
};
32 changes: 32 additions & 0 deletions apps/web/src/components/InstanceCard/InstanceCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/react-vite';

import { getInstanceCardStyle, INSTANCE_CARD_VARIABLES } from '@/utils/card-color';

import { InstanceCard } from './InstanceCard';

type Story = StoryObj<typeof InstanceCard>;

const content = (
<InstanceCard.Header>
<InstanceCard.Title>Card Title</InstanceCard.Title>
<InstanceCard.Description>Card description</InstanceCard.Description>
</InstanceCard.Header>
);

export default { component: InstanceCard } as Meta<typeof InstanceCard>;

export const Invisible: Story = { args: { children: content } };

export const Colored: Story = {
args: { children: content },
decorators: [
(Story) => (
<div
className={INSTANCE_CARD_VARIABLES}
style={getInstanceCardStyle({ darkCardTheme: 'mist', lightCardTheme: 'mist' })}
>
<Story />
</div>
)
]
};
19 changes: 19 additions & 0 deletions apps/web/src/components/InstanceCard/InstanceCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ComponentProps } from 'react';

import { Card } from '@douglasneuroinformatics/libui/components';
import { cn } from '@douglasneuroinformatics/libui/utils';

import { INSTANCE_CARD_SURFACE } from '@/utils/card-color';

const InstanceCardRoot = ({ className, ...props }: ComponentProps<typeof Card>) => (
<Card className={cn(INSTANCE_CARD_SURFACE, className)} data-instance-card="" {...props} />
);

/** A card in the color chosen for the whole instance in the application settings. */
export const InstanceCard = Object.assign(InstanceCardRoot, {
Content: Card.Content,
Description: Card.Description,
Footer: Card.Footer,
Header: Card.Header,
Title: Card.Title
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import { InstanceCard } from '../InstanceCard';

describe('InstanceCard', () => {
afterEach(cleanup);

it("should replace libui's own surface, so every card in the instance shares one color", () => {
render(<InstanceCard data-testid="instance-card" />);
const { className } = screen.getByTestId('instance-card');
expect(className).toContain('bg-[color:var(--instance-card-fill,transparent)]');
expect(className).toContain('border-[color:var(--instance-card-border,transparent)]');
expect(className).not.toContain('bg-card');
expect(className).not.toContain('shadow-sm');
});

it('should keep the layout a caller asks for', () => {
render(<InstanceCard className="flex p-6" data-testid="instance-card" />);
expect(screen.getByTestId('instance-card').className).toContain('flex p-6');
});

it('should mark itself, so a page can be checked for having one', () => {
render(<InstanceCard data-testid="instance-card" />);
expect(screen.getByTestId('instance-card').hasAttribute('data-instance-card')).toBe(true);
});
});
1 change: 1 addition & 0 deletions apps/web/src/components/InstanceCard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './InstanceCard';
8 changes: 5 additions & 3 deletions apps/web/src/components/InstrumentCard/InstrumentCard.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React from 'react';

import { Card, Heading, Tooltip } from '@douglasneuroinformatics/libui/components';
import { Heading, Tooltip } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { cn } from '@douglasneuroinformatics/libui/utils';
import { licenses } from '@opendatacapture/licenses';
import { InstrumentIcon } from '@opendatacapture/react-core';
import type { TranslatedInstrumentInfo } from '@opendatacapture/schemas/instrument';
import { BadgeAlertIcon, BadgeCheckIcon } from 'lucide-react';

import { InstanceCard } from '@/components/InstanceCard';

type BaseCardItem = { label: string; tooltip?: React.ReactNode };

type LinkCardItem = BaseCardItem & { href?: null | string; kind: 'link' };
Expand Down Expand Up @@ -134,7 +136,7 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC
];

return (
<Card
<InstanceCard
className={cn(
'group flex gap-8 p-6 transition-transform duration-300 ease-in-out hover:scale-[1.03] hover:cursor-pointer sm:p-8',
highlighted && 'bg-sky-100/60 ring-2 ring-sky-500/60 dark:bg-sky-900/30'
Expand Down Expand Up @@ -180,6 +182,6 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC
})}
</div>
</div>
</Card>
</InstanceCard>
);
};
10 changes: 9 additions & 1 deletion apps/web/src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { cn } from '@douglasneuroinformatics/libui/utils';
import { Outlet } from '@tanstack/react-router';

import { useSetupStateQuery } from '@/hooks/useSetupStateQuery';
import { useAppStore } from '@/store';
import { getInstanceCardStyle, INSTANCE_CARD_VARIABLES } from '@/utils/card-color';

import { Footer } from '../Footer';
import { GroupSwitcher, useIsGroupSwitcherVisible } from '../GroupSwitcher';
Expand All @@ -10,9 +13,14 @@ import { Sidebar } from '../Sidebar';
export const Layout = () => {
const groupSwitcherPosition = useAppStore((store) => store.groupSwitcherPosition);
const isGroupSwitcherVisible = useIsGroupSwitcherVisible();
const setupState = useSetupStateQuery().data;

return (
<div className="flex h-screen w-screen flex-col md:flex-row" data-testid="layout">
<div
className={cn('flex h-screen w-screen flex-col md:flex-row', INSTANCE_CARD_VARIABLES)}
data-testid="layout"
style={getInstanceCardStyle(setupState)}
>
<div className="absolute md:hidden">
<Navbar />
</div>
Expand Down
Loading
Loading