Skip to content

Commit 0e16156

Browse files
authored
Merge pull request #194 from openscript-ch/32-implement-child-id-entry-and-csv-import-for-series
32 implement child id entry and csv import for series
2 parents 24912bb + 3a00a21 commit 0e16156

File tree

16 files changed

+197
-23
lines changed

16 files changed

+197
-23
lines changed

.changeset/khaki-poems-approve.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@quassel/frontend": patch
3+
"@quassel/backend": patch
4+
"@quassel/ui": patch
5+
---
6+
7+
Add participant csv import

apps/backend/src/research/participants/participant.dto.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ export class ParticipantDto {
2626
}
2727

2828
export class ParticipantResponseDto extends ParticipantDto {}
29-
export class ParticipantCreationDto extends OmitType(ParticipantDto, ["questionnaires", "carers", "languages"]) {}
29+
export class ParticipantCreationDto extends OmitType(ParticipantDto, ["questionnaires", "carers", "languages", "latestQuestionnaire"]) {}
3030
export class ParticipantMutationDto extends PartialType(ParticipantDto) {}

apps/backend/src/research/participants/participants.controller.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import { Body, Controller, Delete, Get, Param, Patch, Post } from "@nestjs/common";
22
import { ParticipantsService } from "./participants.service";
3-
import { ApiNotFoundResponse, ApiOperation, ApiTags, ApiUnprocessableEntityResponse } from "@nestjs/swagger";
3+
import {
4+
ApiBody,
5+
ApiExtraModels,
6+
ApiNotFoundResponse,
7+
ApiOperation,
8+
ApiTags,
9+
ApiUnprocessableEntityResponse,
10+
getSchemaPath,
11+
} from "@nestjs/swagger";
412
import { ParticipantCreationDto, ParticipantMutationDto, ParticipantResponseDto } from "./participant.dto";
513
import { ErrorResponseDto } from "../../common/dto/error.dto";
614
import { Roles } from "../../system/users/roles.decorator";
715
import { UserRole } from "../../system/users/user.entity";
816
import { QuestionnairesService } from "../questionnaires/questionnaires.service";
17+
import { OneOrMany } from "../../types";
918

1019
@ApiTags("Participants")
20+
@ApiExtraModels(ParticipantCreationDto)
1121
@Controller("participants")
1222
export class ParticipantsController {
1323
constructor(
@@ -18,7 +28,27 @@ export class ParticipantsController {
1828
@Post()
1929
@ApiOperation({ summary: "Create a participant" })
2030
@ApiUnprocessableEntityResponse({ description: "Unique id constraint violation", type: ErrorResponseDto })
21-
create(@Body() participant: ParticipantCreationDto): Promise<ParticipantResponseDto> {
31+
@ApiBody({
32+
schema: {
33+
oneOf: [
34+
{ $ref: getSchemaPath(ParticipantCreationDto) },
35+
{
36+
type: "array",
37+
items: { $ref: getSchemaPath(ParticipantCreationDto) },
38+
},
39+
],
40+
},
41+
examples: {
42+
single: { value: { id: 1, birthday: "2024-11-01T00:05:02.718Z" } },
43+
multiple: {
44+
value: [
45+
{ id: 1, birthday: "2024-11-01T00:05:02.718Z" },
46+
{ id: 2, birthday: "2024-11-01T00:05:02.718Z" },
47+
],
48+
},
49+
},
50+
})
51+
create(@Body() participant: OneOrMany<ParticipantCreationDto>): Promise<ParticipantResponseDto[]> {
2252
return this.participantService.create(participant);
2353
}
2454

apps/backend/src/research/participants/participants.service.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Injectable, UnprocessableEntityException } from "@nestjs/common";
33
import { EntityManager, EntityRepository, FilterQuery, UniqueConstraintViolationException } from "@mikro-orm/core";
44
import { ParticipantCreationDto, ParticipantMutationDto } from "./participant.dto";
55
import { Participant } from "./participant.entity";
6+
import { OneOrMany } from "../../types";
67

78
@Injectable()
89
export class ParticipantsService {
@@ -12,20 +13,25 @@ export class ParticipantsService {
1213
private readonly em: EntityManager
1314
) {}
1415

15-
async create(participantCreationDto: ParticipantCreationDto) {
16-
const participant = new Participant();
17-
participant.birthday = participantCreationDto.birthday;
16+
async create(participantCreationDto: OneOrMany<ParticipantCreationDto>) {
17+
const participantDtos = Array.isArray(participantCreationDto) ? participantCreationDto : [participantCreationDto];
18+
const participants = participantDtos.map((dto) => {
19+
const participant = new Participant();
20+
participant.id = dto.id;
21+
participant.birthday = dto.birthday;
22+
return participant;
23+
});
1824

1925
try {
20-
await this.em.persist(participant).flush();
26+
await this.em.persist(participants).flush();
2127
} catch (e) {
2228
if (e instanceof UniqueConstraintViolationException) {
2329
throw new UnprocessableEntityException("Participant with this id already exists");
2430
}
2531
throw e;
2632
}
2733

28-
return participant.toObject();
34+
return participants.map((p) => p.toObject());
2935
}
3036

3137
async findAll() {

apps/backend/src/types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@ declare global {
2626
? T[S]
2727
: never;
2828
}
29+
30+
export type OneOrMany<T> = T | T[];

apps/frontend/src/api.gen.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,6 @@ export interface components {
619619
* @example 2024-11-01T00:05:02.718Z
620620
*/
621621
birthday?: string;
622-
latestQuestionnaire?: components["schemas"]["QuestionnaireListResponseDto"];
623622
};
624623
ParticipantResponseDto: {
625624
/**
@@ -1604,7 +1603,7 @@ export interface operations {
16041603
};
16051604
requestBody: {
16061605
content: {
1607-
"application/json": components["schemas"]["ParticipantCreationDto"];
1606+
"application/json": components["schemas"]["ParticipantCreationDto"] | components["schemas"]["ParticipantCreationDto"][];
16081607
};
16091608
};
16101609
responses: {
@@ -1613,7 +1612,7 @@ export interface operations {
16131612
[name: string]: unknown;
16141613
};
16151614
content: {
1616-
"application/json": components["schemas"]["ParticipantResponseDto"];
1615+
"application/json": components["schemas"]["ParticipantResponseDto"][];
16171616
};
16181617
};
16191618
/** @description Unique id constraint violation */

apps/frontend/src/routeTree.gen.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { Route as AuthQuestionnaireQuestionnaireNewImport } from "./routes/_auth
3838
import { Route as AuthAdministrationUsersNewImport } from "./routes/_auth/administration/users/new";
3939
import { Route as AuthAdministrationStudiesNewImport } from "./routes/_auth/administration/studies/new";
4040
import { Route as AuthAdministrationParticipantsNewImport } from "./routes/_auth/administration/participants/new";
41+
import { Route as AuthAdministrationParticipantsImportImport } from "./routes/_auth/administration/participants/import";
4142
import { Route as AuthAdministrationLanguagesNewImport } from "./routes/_auth/administration/languages/new";
4243
import { Route as AuthAdministrationCarersNewImport } from "./routes/_auth/administration/carers/new";
4344
import { Route as AuthQuestionnaireQuestionnaireIdRemarksImport } from "./routes/_auth/questionnaire/_questionnaire/$id/remarks";
@@ -230,6 +231,13 @@ const AuthAdministrationParticipantsNewRoute =
230231
getParentRoute: () => AuthAdministrationParticipantsRoute,
231232
} as any);
232233

234+
const AuthAdministrationParticipantsImportRoute =
235+
AuthAdministrationParticipantsImportImport.update({
236+
id: "/import",
237+
path: "/import",
238+
getParentRoute: () => AuthAdministrationParticipantsRoute,
239+
} as any);
240+
233241
const AuthAdministrationLanguagesNewRoute =
234242
AuthAdministrationLanguagesNewImport.update({
235243
id: "/new",
@@ -437,6 +445,13 @@ declare module "@tanstack/react-router" {
437445
preLoaderRoute: typeof AuthAdministrationLanguagesNewImport;
438446
parentRoute: typeof AuthAdministrationLanguagesImport;
439447
};
448+
"/_auth/administration/participants/import": {
449+
id: "/_auth/administration/participants/import";
450+
path: "/import";
451+
fullPath: "/administration/participants/import";
452+
preLoaderRoute: typeof AuthAdministrationParticipantsImportImport;
453+
parentRoute: typeof AuthAdministrationParticipantsImport;
454+
};
440455
"/_auth/administration/participants/new": {
441456
id: "/_auth/administration/participants/new";
442457
path: "/new";
@@ -649,13 +664,16 @@ const AuthAdministrationLanguagesRouteWithChildren =
649664
);
650665

651666
interface AuthAdministrationParticipantsRouteChildren {
667+
AuthAdministrationParticipantsImportRoute: typeof AuthAdministrationParticipantsImportRoute;
652668
AuthAdministrationParticipantsNewRoute: typeof AuthAdministrationParticipantsNewRoute;
653669
AuthAdministrationParticipantsIndexRoute: typeof AuthAdministrationParticipantsIndexRoute;
654670
AuthAdministrationParticipantsEditIdRoute: typeof AuthAdministrationParticipantsEditIdRoute;
655671
}
656672

657673
const AuthAdministrationParticipantsRouteChildren: AuthAdministrationParticipantsRouteChildren =
658674
{
675+
AuthAdministrationParticipantsImportRoute:
676+
AuthAdministrationParticipantsImportRoute,
659677
AuthAdministrationParticipantsNewRoute:
660678
AuthAdministrationParticipantsNewRoute,
661679
AuthAdministrationParticipantsIndexRoute:
@@ -826,6 +844,7 @@ export interface FileRoutesByFullPath {
826844
"/questionnaire/": typeof AuthQuestionnaireIndexRoute;
827845
"/administration/carers/new": typeof AuthAdministrationCarersNewRoute;
828846
"/administration/languages/new": typeof AuthAdministrationLanguagesNewRoute;
847+
"/administration/participants/import": typeof AuthAdministrationParticipantsImportRoute;
829848
"/administration/participants/new": typeof AuthAdministrationParticipantsNewRoute;
830849
"/administration/studies/new": typeof AuthAdministrationStudiesNewRoute;
831850
"/administration/users/new": typeof AuthAdministrationUsersNewRoute;
@@ -857,6 +876,7 @@ export interface FileRoutesByTo {
857876
"/administration": typeof AuthAdministrationIndexRoute;
858877
"/administration/carers/new": typeof AuthAdministrationCarersNewRoute;
859878
"/administration/languages/new": typeof AuthAdministrationLanguagesNewRoute;
879+
"/administration/participants/import": typeof AuthAdministrationParticipantsImportRoute;
860880
"/administration/participants/new": typeof AuthAdministrationParticipantsNewRoute;
861881
"/administration/studies/new": typeof AuthAdministrationStudiesNewRoute;
862882
"/administration/users/new": typeof AuthAdministrationUsersNewRoute;
@@ -900,6 +920,7 @@ export interface FileRoutesById {
900920
"/_auth/questionnaire/": typeof AuthQuestionnaireIndexRoute;
901921
"/_auth/administration/carers/new": typeof AuthAdministrationCarersNewRoute;
902922
"/_auth/administration/languages/new": typeof AuthAdministrationLanguagesNewRoute;
923+
"/_auth/administration/participants/import": typeof AuthAdministrationParticipantsImportRoute;
903924
"/_auth/administration/participants/new": typeof AuthAdministrationParticipantsNewRoute;
904925
"/_auth/administration/studies/new": typeof AuthAdministrationStudiesNewRoute;
905926
"/_auth/administration/users/new": typeof AuthAdministrationUsersNewRoute;
@@ -943,6 +964,7 @@ export interface FileRouteTypes {
943964
| "/questionnaire/"
944965
| "/administration/carers/new"
945966
| "/administration/languages/new"
967+
| "/administration/participants/import"
946968
| "/administration/participants/new"
947969
| "/administration/studies/new"
948970
| "/administration/users/new"
@@ -973,6 +995,7 @@ export interface FileRouteTypes {
973995
| "/administration"
974996
| "/administration/carers/new"
975997
| "/administration/languages/new"
998+
| "/administration/participants/import"
976999
| "/administration/participants/new"
9771000
| "/administration/studies/new"
9781001
| "/administration/users/new"
@@ -1014,6 +1037,7 @@ export interface FileRouteTypes {
10141037
| "/_auth/questionnaire/"
10151038
| "/_auth/administration/carers/new"
10161039
| "/_auth/administration/languages/new"
1040+
| "/_auth/administration/participants/import"
10171041
| "/_auth/administration/participants/new"
10181042
| "/_auth/administration/studies/new"
10191043
| "/_auth/administration/users/new"
@@ -1129,6 +1153,7 @@ export const routeTree = rootRoute
11291153
"filePath": "_auth/administration/participants.tsx",
11301154
"parent": "/_auth/administration",
11311155
"children": [
1156+
"/_auth/administration/participants/import",
11321157
"/_auth/administration/participants/new",
11331158
"/_auth/administration/participants/",
11341159
"/_auth/administration/participants/edit/$id"
@@ -1188,6 +1213,10 @@ export const routeTree = rootRoute
11881213
"filePath": "_auth/administration/languages/new.tsx",
11891214
"parent": "/_auth/administration/languages"
11901215
},
1216+
"/_auth/administration/participants/import": {
1217+
"filePath": "_auth/administration/participants/import.tsx",
1218+
"parent": "/_auth/administration/participants"
1219+
},
11911220
"/_auth/administration/participants/new": {
11921221
"filePath": "_auth/administration/participants/new.tsx",
11931222
"parent": "/_auth/administration/participants"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Button, ColumnType, DSVImport, ImportInput, ImportPreview, useForm } from "@quassel/ui";
2+
import { createFileRoute, useNavigate } from "@tanstack/react-router";
3+
import { $api } from "../../../../stores/api";
4+
import { components } from "../../../../api.gen";
5+
6+
type ImportType = { id: string; birthday: string };
7+
type FormValues = components["schemas"]["ParticipantCreationDto"][];
8+
9+
const columns: ColumnType<ImportType>[] = [
10+
{ key: "id", label: "Child ID" },
11+
{ key: "birthday", label: "Birthday" },
12+
];
13+
14+
function AdministrationParticipantsImport() {
15+
const n = useNavigate();
16+
const createParticipantMutation = $api.useMutation("post", "/participants", {
17+
onSuccess: () => {
18+
n({ to: "/administration/participants" });
19+
},
20+
});
21+
const f = useForm<FormValues>({
22+
mode: "uncontrolled",
23+
initialValues: [],
24+
});
25+
const handleSubmit = (values: FormValues) => {
26+
createParticipantMutation.mutate({ body: Object.values(values) });
27+
};
28+
const mapValues = (values: ImportType[]): FormValues => {
29+
return values.map((value) => ({
30+
id: parseInt(value.id),
31+
birthday: value.birthday,
32+
}));
33+
};
34+
35+
return (
36+
<form onSubmit={f.onSubmit(handleSubmit)}>
37+
<DSVImport<ImportType> columns={columns} onChange={(values) => f.setValues(mapValues(values))}>
38+
<ImportInput />
39+
<ImportPreview />
40+
</DSVImport>
41+
42+
<Button type="submit" fullWidth mt="xl" loading={createParticipantMutation.isPending}>
43+
Create
44+
</Button>
45+
</form>
46+
);
47+
}
48+
49+
export const Route = createFileRoute("/_auth/administration/participants/import")({
50+
component: AdministrationParticipantsImport,
51+
});

apps/frontend/src/routes/_auth/administration/participants/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ function AdministrationParticipantsIndex() {
1414
<Button variant="default" renderRoot={(props) => <Link to="/administration/participants/new" {...props} />}>
1515
New participant
1616
</Button>
17+
<Button variant="default" renderRoot={(props) => <Link to="/administration/participants/import" {...props} />}>
18+
Import participants
19+
</Button>
1720
<Table>
1821
<Table.Thead>
1922
<Table.Tr>

docs/setup.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ The following steps describe how to set up the application system:
115115
- `SESSION_SALT` to a 8byte random hex string with `openssl rand -hex 8`
116116
- `DATABASE_PASSWORD` set a more secure password for the database
117117
- **frontend**:
118-
- `API_URL` point to the API endpoint (e.g. "https://api.test.example.com")
118+
- `API_URL` point to the API endpoint (e.g. "<https://api.test.example.com>")
119119
1. Run application system
120120

121121
```bash

libs/ui/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,11 @@
3030
"@mantine/dates": "7.14.3",
3131
"@mantine/form": "7.14.3",
3232
"@mantine/hooks": "7.14.3",
33-
"@tabler/icons-react": "3.25.0",
33+
"@tabler/icons-react": "3.26.0",
3434
"dayjs": "^1.11.13",
3535
"react": "^18.3.1",
36-
"react-dom": "^18.3.1"
36+
"react-dom": "^18.3.1",
37+
"react-dsv-import": "^0.4.10"
3738
},
3839
"devDependencies": {
3940
"@eslint/js": "^9.17.0",
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Textarea } from "@mantine/core";
2+
import { useDSVImport } from "react-dsv-import";
3+
4+
export function ImportInput() {
5+
const [, dispatch] = useDSVImport();
6+
7+
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
8+
dispatch({ type: "setRaw", raw: event.target.value });
9+
};
10+
11+
return <Textarea rows={15} onChange={handleChange} />;
12+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Table, TableData } from "@mantine/core";
2+
import { useDSVImport } from "react-dsv-import";
3+
4+
export function ImportPreview() {
5+
const [context] = useDSVImport();
6+
7+
const data: TableData = {
8+
head: context.columns.map((c) => c.label),
9+
body: context.parsed?.map((r) => context.columns.map((c) => r[c.key])),
10+
};
11+
12+
return <Table data={data} />;
13+
}

libs/ui/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export { formatDate, getTime, getDateFromTimeAndWeekday, getNext } from "./utils
3737

3838
// custom components
3939
export { Brand } from "./components/Brand";
40+
export { ImportInput } from "./components/ImportInput";
41+
export { ImportPreview } from "./components/ImportPreview";
4042
export { MonthPicker } from "./components/MonthPicker";
4143

4244
// external components
@@ -86,3 +88,6 @@ export {
8688
IconMapSearch,
8789
IconMinus,
8890
} from "@tabler/icons-react";
91+
92+
export { DSVImport } from "react-dsv-import";
93+
export type { ColumnType } from "react-dsv-import";

libs/ui/vite.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export default defineConfig({
2626
"@mantine/core": "mantineCore",
2727
"@mantine/dates": "mantineDates",
2828
"@mantine/hooks": "mantineHooks",
29+
"@mantine/form": "mantineForm",
2930
"@tabler/icons-react/dist/esm/icons/index.mjs": "tablerIconsReact",
31+
"react-dsv-import": "reactDsvImport",
3032
},
3133
},
3234
},

0 commit comments

Comments
 (0)