Skip to content

Commit 78d72ab

Browse files
authored
Merge pull request #1190 from DouglasNeuroInformatics/zod4-upload
Zod4 upload
2 parents 62cd79d + 2d8d007 commit 78d72ab

File tree

9 files changed

+1396
-576
lines changed

9 files changed

+1396
-576
lines changed

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"license": "Apache-2.0",
77
"scripts": {
88
"build": "rm -rf dist && NODE_ENV=production libnest build -c libnest.config.ts",
9-
"db:generate": "prisma generate",
9+
"db:generate": "prisma generate --no-hints",
1010
"dev": "NODE_ENV=development NODE_OPTIONS='--conditions=development' env-cmd -f ../../.env libnest dev -c libnest.config.ts",
1111
"dev:test": "NODE_ENV=test env-cmd -f ../../.env libnest dev -c libnest.config.ts",
1212
"format": "prettier --write src",

apps/gateway/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"build": "rm -rf dist && pnpm build:client && pnpm build:server && NODE_ENV=production tsx scripts/build.ts",
99
"build:client": "vite build --ssrManifest --outDir dist/client",
1010
"build:server": "vite build --ssr src/entry-server.tsx --outDir dist/server",
11-
"db:generate": "prisma generate",
11+
"db:generate": "prisma generate --no-hints",
1212
"db:push": "env-cmd --silent -f ../../.env prisma db push --skip-generate",
1313
"dev": "NODE_ENV=development env-cmd -f ../../.env tsx scripts/dev.ts && env-cmd -f ../../.env node dist/main.js",
1414
"dev:test": "NODE_ENV=test env-cmd -f ../../.env tsx scripts/dev.ts && env-cmd -f ../../.env node dist/main.js",

apps/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"react": "workspace:react__19.x@*",
5050
"react-dom": "workspace:react-dom__19.x@*",
5151
"recharts": "^2.15.2",
52+
"serialize-error": "catalog:",
5253
"ts-pattern": "workspace:ts-pattern__5.x@*",
5354
"type-fest": "workspace:type-fest__4.x@*",
5455
"xlsx": "^0.18.5",
Lines changed: 136 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import React, { useState } from 'react';
22

3-
import { isZodType } from '@douglasneuroinformatics/libjs';
3+
import { serializeError } from '@douglasneuroinformatics/libjs';
44
import { Button, FileDropzone, Heading, Spinner } from '@douglasneuroinformatics/libui/components';
55
import { useDownload, useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
66
import type { AnyUnilingualFormInstrument } from '@opendatacapture/runtime-core';
77
import { createFileRoute } from '@tanstack/react-router';
8-
import { BadgeHelpIcon, CircleAlertIcon, DownloadIcon } from 'lucide-react';
8+
import { BadgeHelpIcon, DownloadIcon } from 'lucide-react';
9+
import z from 'zod/v4';
910

1011
import { PageHeader } from '@/components/PageHeader';
1112
import { useInstrument } from '@/hooks/useInstrument';
1213
import { useUploadInstrumentRecordsMutation } from '@/hooks/useUploadInstrumentRecordsMutation';
1314
import { useAppStore } from '@/store';
14-
import { createUploadTemplateCSV, processInstrumentCSV, reformatInstrumentData } from '@/utils/upload';
15+
import { createUploadTemplateCSV, processInstrumentCSV, reformatInstrumentData, UploadError } from '@/utils/upload';
1516

1617
const RouteComponent = () => {
1718
const [file, setFile] = useState<File | null>(null);
@@ -22,76 +23,109 @@ const RouteComponent = () => {
2223
const uploadInstrumentRecordsMutation = useUploadInstrumentRecordsMutation();
2324

2425
const params = Route.useParams();
26+
const { error } = Route.useSearch();
27+
const navigate = Route.useNavigate();
28+
2529
const instrument = useInstrument(params.instrumentId) as (AnyUnilingualFormInstrument & { id: string }) | null;
2630
const { t } = useTranslation();
2731

2832
const handleTemplateDownload = () => {
2933
try {
30-
const { content, fileName } = createUploadTemplateCSV(instrument!);
31-
void download(fileName, content);
34+
const { content, filename } = createUploadTemplateCSV(instrument!);
35+
void download(filename, content);
3236
} catch (error) {
33-
if (error instanceof Error) {
34-
addNotification({
35-
message: t({
36-
en: `Error occurred downloading sample template with the following message: ${error.message}`,
37-
fr: `Un occurence d'un erreur quand le csv document est telecharger avec la message suivant: ${error.message}`
38-
}),
39-
type: 'error'
40-
});
41-
} else {
42-
addNotification({
43-
message: t({
44-
en: `Error occurred downloading sample template.`,
45-
fr: `Un occurence d'un erreur quand le csv est telecharger. `
46-
}),
47-
type: 'error'
48-
});
49-
}
5037
console.error(error);
38+
void navigate({
39+
search: {
40+
error: {
41+
description: error instanceof UploadError ? error.description : undefined,
42+
title: {
43+
en: `Error Occurred Downloading Sample Template`,
44+
fr: `Une erreur s'est produite lors du téléchargement du CSV`
45+
}
46+
}
47+
},
48+
to: '.'
49+
});
5150
}
5251
};
5352

5453
const handleInstrumentCSV = async () => {
5554
try {
5655
setIsLoading(true);
5756
const processedDataResult = await processInstrumentCSV(file!, instrument!);
58-
if (processedDataResult.success) {
59-
const reformattedData = reformatInstrumentData({
60-
currentGroup,
61-
data: processedDataResult.value,
62-
instrument: instrument!
63-
});
64-
if (reformattedData.records.length > 1000) {
65-
addNotification({
66-
message: t({
67-
en: 'Lots of entries loading, please wait...',
68-
fr: 'Beaucoup de données, veuillez patienter...'
69-
}),
70-
type: 'info'
71-
});
72-
}
73-
await uploadInstrumentRecordsMutation.mutateAsync(reformattedData);
74-
} else {
57+
const reformattedData = reformatInstrumentData({
58+
currentGroup,
59+
data: processedDataResult,
60+
instrument: instrument!
61+
});
62+
if (reformattedData.records.length > 1000) {
7563
addNotification({
76-
message: processedDataResult.message,
77-
type: 'error'
64+
message: t({
65+
en: 'Lots of entries loading, please wait...',
66+
fr: 'Beaucoup de données, veuillez patienter...'
67+
}),
68+
type: 'info'
7869
});
7970
}
71+
await uploadInstrumentRecordsMutation.mutateAsync(reformattedData);
8072
setFile(null);
8173
} catch (error) {
82-
if (error instanceof Error)
83-
addNotification({
84-
message: t({
85-
en: `An error has happened within the request: '${error.message}'`,
86-
fr: `Une erreur s'est produite lors du téléversement :'${error.message}'.`
87-
}),
88-
type: 'error'
89-
});
74+
console.error(error);
75+
void navigate({
76+
search: {
77+
error: {
78+
description: error instanceof UploadError ? error.description : undefined,
79+
title: {
80+
en: `An error has happened within the request`,
81+
fr: `Une erreur s'est produite lors du téléversement`
82+
}
83+
}
84+
},
85+
to: '.'
86+
});
9087
} finally {
9188
setIsLoading(false);
9289
}
9390
};
9491

92+
if (error) {
93+
return (
94+
<div className="flex min-h-screen flex-col items-center justify-center gap-1 p-3 text-center">
95+
<h3 className="text-2xl font-extrabold tracking-tight sm:text-3xl">{t(error.title)}</h3>
96+
{error.description && (
97+
<p className="text-muted-foreground mt-2 max-w-prose text-sm sm:text-base">{t(error.description)}</p>
98+
)}
99+
<div className="mt-6 flex gap-2">
100+
<Button
101+
type="button"
102+
variant="outline"
103+
onClick={() => {
104+
void download('error.json', JSON.stringify(serializeError(error), null, 2));
105+
}}
106+
>
107+
{t({
108+
en: 'Error Report',
109+
fr: "Rapport d'erreur"
110+
})}
111+
</Button>
112+
<Button
113+
type="button"
114+
variant="primary"
115+
onClick={() => {
116+
void navigate({ to: '.' });
117+
}}
118+
>
119+
{t({
120+
en: 'Try Again',
121+
fr: 'Réessayer'
122+
})}
123+
</Button>
124+
</div>
125+
</div>
126+
);
127+
}
128+
95129
if (!instrument) {
96130
return null;
97131
}
@@ -107,56 +141,44 @@ const RouteComponent = () => {
107141
</Heading>
108142
</PageHeader>
109143
{!isLoading ? (
110-
isZodType(instrument.validationSchema, { version: 4 }) ? (
111-
<div className="mb-2 flex items-center gap-2 rounded-md bg-red-300 p-4 dark:bg-red-800">
112-
<CircleAlertIcon style={{ height: '20px', strokeWidth: '2px', width: '20px' }} />
113-
<h5 className="font-medium tracking-tight">
114-
{t({
115-
en: 'Upload is Not Supported for Zod v4 Instruments',
116-
fr: "Le téléchargement n'est pas pris en charge pour les instruments utilisant Zod v4"
117-
})}
118-
</h5>
119-
</div>
120-
) : (
121-
<div className="mx-auto flex w-full max-w-3xl grow flex-col justify-center">
122-
<FileDropzone
123-
acceptedFileTypes={{
124-
'text/csv': ['.csv']
125-
}}
126-
className="flex h-80 w-full flex-col"
127-
file={file}
128-
setFile={setFile}
129-
/>
130-
<div className="mt-4 flex justify-between space-x-2">
131-
<Button disabled={!(file && instrument)} variant={'primary'} onClick={() => void handleInstrumentCSV()}>
132-
{t('core.submit')}
144+
<div className="mx-auto flex w-full max-w-3xl grow flex-col justify-center">
145+
<FileDropzone
146+
acceptedFileTypes={{
147+
'text/csv': ['.csv']
148+
}}
149+
className="flex h-80 w-full flex-col"
150+
file={file}
151+
setFile={setFile}
152+
/>
153+
<div className="mt-4 flex justify-between space-x-2">
154+
<Button disabled={!(file && instrument)} variant={'primary'} onClick={() => void handleInstrumentCSV()}>
155+
{t('core.submit')}
156+
</Button>
157+
<div className="flex justify-between space-x-1">
158+
<Button className="gap-1" disabled={!instrument} variant={'primary'} onClick={handleTemplateDownload}>
159+
<DownloadIcon />
160+
{t({
161+
en: 'Download Template',
162+
fr: 'Télécharger le modèle'
163+
})}
164+
</Button>
165+
<Button
166+
className="gap-1"
167+
disabled={!instrument}
168+
variant={'primary'}
169+
onClick={() => {
170+
window.open('https://opendatacapture.org/en/docs/guides/how-to-upload-data/');
171+
}}
172+
>
173+
<BadgeHelpIcon />
174+
{t({
175+
en: 'Help',
176+
fr: 'Aide'
177+
})}
133178
</Button>
134-
<div className="flex justify-between space-x-1">
135-
<Button className="gap-1" disabled={!instrument} variant={'primary'} onClick={handleTemplateDownload}>
136-
<DownloadIcon />
137-
{t({
138-
en: 'Download Template',
139-
fr: 'Télécharger le modèle'
140-
})}
141-
</Button>
142-
<Button
143-
className="gap-1"
144-
disabled={!instrument}
145-
variant={'primary'}
146-
onClick={() => {
147-
window.open('https://opendatacapture.org/en/docs/guides/how-to-upload-data/');
148-
}}
149-
>
150-
<BadgeHelpIcon />
151-
{t({
152-
en: 'Help',
153-
fr: 'Aide'
154-
})}
155-
</Button>
156-
</div>
157179
</div>
158180
</div>
159-
)
181+
</div>
160182
) : (
161183
<>
162184
<div className="mx-auto flex w-full max-w-3xl grow flex-col justify-center">
@@ -175,5 +197,22 @@ const RouteComponent = () => {
175197
};
176198

177199
export const Route = createFileRoute('/_app/upload/$instrumentId')({
178-
component: RouteComponent
200+
component: RouteComponent,
201+
validateSearch: z.object({
202+
error: z
203+
.object({
204+
description: z
205+
.object({
206+
en: z.string(),
207+
fr: z.string()
208+
})
209+
.partial()
210+
.optional(),
211+
title: z.object({
212+
en: z.string(),
213+
fr: z.string()
214+
})
215+
})
216+
.optional()
217+
})
179218
});

0 commit comments

Comments
 (0)