Skip to content
Open
53 changes: 50 additions & 3 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,57 @@ model Session {

// Setup

type BrandingText {
en String?
fr String?
}

type ResourceLink {
href String
label String
}

type BrandingConfig {
boldDetails Boolean?
boldName Boolean?
boldResourceLinks Boolean?
boldTagline Boolean?
customLogoHeight Int?
customLogoSrc String?
customLogoUrl String?
customLogoWidth Int?
customPrimaryColor String?
customSecondaryColor String?
detailsFontSize Int?
instanceDetails BrandingText?
instanceName BrandingText?
instanceTagline BrandingText?
loginTheme String?
logoAlignment String?
logoSize String?
logoSource String?
nameAlignment String?
nameFontSize Int?
panelTextColor String?
resourceLinks ResourceLink[]
resourceLinksFontSize Int?
rightPanelPrimaryColor String?
rightPanelSecondaryColor String?
rightPanelTheme String?
sectionsOrder String[]
showDetails Boolean?
showFooterLinks Boolean?
showLogo Boolean?
showResourceLinks Boolean?
showTagline Boolean?
taglineFontSize Int?
}

model SetupState {
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @updatedAt @db.Date
id String @id @default(auto()) @map("_id") @db.ObjectId
branding BrandingConfig?
isDemo Boolean
isExperimentalFeaturesEnabled Boolean?
isSetup Boolean
Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/setup/dto/update-setup-state.dto.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { ValidationSchema } from '@douglasneuroinformatics/libnest';
import { ApiProperty } from '@nestjs/swagger';
import { $UpdateSetupStateData } from '@opendatacapture/schemas/setup';
import type { UpdateSetupStateData } from '@opendatacapture/schemas/setup';
import type { BrandingConfig, UpdateSetupStateData } from '@opendatacapture/schemas/setup';

@ValidationSchema($UpdateSetupStateData)
export class UpdateSetupStateDto implements UpdateSetupStateData {
@ApiProperty()
@ApiProperty({ required: false })
branding?: BrandingConfig | null;

@ApiProperty({ required: false })
isExperimentalFeaturesEnabled?: boolean;
}
18 changes: 15 additions & 3 deletions apps/api/src/setup/setup.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
InternalServerErrorException,
ServiceUnavailableException
} from '@nestjs/common';
import { $BrandingConfig } from '@opendatacapture/schemas/setup';
import type { CreateAdminData, InitAppOptions, SetupState, UpdateSetupStateData } from '@opendatacapture/schemas/setup';

import type { RuntimePrismaClient } from '@/core/prisma';
Expand Down Expand Up @@ -37,7 +38,13 @@ export class SetupService {

async getState() {
const savedOptions = await this.getSavedOptions();
// The stored value is validated against the schema so that scalar columns
// (e.g. `loginTheme`) are narrowed to their expected literal union types.
// Note: unknown keys are stripped here, so a stale dev server running an
// older $BrandingConfig will silently drop newer branding fields on read.
const branding = $BrandingConfig.nullable().safeParse(savedOptions?.branding ?? null);
return {
branding: branding.success ? branding.data : null,
isDemo: Boolean(savedOptions?.isDemo),
isExperimentalFeaturesEnabled: Boolean(savedOptions?.isExperimentalFeaturesEnabled),
isGatewayEnabled: this.configService.get('GATEWAY_ENABLED'),
Expand Down Expand Up @@ -67,17 +74,22 @@ export class SetupService {
return { success: true };
}

async updateState(data: UpdateSetupStateData): Promise<Partial<SetupState>> {
async updateState({ branding, ...rest }: UpdateSetupStateData): Promise<Partial<SetupState>> {
const setupState = await this.getSavedOptions();
if (!setupState?.isSetup) {
throw new ServiceUnavailableException('Cannot update state before setup');
}
return this.setupStateModel.update({
data,
await this.setupStateModel.update({
data: {
...rest,
// Composite types must be replaced wholesale via `set`
...(branding !== undefined ? { branding: { set: branding ?? null } } : {})
},
where: {
id: setupState.id
}
});
return this.getState();
}

private async dropDatabase(): Promise<void> {
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/Footer/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { Link } from '@tanstack/react-router';

import { config } from '@/config';

const CURRENT_YEAR = new Date().getFullYear();
import { useCurrentYear } from '@/hooks/useCurrentYear';

export const Footer = () => {
const { t } = useTranslation('layout');
const currentYear = useCurrentYear();

return (
<footer className="text-muted-foreground container py-3 text-sm" data-testid="footer">
Expand Down Expand Up @@ -51,7 +51,7 @@ export const Footer = () => {
</div>
</div>
<p className="text-center">
&copy; {CURRENT_YEAR} {t('organization.name')}
&copy; {currentYear} {t('organization.name')}
</p>
</footer>
);
Expand Down
18 changes: 16 additions & 2 deletions apps/web/src/components/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,28 @@ import { Sidebar } from '../Sidebar';

export const Layout = () => {
return (
<div className="flex h-screen w-screen flex-col md:flex-row" data-testid="layout">
// `w-full` (100% of #root, which excludes the scrollbar gutter) rather than
// `w-screen` (100vw, which *includes* it): when a vertical scrollbar is
// present, 100vw is wider than the document and pushes the body sideways,
// exposing the slate body background. `overflow-clip` additionally prevents
// any page or child from extending the layout below the footer.
<div className="flex h-screen w-full flex-col overflow-clip md:flex-row" data-testid="layout">
<div className="absolute md:hidden">
<Navbar />
</div>
<div className="hidden md:flex md:shrink-0">
<Sidebar />
</div>
<div className="scrollbar-none flex grow flex-col overflow-y-scroll pt-14 md:pt-0" data-testid="layout-main">
{/*
The main scroll region scrolls vertically. `overflow-x-clip` joins
`overflow-y-scroll` so the region scrolls only along Y while still
clipping any horizontal overflow without breaking `position: sticky`
inside (clip does not create a separate scroll container).
*/}
<div
className="scrollbar-none flex grow flex-col overflow-x-clip overflow-y-scroll pt-14 md:pt-0"
data-testid="layout-main"
>
<main className="container flex grow flex-col">
<Outlet />
</main>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { BrandingConfig } from '@opendatacapture/schemas/setup';
import type { Meta, StoryObj } from '@storybook/react-vite';

import { LoginBrandingPanel } from './LoginBrandingPanel';

type Story = StoryObj<typeof LoginBrandingPanel>;

const baseBranding: BrandingConfig = {
instanceName: { en: 'Open Data Capture', fr: 'Open Data Capture' },
instanceTagline: {
en: 'A platform for clinical and research data collection.',
fr: 'Une plateforme pour la collecte de données cliniques et de recherche.'
},
loginTheme: 'ocean'
};

export const Default: Story = {
args: {
branding: baseBranding,
className: 'h-screen w-screen'
}
};

export const Preview: Story = {
args: {
branding: baseBranding,
className: 'h-96 w-[36rem]',
preview: true
}
};

export const WithResources: Story = {
args: {
branding: {
...baseBranding,
loginTheme: 'midnight',
resourceLinks: [
{ href: 'https://example.org/handbook', label: 'Handbook' },
{ href: 'https://example.org/contact', label: 'Contact' }
],
showResourceLinks: true
},
className: 'h-screen w-screen'
}
};

export const CustomGradient: Story = {
args: {
branding: {
...baseBranding,
customPrimaryColor: '#0ea5e9',
customSecondaryColor: '#7c3aed',
loginTheme: 'custom'
},
className: 'h-screen w-screen'
}
};

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