Skip to content

Commit

Permalink
Merge pull request #194 from openscript-ch/32-implement-child-id-entr…
Browse files Browse the repository at this point in the history
…y-and-csv-import-for-series

32 implement child id entry and csv import for series
  • Loading branch information
openscript authored Dec 16, 2024
2 parents 24912bb + 3a00a21 commit 0e16156
Show file tree
Hide file tree
Showing 16 changed files with 197 additions and 23 deletions.
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

0 comments on commit 0e16156

Please sign in to comment.