Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/khaki-poems-approve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@quassel/frontend": patch
"@quassel/backend": patch
"@quassel/ui": patch
---

Add participant csv import
2 changes: 1 addition & 1 deletion apps/backend/src/research/participants/participant.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,5 @@ export class ParticipantDto {
}

export class ParticipantResponseDto extends ParticipantDto {}
export class ParticipantCreationDto extends OmitType(ParticipantDto, ["questionnaires", "carers", "languages"]) {}
export class ParticipantCreationDto extends OmitType(ParticipantDto, ["questionnaires", "carers", "languages", "latestQuestionnaire"]) {}
export class ParticipantMutationDto extends PartialType(ParticipantDto) {}
34 changes: 32 additions & 2 deletions apps/backend/src/research/participants/participants.controller.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { Body, Controller, Delete, Get, Param, Patch, Post } from "@nestjs/common";
import { ParticipantsService } from "./participants.service";
import { ApiNotFoundResponse, ApiOperation, ApiTags, ApiUnprocessableEntityResponse } from "@nestjs/swagger";
import {
ApiBody,
ApiExtraModels,
ApiNotFoundResponse,
ApiOperation,
ApiTags,
ApiUnprocessableEntityResponse,
getSchemaPath,
} from "@nestjs/swagger";
import { ParticipantCreationDto, ParticipantMutationDto, ParticipantResponseDto } from "./participant.dto";
import { ErrorResponseDto } from "../../common/dto/error.dto";
import { Roles } from "../../system/users/roles.decorator";
import { UserRole } from "../../system/users/user.entity";
import { QuestionnairesService } from "../questionnaires/questionnaires.service";
import { OneOrMany } from "../../types";

@ApiTags("Participants")
@ApiExtraModels(ParticipantCreationDto)
@Controller("participants")
export class ParticipantsController {
constructor(
Expand All @@ -18,7 +28,27 @@ export class ParticipantsController {
@Post()
@ApiOperation({ summary: "Create a participant" })
@ApiUnprocessableEntityResponse({ description: "Unique id constraint violation", type: ErrorResponseDto })
create(@Body() participant: ParticipantCreationDto): Promise<ParticipantResponseDto> {
@ApiBody({
schema: {
oneOf: [
{ $ref: getSchemaPath(ParticipantCreationDto) },
{
type: "array",
items: { $ref: getSchemaPath(ParticipantCreationDto) },
},
],
},
examples: {
single: { value: { id: 1, birthday: "2024-11-01T00:05:02.718Z" } },
multiple: {
value: [
{ id: 1, birthday: "2024-11-01T00:05:02.718Z" },
{ id: 2, birthday: "2024-11-01T00:05:02.718Z" },
],
},
},
})
create(@Body() participant: OneOrMany<ParticipantCreationDto>): Promise<ParticipantResponseDto[]> {
return this.participantService.create(participant);
}

Expand Down
16 changes: 11 additions & 5 deletions apps/backend/src/research/participants/participants.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Injectable, UnprocessableEntityException } from "@nestjs/common";
import { EntityManager, EntityRepository, FilterQuery, UniqueConstraintViolationException } from "@mikro-orm/core";
import { ParticipantCreationDto, ParticipantMutationDto } from "./participant.dto";
import { Participant } from "./participant.entity";
import { OneOrMany } from "../../types";

@Injectable()
export class ParticipantsService {
Expand All @@ -12,20 +13,25 @@ export class ParticipantsService {
private readonly em: EntityManager
) {}

async create(participantCreationDto: ParticipantCreationDto) {
const participant = new Participant();
participant.birthday = participantCreationDto.birthday;
async create(participantCreationDto: OneOrMany<ParticipantCreationDto>) {
const participantDtos = Array.isArray(participantCreationDto) ? participantCreationDto : [participantCreationDto];
const participants = participantDtos.map((dto) => {
const participant = new Participant();
participant.id = dto.id;
participant.birthday = dto.birthday;
return participant;
});

try {
await this.em.persist(participant).flush();
await this.em.persist(participants).flush();
} catch (e) {
if (e instanceof UniqueConstraintViolationException) {
throw new UnprocessableEntityException("Participant with this id already exists");
}
throw e;
}

return participant.toObject();
return participants.map((p) => p.toObject());
}

async findAll() {
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ declare global {
? T[S]
: never;
}

export type OneOrMany<T> = T | T[];
5 changes: 2 additions & 3 deletions apps/frontend/src/api.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,6 @@ export interface components {
* @example 2024-11-01T00:05:02.718Z
*/
birthday?: string;
latestQuestionnaire?: components["schemas"]["QuestionnaireListResponseDto"];
};
ParticipantResponseDto: {
/**
Expand Down Expand Up @@ -1604,7 +1603,7 @@ export interface operations {
};
requestBody: {
content: {
"application/json": components["schemas"]["ParticipantCreationDto"];
"application/json": components["schemas"]["ParticipantCreationDto"] | components["schemas"]["ParticipantCreationDto"][];
};
};
responses: {
Expand All @@ -1613,7 +1612,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ParticipantResponseDto"];
"application/json": components["schemas"]["ParticipantResponseDto"][];
};
};
/** @description Unique id constraint violation */
Expand Down
29 changes: 29 additions & 0 deletions apps/frontend/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { Route as AuthQuestionnaireQuestionnaireNewImport } from "./routes/_auth
import { Route as AuthAdministrationUsersNewImport } from "./routes/_auth/administration/users/new";
import { Route as AuthAdministrationStudiesNewImport } from "./routes/_auth/administration/studies/new";
import { Route as AuthAdministrationParticipantsNewImport } from "./routes/_auth/administration/participants/new";
import { Route as AuthAdministrationParticipantsImportImport } from "./routes/_auth/administration/participants/import";
import { Route as AuthAdministrationLanguagesNewImport } from "./routes/_auth/administration/languages/new";
import { Route as AuthAdministrationCarersNewImport } from "./routes/_auth/administration/carers/new";
import { Route as AuthQuestionnaireQuestionnaireIdRemarksImport } from "./routes/_auth/questionnaire/_questionnaire/$id/remarks";
Expand Down Expand Up @@ -230,6 +231,13 @@ const AuthAdministrationParticipantsNewRoute =
getParentRoute: () => AuthAdministrationParticipantsRoute,
} as any);

const AuthAdministrationParticipantsImportRoute =
AuthAdministrationParticipantsImportImport.update({
id: "/import",
path: "/import",
getParentRoute: () => AuthAdministrationParticipantsRoute,
} as any);

const AuthAdministrationLanguagesNewRoute =
AuthAdministrationLanguagesNewImport.update({
id: "/new",
Expand Down Expand Up @@ -437,6 +445,13 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof AuthAdministrationLanguagesNewImport;
parentRoute: typeof AuthAdministrationLanguagesImport;
};
"/_auth/administration/participants/import": {
id: "/_auth/administration/participants/import";
path: "/import";
fullPath: "/administration/participants/import";
preLoaderRoute: typeof AuthAdministrationParticipantsImportImport;
parentRoute: typeof AuthAdministrationParticipantsImport;
};
"/_auth/administration/participants/new": {
id: "/_auth/administration/participants/new";
path: "/new";
Expand Down Expand Up @@ -649,13 +664,16 @@ const AuthAdministrationLanguagesRouteWithChildren =
);

interface AuthAdministrationParticipantsRouteChildren {
AuthAdministrationParticipantsImportRoute: typeof AuthAdministrationParticipantsImportRoute;
AuthAdministrationParticipantsNewRoute: typeof AuthAdministrationParticipantsNewRoute;
AuthAdministrationParticipantsIndexRoute: typeof AuthAdministrationParticipantsIndexRoute;
AuthAdministrationParticipantsEditIdRoute: typeof AuthAdministrationParticipantsEditIdRoute;
}

const AuthAdministrationParticipantsRouteChildren: AuthAdministrationParticipantsRouteChildren =
{
AuthAdministrationParticipantsImportRoute:
AuthAdministrationParticipantsImportRoute,
AuthAdministrationParticipantsNewRoute:
AuthAdministrationParticipantsNewRoute,
AuthAdministrationParticipantsIndexRoute:
Expand Down Expand Up @@ -826,6 +844,7 @@ export interface FileRoutesByFullPath {
"/questionnaire/": typeof AuthQuestionnaireIndexRoute;
"/administration/carers/new": typeof AuthAdministrationCarersNewRoute;
"/administration/languages/new": typeof AuthAdministrationLanguagesNewRoute;
"/administration/participants/import": typeof AuthAdministrationParticipantsImportRoute;
"/administration/participants/new": typeof AuthAdministrationParticipantsNewRoute;
"/administration/studies/new": typeof AuthAdministrationStudiesNewRoute;
"/administration/users/new": typeof AuthAdministrationUsersNewRoute;
Expand Down Expand Up @@ -857,6 +876,7 @@ export interface FileRoutesByTo {
"/administration": typeof AuthAdministrationIndexRoute;
"/administration/carers/new": typeof AuthAdministrationCarersNewRoute;
"/administration/languages/new": typeof AuthAdministrationLanguagesNewRoute;
"/administration/participants/import": typeof AuthAdministrationParticipantsImportRoute;
"/administration/participants/new": typeof AuthAdministrationParticipantsNewRoute;
"/administration/studies/new": typeof AuthAdministrationStudiesNewRoute;
"/administration/users/new": typeof AuthAdministrationUsersNewRoute;
Expand Down Expand Up @@ -900,6 +920,7 @@ export interface FileRoutesById {
"/_auth/questionnaire/": typeof AuthQuestionnaireIndexRoute;
"/_auth/administration/carers/new": typeof AuthAdministrationCarersNewRoute;
"/_auth/administration/languages/new": typeof AuthAdministrationLanguagesNewRoute;
"/_auth/administration/participants/import": typeof AuthAdministrationParticipantsImportRoute;
"/_auth/administration/participants/new": typeof AuthAdministrationParticipantsNewRoute;
"/_auth/administration/studies/new": typeof AuthAdministrationStudiesNewRoute;
"/_auth/administration/users/new": typeof AuthAdministrationUsersNewRoute;
Expand Down Expand Up @@ -943,6 +964,7 @@ export interface FileRouteTypes {
| "/questionnaire/"
| "/administration/carers/new"
| "/administration/languages/new"
| "/administration/participants/import"
| "/administration/participants/new"
| "/administration/studies/new"
| "/administration/users/new"
Expand Down Expand Up @@ -973,6 +995,7 @@ export interface FileRouteTypes {
| "/administration"
| "/administration/carers/new"
| "/administration/languages/new"
| "/administration/participants/import"
| "/administration/participants/new"
| "/administration/studies/new"
| "/administration/users/new"
Expand Down Expand Up @@ -1014,6 +1037,7 @@ export interface FileRouteTypes {
| "/_auth/questionnaire/"
| "/_auth/administration/carers/new"
| "/_auth/administration/languages/new"
| "/_auth/administration/participants/import"
| "/_auth/administration/participants/new"
| "/_auth/administration/studies/new"
| "/_auth/administration/users/new"
Expand Down Expand Up @@ -1129,6 +1153,7 @@ export const routeTree = rootRoute
"filePath": "_auth/administration/participants.tsx",
"parent": "/_auth/administration",
"children": [
"/_auth/administration/participants/import",
"/_auth/administration/participants/new",
"/_auth/administration/participants/",
"/_auth/administration/participants/edit/$id"
Expand Down Expand Up @@ -1188,6 +1213,10 @@ export const routeTree = rootRoute
"filePath": "_auth/administration/languages/new.tsx",
"parent": "/_auth/administration/languages"
},
"/_auth/administration/participants/import": {
"filePath": "_auth/administration/participants/import.tsx",
"parent": "/_auth/administration/participants"
},
"/_auth/administration/participants/new": {
"filePath": "_auth/administration/participants/new.tsx",
"parent": "/_auth/administration/participants"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Button, ColumnType, DSVImport, ImportInput, ImportPreview, useForm } from "@quassel/ui";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { $api } from "../../../../stores/api";
import { components } from "../../../../api.gen";

type ImportType = { id: string; birthday: string };
type FormValues = components["schemas"]["ParticipantCreationDto"][];

const columns: ColumnType<ImportType>[] = [
{ key: "id", label: "Child ID" },
{ key: "birthday", label: "Birthday" },
];

function AdministrationParticipantsImport() {
const n = useNavigate();
const createParticipantMutation = $api.useMutation("post", "/participants", {
onSuccess: () => {
n({ to: "/administration/participants" });
},
});
const f = useForm<FormValues>({
mode: "uncontrolled",
initialValues: [],
});
const handleSubmit = (values: FormValues) => {
createParticipantMutation.mutate({ body: Object.values(values) });
};
const mapValues = (values: ImportType[]): FormValues => {
return values.map((value) => ({
id: parseInt(value.id),
birthday: value.birthday,
}));
};

return (
<form onSubmit={f.onSubmit(handleSubmit)}>
<DSVImport<ImportType> columns={columns} onChange={(values) => f.setValues(mapValues(values))}>
<ImportInput />
<ImportPreview />
</DSVImport>

<Button type="submit" fullWidth mt="xl" loading={createParticipantMutation.isPending}>
Create
</Button>
</form>
);
}

export const Route = createFileRoute("/_auth/administration/participants/import")({
component: AdministrationParticipantsImport,
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ function AdministrationParticipantsIndex() {
<Button variant="default" renderRoot={(props) => <Link to="/administration/participants/new" {...props} />}>
New participant
</Button>
<Button variant="default" renderRoot={(props) => <Link to="/administration/participants/import" {...props} />}>
Import participants
</Button>
<Table>
<Table.Thead>
<Table.Tr>
Expand Down
2 changes: 1 addition & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ The following steps describe how to set up the application system:
- `SESSION_SALT` to a 8byte random hex string with `openssl rand -hex 8`
- `DATABASE_PASSWORD` set a more secure password for the database
- **frontend**:
- `API_URL` point to the API endpoint (e.g. "https://api.test.example.com")
- `API_URL` point to the API endpoint (e.g. "<https://api.test.example.com>")
1. Run application system

```bash
Expand Down
5 changes: 3 additions & 2 deletions libs/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@
"@mantine/dates": "7.14.3",
"@mantine/form": "7.14.3",
"@mantine/hooks": "7.14.3",
"@tabler/icons-react": "3.25.0",
"@tabler/icons-react": "3.26.0",
"dayjs": "^1.11.13",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-dsv-import": "^0.4.10"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
Expand Down
12 changes: 12 additions & 0 deletions libs/ui/src/components/ImportInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Textarea } from "@mantine/core";
import { useDSVImport } from "react-dsv-import";

export function ImportInput() {
const [, dispatch] = useDSVImport();

const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
dispatch({ type: "setRaw", raw: event.target.value });
};

return <Textarea rows={15} onChange={handleChange} />;
}
13 changes: 13 additions & 0 deletions libs/ui/src/components/ImportPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Table, TableData } from "@mantine/core";
import { useDSVImport } from "react-dsv-import";

export function ImportPreview() {
const [context] = useDSVImport();

const data: TableData = {
head: context.columns.map((c) => c.label),
body: context.parsed?.map((r) => context.columns.map((c) => r[c.key])),
};

return <Table data={data} />;
}
5 changes: 5 additions & 0 deletions libs/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export { formatDate, getTime, getDateFromTimeAndWeekday, getNext } from "./utils

// custom components
export { Brand } from "./components/Brand";
export { ImportInput } from "./components/ImportInput";
export { ImportPreview } from "./components/ImportPreview";
export { MonthPicker } from "./components/MonthPicker";

// external components
Expand Down Expand Up @@ -86,3 +88,6 @@ export {
IconMapSearch,
IconMinus,
} from "@tabler/icons-react";

export { DSVImport } from "react-dsv-import";
export type { ColumnType } from "react-dsv-import";
2 changes: 2 additions & 0 deletions libs/ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export default defineConfig({
"@mantine/core": "mantineCore",
"@mantine/dates": "mantineDates",
"@mantine/hooks": "mantineHooks",
"@mantine/form": "mantineForm",
"@tabler/icons-react/dist/esm/icons/index.mjs": "tablerIconsReact",
"react-dsv-import": "reactDsvImport",
},
},
},
Expand Down
Loading
Loading