Skip to content

Commit 21cf080

Browse files
authored
Merge pull request #1068 from joshunrau/qol
2 parents 34755d8 + bdab885 commit 21cf080

File tree

9 files changed

+154
-70
lines changed

9 files changed

+154
-70
lines changed

.env.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ PLAYGROUND_DEV_SERVER_PORT=3750
7676
GATEWAY_DEV_SERVER_PORT=3500
7777
# The port to use for the Vite (full web app) development server
7878
WEB_DEV_SERVER_PORT=3000
79-
79+
# Set an arbitrary delay (in milliseconds) for all responses (useful for testing suspense)
80+
API_RESPONSE_DELAY=0
8081
# If set to 'true' and NODE_ENV === 'development', then login is automated
8182
VITE_DEV_BYPASS_AUTH=false
8283
# The username to use if VITE_DEV_BYPASS_AUTH is set to true

apps/api/src/app.module.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CryptoModule } from '@douglasneuroinformatics/libnest/crypto';
22
import { LoggingModule } from '@douglasneuroinformatics/libnest/logging';
33
import { Module } from '@nestjs/common';
4+
import type { MiddlewareConsumer, NestModule } from '@nestjs/common';
45
import { APP_GUARD } from '@nestjs/core';
56
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
67

@@ -10,6 +11,7 @@ import { AuthenticationGuard } from './auth/guards/authentication.guard';
1011
import { AuthorizationGuard } from './auth/guards/authorization.guard';
1112
import { ConfigurationModule } from './configuration/configuration.module';
1213
import { ConfigurationService } from './configuration/configuration.service';
14+
import { DelayMiddleware } from './core/middleware/delay.middleware';
1315
import { GatewayModule } from './gateway/gateway.module';
1416
import { GroupsModule } from './groups/groups.module';
1517
import { InstrumentsModule } from './instruments/instruments.module';
@@ -93,4 +95,13 @@ import { UsersModule } from './users/users.module';
9395
}
9496
]
9597
})
96-
export class AppModule {}
98+
export class AppModule implements NestModule {
99+
constructor(private readonly configurationService: ConfigurationService) {}
100+
101+
configure(consumer: MiddlewareConsumer) {
102+
const isDev = this.configurationService.get('NODE_ENV') === 'development';
103+
if (isDev) {
104+
consumer.apply(DelayMiddleware).forRoutes('*');
105+
}
106+
}
107+
}

apps/api/src/configuration/configuration.schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const $Configuration = z
1414
.object({
1515
API_DEV_SERVER_PORT: z.coerce.number().positive().int().optional(),
1616
API_PROD_SERVER_PORT: z.coerce.number().positive().int().default(80),
17+
API_RESPONSE_DELAY: z.coerce.number().positive().int().optional(),
1718
DANGEROUSLY_DISABLE_PBKDF2_ITERATION: $BooleanString.default(false),
1819
DEBUG: $BooleanString,
1920
GATEWAY_API_KEY: z.string().min(32),
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Injectable, type NestMiddleware } from '@nestjs/common';
2+
3+
import { ConfigurationService } from '@/configuration/configuration.service';
4+
5+
@Injectable()
6+
export class DelayMiddleware implements NestMiddleware {
7+
constructor(private readonly configurationService: ConfigurationService) {}
8+
9+
use(_req: any, _res: any, next: (error?: any) => void) {
10+
const responseDelay = this.configurationService.get('API_RESPONSE_DELAY');
11+
if (!responseDelay) {
12+
return next();
13+
}
14+
setTimeout(() => {
15+
next();
16+
}, responseDelay);
17+
}
18+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/* eslint-disable react/function-component-definition */
2+
3+
import { useEffect, useState } from 'react';
4+
5+
import { LoadingFallback } from './LoadingFallback';
6+
7+
const MIN_DELAY = 300; // ms
8+
9+
function isDataReady<TProps extends { data: unknown }>(
10+
props: TProps
11+
): props is TProps & { data: NonNullable<TProps['data']> } {
12+
return !(props.data === null || props.data === undefined);
13+
}
14+
15+
export function WithFallback<TProps extends { [key: string]: unknown }>({
16+
Component,
17+
props
18+
}: {
19+
Component: React.FC<TProps>;
20+
props: TProps extends { data: infer TData extends NonNullable<unknown> }
21+
? Omit<TProps, 'data'> & { data: null | TData | undefined }
22+
: never;
23+
}) {
24+
// if the data is not initially ready, set a min delay
25+
const [isMinDelayComplete, setIsMinDelayComplete] = useState(isDataReady(props));
26+
27+
useEffect(() => {
28+
let timeout: ReturnType<typeof setTimeout>;
29+
if (!isMinDelayComplete) {
30+
timeout = setTimeout(() => {
31+
setIsMinDelayComplete(true);
32+
}, MIN_DELAY);
33+
}
34+
return () => clearTimeout(timeout);
35+
}, []);
36+
37+
return isMinDelayComplete && isDataReady(props) ? (
38+
<Component {...(props as unknown as TProps)} />
39+
) : (
40+
<LoadingFallback />
41+
);
42+
}
Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
22
import { ClipboardDocumentIcon, DocumentTextIcon, UserIcon, UsersIcon } from '@heroicons/react/24/solid';
3+
import type { Summary as SummaryType } from '@opendatacapture/schemas/summary';
34

4-
import { LoadingFallback } from '@/components/LoadingFallback';
5+
import { WithFallback } from '@/components/WithFallback';
56
import { useAppStore } from '@/store';
67

78
import { StatisticCard } from '../components/StatisticCard';
@@ -17,46 +18,49 @@ export const Summary = () => {
1718
}
1819
});
1920

20-
if (!summaryQuery.data) {
21-
return <LoadingFallback />;
22-
}
23-
2421
return (
25-
<div className="body-font">
26-
<div className="grid grid-cols-1 gap-5 text-center lg:grid-cols-2">
27-
<StatisticCard
28-
icon={<UsersIcon className="h-12 w-12" />}
29-
label={t({
30-
en: 'Total Users',
31-
fr: "Nombre d'utilisateurs"
32-
})}
33-
value={summaryQuery.data.counts.users}
34-
/>
35-
<StatisticCard
36-
icon={<UserIcon className="h-12 w-12" />}
37-
label={t({
38-
en: 'Total Subjects',
39-
fr: 'Nombre de clients'
40-
})}
41-
value={summaryQuery.data.counts.subjects}
42-
/>
43-
<StatisticCard
44-
icon={<ClipboardDocumentIcon className="h-12 w-12" />}
45-
label={t({
46-
en: 'Total Instruments',
47-
fr: "Nombre d'instruments"
48-
})}
49-
value={summaryQuery.data.counts.instruments}
50-
/>
51-
<StatisticCard
52-
icon={<DocumentTextIcon className="h-12 w-12" />}
53-
label={t({
54-
en: 'Total Records',
55-
fr: "Nombre d'enregistrements"
56-
})}
57-
value={summaryQuery.data.counts.records}
58-
/>
59-
</div>
60-
</div>
22+
<WithFallback
23+
Component={({ data }: { data: SummaryType }) => (
24+
<div className="body-font">
25+
<div className="grid grid-cols-1 gap-5 text-center lg:grid-cols-2">
26+
<StatisticCard
27+
icon={<UsersIcon className="h-12 w-12" />}
28+
label={t({
29+
en: 'Total Users',
30+
fr: "Nombre d'utilisateurs"
31+
})}
32+
value={data.counts.users}
33+
/>
34+
<StatisticCard
35+
icon={<UserIcon className="h-12 w-12" />}
36+
label={t({
37+
en: 'Total Subjects',
38+
fr: 'Nombre de clients'
39+
})}
40+
value={data.counts.subjects}
41+
/>
42+
<StatisticCard
43+
icon={<ClipboardDocumentIcon className="h-12 w-12" />}
44+
label={t({
45+
en: 'Total Instruments',
46+
fr: "Nombre d'instruments"
47+
})}
48+
value={data.counts.instruments}
49+
/>
50+
<StatisticCard
51+
icon={<DocumentTextIcon className="h-12 w-12" />}
52+
label={t({
53+
en: 'Total Records',
54+
fr: "Nombre d'enregistrements"
55+
})}
56+
value={data.counts.records}
57+
/>
58+
</div>
59+
</div>
60+
)}
61+
props={{
62+
data: summaryQuery.data
63+
}}
64+
/>
6165
);
6266
};

apps/web/src/features/datahub/pages/DataHubPage.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useNavigate } from 'react-router-dom';
1212
import { IdentificationForm } from '@/components/IdentificationForm';
1313
import { LoadingFallback } from '@/components/LoadingFallback';
1414
import { PageHeader } from '@/components/PageHeader';
15+
import { WithFallback } from '@/components/WithFallback';
1516
import { useAppStore } from '@/store';
1617
import { downloadExcel } from '@/utils/excel';
1718

@@ -84,7 +85,7 @@ export const DataHubPage = () => {
8485
</Heading>
8586
</PageHeader>
8687
<React.Suspense fallback={<LoadingFallback />}>
87-
<div>
88+
<div className="flex flex-grow flex-col">
8889
<div className="mb-3 flex flex-col justify-between gap-3 lg:flex-row">
8990
<Dialog open={isLookupOpen} onOpenChange={setIsLookupOpen}>
9091
<Dialog.Trigger className="flex-grow">
@@ -115,10 +116,13 @@ export const DataHubPage = () => {
115116
/>
116117
</div>
117118
</div>
118-
<MasterDataTable
119-
data={data ?? []}
120-
onSelect={(subject) => {
121-
navigate(`${subject.id}/assignments`);
119+
<WithFallback
120+
Component={MasterDataTable}
121+
props={{
122+
data,
123+
onSelect: (subject) => {
124+
navigate(`${subject.id}/assignments`);
125+
}
122126
}}
123127
/>
124128
</div>

apps/web/src/features/group/components/ManageGroupForm.tsx

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,21 @@ export type AvailableInstrumentOptions = {
1414
};
1515

1616
export type ManageGroupFormProps = {
17-
availableInstrumentOptions: AvailableInstrumentOptions;
18-
initialValues: {
19-
accessibleFormInstrumentIds: Set<string>;
20-
accessibleInteractiveInstrumentIds: Set<string>;
21-
defaultIdentificationMethod?: SubjectIdentificationMethod;
22-
idValidationRegex?: null | string;
17+
data: {
18+
availableInstrumentOptions: AvailableInstrumentOptions;
19+
initialValues: {
20+
accessibleFormInstrumentIds: Set<string>;
21+
accessibleInteractiveInstrumentIds: Set<string>;
22+
defaultIdentificationMethod?: SubjectIdentificationMethod;
23+
idValidationRegex?: null | string;
24+
};
2325
};
2426
onSubmit: (data: Partial<UpdateGroupData>) => Promisable<any>;
2527
readOnly: boolean;
2628
};
2729

28-
export const ManageGroupForm = ({
29-
availableInstrumentOptions,
30-
initialValues,
31-
onSubmit,
32-
readOnly
33-
}: ManageGroupFormProps) => {
30+
export const ManageGroupForm = ({ data, onSubmit, readOnly }: ManageGroupFormProps) => {
31+
const { availableInstrumentOptions, initialValues } = data;
3432
const { t } = useTranslation();
3533

3634
let description = t('group.manage.accessibleInstrumentsDesc');

apps/web/src/features/group/pages/ManageGroupPage.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Heading } from '@douglasneuroinformatics/libui/components';
44
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
55

66
import { PageHeader } from '@/components/PageHeader';
7+
import { WithFallback } from '@/components/WithFallback';
78
import { useInstrumentInfoQuery } from '@/hooks/useInstrumentInfoQuery';
89
import { useSetupState } from '@/hooks/useSetupState';
910
import { useAppStore } from '@/store';
@@ -19,12 +20,15 @@ export const ManageGroupPage = () => {
1920
const changeGroup = useAppStore((store) => store.changeGroup);
2021
const setupState = useSetupState();
2122

22-
const availableInstruments = instrumentInfoQuery.data ?? [];
23+
const availableInstruments = instrumentInfoQuery.data;
2324

2425
const accessibleInstrumentIds = currentGroup?.accessibleInstrumentIds;
2526
const defaultIdentificationMethod = currentGroup?.settings.defaultIdentificationMethod;
2627

27-
const { availableInstrumentOptions, initialValues } = useMemo(() => {
28+
const data = useMemo(() => {
29+
if (!availableInstruments) {
30+
return null;
31+
}
2832
const availableInstrumentOptions: AvailableInstrumentOptions = {
2933
form: {},
3034
interactive: {},
@@ -69,14 +73,15 @@ export const ManageGroupPage = () => {
6973
{t('manage.pageTitle')}
7074
</Heading>
7175
</PageHeader>
72-
73-
<ManageGroupForm
74-
availableInstrumentOptions={availableInstrumentOptions}
75-
initialValues={initialValues}
76-
readOnly={Boolean(setupState.data?.isDemo && import.meta.env.PROD)}
77-
onSubmit={async (data) => {
78-
const updatedGroup = await updateGroupMutation.mutateAsync(data);
79-
changeGroup(updatedGroup);
76+
<WithFallback
77+
Component={ManageGroupForm}
78+
props={{
79+
data,
80+
onSubmit: async (data) => {
81+
const updatedGroup = await updateGroupMutation.mutateAsync(data);
82+
changeGroup(updatedGroup);
83+
},
84+
readOnly: Boolean(setupState.data?.isDemo && import.meta.env.PROD)
8085
}}
8186
/>
8287
</React.Fragment>

0 commit comments

Comments
 (0)