diff --git a/.changeset/flat-zoos-rescue.md b/.changeset/flat-zoos-rescue.md new file mode 100644 index 00000000..3de214f2 --- /dev/null +++ b/.changeset/flat-zoos-rescue.md @@ -0,0 +1,8 @@ +--- +"@quassel/frontend": patch +"@quassel/backend": patch +"@quassel/mockup": patch +"@quassel/ui": patch +--- + +Allow selecting templates when entering calendar entries diff --git a/apps/backend/src/research/entries/entries.service.spec.ts b/apps/backend/src/research/entries/entries.service.spec.ts index 7a127713..cb203794 100644 --- a/apps/backend/src/research/entries/entries.service.spec.ts +++ b/apps/backend/src/research/entries/entries.service.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { EntriesService } from "./entries.service"; import { getRepositoryToken } from "@mikro-orm/nestjs"; import { Entry } from "./entry.entity"; -import { EntityManager } from "@mikro-orm/core"; +import { EntityManager } from "@mikro-orm/postgresql"; describe("EntriesService", () => { let service: EntriesService; diff --git a/apps/backend/src/research/entries/entries.service.ts b/apps/backend/src/research/entries/entries.service.ts index 600c6829..bfccdf25 100644 --- a/apps/backend/src/research/entries/entries.service.ts +++ b/apps/backend/src/research/entries/entries.service.ts @@ -1,8 +1,9 @@ -import { EntityRepository, EntityManager, UniqueConstraintViolationException, FilterQuery } from "@mikro-orm/core"; +import { EntityRepository, UniqueConstraintViolationException, FilterQuery } from "@mikro-orm/core"; import { InjectRepository } from "@mikro-orm/nestjs"; import { Injectable, UnprocessableEntityException } from "@nestjs/common"; import { EntryCreationDto, EntryMutationDto } from "./entry.dto"; import { Entry } from "./entry.entity"; +import { EntityManager, raw } from "@mikro-orm/postgresql"; @Injectable() export class EntriesService { @@ -40,6 +41,31 @@ export class EntriesService { return (await this.entryRepository.findOneOrFail(filter, { populate: ["entryLanguages"] })).toObject(); } + async findTemplatesForParticipant(participantId: number) { + const uniqueEntryGroups = this.em + .createQueryBuilder(Entry, "e") + .select(["e.id"]) + .distinctOn(raw("array_agg(ARRAY[el.language_id, el.ratio] ORDER BY el.language_id)")) + .join("e.entryLanguages", "el") + .join("e.questionnaire", "q") + .where({ "q.participant": participantId }) + .groupBy("e.id"); + + const populatedUniqueEntries = await this.em + .createQueryBuilder(Entry, "e") + .select("*") + .joinAndSelect("e.entryLanguages", "el") + .joinAndSelect("e.carer", "c") + .joinAndSelect("el.language", "l") + .where({ id: { $in: uniqueEntryGroups.getKnexQuery() } }) + .getResultList(); + + return populatedUniqueEntries.map((entry) => { + const { entryLanguages, carer } = entry.toObject(); + return { entryLanguages, carer: { ...carer } }; + }); + } + async update(id: number, entryMutationDto: EntryMutationDto) { const entry = await this.entryRepository.findOneOrFail(id, { populate: ["entryLanguages"] }); diff --git a/apps/backend/src/research/entries/entry.dto.ts b/apps/backend/src/research/entries/entry.dto.ts index bc50980e..1974c887 100644 --- a/apps/backend/src/research/entries/entry.dto.ts +++ b/apps/backend/src/research/entries/entry.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, OmitType, PartialType } from "@nestjs/swagger"; +import { ApiProperty, OmitType, PartialType, PickType } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { IsOptional, Min, Max, IsMilitaryTime } from "class-validator"; import { CarerDto } from "../../defaults/carers/carer.dto"; @@ -46,3 +46,5 @@ export class EntryCreationDto extends OmitType(EntryDto, ["id", "carer", "questi entryLanguages: Array; } export class EntryMutationDto extends PartialType(EntryCreationDto) {} + +export class EntryTemplateDto extends PickType(EntryDto, ["carer", "entryLanguages"]) {} diff --git a/apps/backend/src/research/participants/participants.controller.spec.ts b/apps/backend/src/research/participants/participants.controller.spec.ts index 4336cd55..e7690858 100644 --- a/apps/backend/src/research/participants/participants.controller.spec.ts +++ b/apps/backend/src/research/participants/participants.controller.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { ParticipantsController } from "./participants.controller"; import { ParticipantsService } from "./participants.service"; import { QuestionnairesService } from "../questionnaires/questionnaires.service"; +import { EntriesService } from "../entries/entries.service"; describe("ParticipantsController", () => { let controller: ParticipantsController; @@ -18,6 +19,10 @@ describe("ParticipantsController", () => { provide: QuestionnairesService, useValue: {}, }, + { + provide: EntriesService, + useValue: {}, + }, ], }).compile(); diff --git a/apps/backend/src/research/participants/participants.controller.ts b/apps/backend/src/research/participants/participants.controller.ts index 5254a393..486e30b2 100644 --- a/apps/backend/src/research/participants/participants.controller.ts +++ b/apps/backend/src/research/participants/participants.controller.ts @@ -15,6 +15,8 @@ import { Roles } from "../../system/users/roles.decorator"; import { UserRole } from "../../system/users/user.entity"; import { QuestionnairesService } from "../questionnaires/questionnaires.service"; import { OneOrMany } from "../../types"; +import { EntriesService } from "../entries/entries.service"; +import { EntryTemplateDto } from "../entries/entry.dto"; @ApiTags("Participants") @ApiExtraModels(ParticipantCreationDto) @@ -22,7 +24,8 @@ import { OneOrMany } from "../../types"; export class ParticipantsController { constructor( private readonly participantService: ParticipantsService, - private readonly questionnairesService: QuestionnairesService + private readonly questionnairesService: QuestionnairesService, + private readonly entriesService: EntriesService ) {} @Post() @@ -67,6 +70,15 @@ export class ParticipantsController { return { ...participant, latestQuestionnaire }; } + @Get(":id/entry-templates") + @ApiOperation({ + summary: "Uniquely grouped entries by ratio, carer and language, that are used as templates when creating new entries for a participant.", + }) + @ApiNotFoundResponse({ description: "Entity not found exception", type: ErrorResponseDto }) + entryTemplates(@Param("id") id: string): Promise { + return this.entriesService.findTemplatesForParticipant(+id); + } + @Patch(":id") @ApiOperation({ summary: "Update a participant by ID" }) update(@Param("id") id: string, @Body() participant: ParticipantMutationDto): Promise { diff --git a/apps/frontend/src/api.gen.ts b/apps/frontend/src/api.gen.ts index c226922e..7c0c4c3a 100644 --- a/apps/frontend/src/api.gen.ts +++ b/apps/frontend/src/api.gen.ts @@ -222,6 +222,23 @@ export interface paths { patch: operations["ParticipantsController_update"]; trace?: never; }; + "/participants/{id}/entry-templates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get a participant by ID */ + get: operations["ParticipantsController_entryTemplates"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/entries": { parameters: { query?: never; @@ -658,6 +675,61 @@ export interface components { carers: number[]; languages: number[]; }; + CarerDto: { + /** + * @description The id of the carer + * @example 1 + */ + id: number; + /** + * @description The name of the carer + * @example Grandmother + */ + name: string; + /** + * @description The color used to display entries in the calendar + * @example #ffffff + */ + color?: string; + participant?: components["schemas"]["ParticipantDto"]; + entries: number[]; + }; + LanguageDto: { + /** + * @description The id of the language + * @example 1 + */ + id: number; + /** + * @description The name of the language + * @example Deutsch + */ + name: string; + /** + * @description The IETF BCP 47 code of the language + * @example de-DE + */ + ietfBcp47?: string; + participant?: components["schemas"]["ParticipantDto"]; + entryLanguages: number[]; + }; + EntryLanguageResponseDto: { + /** + * @description The id of the entry language + * @example 1 + */ + id: number; + /** + * @description The ratio in percent of the entry language + * @example 50 + */ + ratio: number; + language: components["schemas"]["LanguageDto"]; + }; + EntryTemplateDto: { + carer: components["schemas"]["CarerDto"]; + entryLanguages: components["schemas"]["EntryLanguageResponseDto"][]; + }; ParticipantMutationDto: { /** * @description The id of the participant (child id) @@ -745,57 +817,6 @@ export interface components { study: components["schemas"]["StudyDto"]; participant: components["schemas"]["ParticipantDto"]; }; - CarerDto: { - /** - * @description The id of the carer - * @example 1 - */ - id: number; - /** - * @description The name of the carer - * @example Grandmother - */ - name: string; - /** - * @description The color used to display entries in the calendar - * @example #ffffff - */ - color?: string; - participant?: components["schemas"]["ParticipantDto"]; - entries: number[]; - }; - LanguageDto: { - /** - * @description The id of the language - * @example 1 - */ - id: number; - /** - * @description The name of the language - * @example Deutsch - */ - name: string; - /** - * @description The IETF BCP 47 code of the language - * @example de-DE - */ - ietfBcp47?: string; - participant?: components["schemas"]["ParticipantDto"]; - entryLanguages: number[]; - }; - EntryLanguageResponseDto: { - /** - * @description The id of the entry language - * @example 1 - */ - id: number; - /** - * @description The ratio in percent of the entry language - * @example 50 - */ - ratio: number; - language: components["schemas"]["LanguageDto"]; - }; EntryResponseDto: { /** * @description The id of the entry @@ -1754,6 +1775,36 @@ export interface operations { }; }; }; + ParticipantsController_entryTemplates: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EntryTemplateDto"][]; + }; + }; + /** @description Entity not found exception */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponseDto"]; + }; + }; + }; + }; EntriesController_index: { parameters: { query?: never; diff --git a/apps/frontend/src/components/CarerSelect.tsx b/apps/frontend/src/components/CarerSelect.tsx index 95a94e01..b1b0782d 100644 --- a/apps/frontend/src/components/CarerSelect.tsx +++ b/apps/frontend/src/components/CarerSelect.tsx @@ -1,3 +1,4 @@ +import { ColorSwatch, Group, useMantineTheme } from "@quassel/ui"; import { components } from "../api.gen"; import { EntitySelect, EntitySelectProps } from "./EntitySelect"; @@ -6,5 +7,22 @@ type CarerSelectProps = EntitySelectProps & { }; export function CarerSelect({ value, onChange, onAddNew, data, ...rest }: CarerSelectProps) { - return ; + const theme = useMantineTheme(); + + return ( + ( + + + {name} + + )} + data={data} + labelKey="name" + /> + ); } diff --git a/apps/frontend/src/components/EntitySelect.tsx b/apps/frontend/src/components/EntitySelect.tsx index 8a3243d5..2417cf71 100644 --- a/apps/frontend/src/components/EntitySelect.tsx +++ b/apps/frontend/src/components/EntitySelect.tsx @@ -14,6 +14,7 @@ type StringKeys = { [K in keyof T]-?: T[K] extends string ? K : never }[keyof type Props = Omit & { data?: T[]; + renderOption?: (item: T) => void; onAddNew?: (value: string) => void; labelKey: StringKeys; }; @@ -24,7 +25,7 @@ const messages = i18n("entitySelect", { actionCreateNew: params('Create new "{value}"'), }); -export function EntitySelect({ value, onChange, data, labelKey, onAddNew, ...rest }: Props) { +export function EntitySelect({ value, onChange, data, labelKey, onAddNew, renderOption, ...rest }: Props) { const t = useStore(messages); const combobox = useCombobox({ @@ -41,7 +42,7 @@ export function EntitySelect({ value, onChange, data, const options = filteredOptions?.map((item) => ( - {item[labelKey] as string} + {renderOption?.(item) ?? (item[labelKey] as string)} )); diff --git a/apps/frontend/src/components/TemplateMenu.tsx b/apps/frontend/src/components/TemplateMenu.tsx new file mode 100644 index 00000000..ded96d9d --- /dev/null +++ b/apps/frontend/src/components/TemplateMenu.tsx @@ -0,0 +1,37 @@ +import { Button, ColorSwatch, IconChevronDown, Menu, useMantineTheme } from "@quassel/ui"; +import { components } from "../api.gen"; + +type TemplateSelectProps = { + label: string; + templates: components["schemas"]["EntryTemplateDto"][]; + onSelect: (value: components["schemas"]["EntryTemplateDto"]) => void; +}; + +export function TemplateMenu({ label, templates, onSelect }: TemplateSelectProps) { + const theme = useMantineTheme(); + + return ( + + + + + + + {templates.map((t) => { + const label = `${t.carer.name}: ${t.entryLanguages.map(({ language, ratio }) => `${ratio}% ${language.name}`).join(", ")}`; + return ( + onSelect(t)} + leftSection={} + > + {label} + + ); + })} + + + ); +} diff --git a/apps/frontend/src/components/questionnaire/QuestionnaireEntries.tsx b/apps/frontend/src/components/questionnaire/QuestionnaireEntries.tsx index 4ba8cf74..26c70490 100644 --- a/apps/frontend/src/components/questionnaire/QuestionnaireEntries.tsx +++ b/apps/frontend/src/components/questionnaire/QuestionnaireEntries.tsx @@ -24,9 +24,20 @@ export function QuestionnaireEntries({ questionnaire, gaps }: QuestionnaireEntri const participantId = questionnaire.participant?.id; - const createMutation = $api.useMutation("post", "/entries"); - const updateMutation = $api.useMutation("patch", "/entries/{id}"); - const deleteMutation = $api.useMutation("delete", "/entries/{id}"); + const invalidateTemplates = () => + c.invalidateQueries( + $api.queryOptions("get", "/participants/{id}/entry-templates", { + params: { path: { id: participantId.toString() } }, + }) + ); + + const createMutation = $api.useMutation("post", "/entries", { onSuccess: invalidateTemplates }); + const updateMutation = $api.useMutation("patch", "/entries/{id}", { onSuccess: invalidateTemplates }); + const deleteMutation = $api.useMutation("delete", "/entries/{id}", { onSuccess: invalidateTemplates }); + + const { data: templates } = $api.useQuery("get", "/participants/{id}/entry-templates", { + params: { path: { id: participantId.toString() } }, + }); const { data: languages } = $api.useQuery("get", "/languages", { params: { query: { participantId } } }); const createLanguageMutation = $api.useMutation("post", "/languages", { @@ -83,6 +94,7 @@ export function QuestionnaireEntries({ questionnaire, gaps }: QuestionnaireEntri onDeleteEntry={handleDelete} carers={carers ?? []} languages={languages ?? []} + templates={templates ?? []} onAddCarer={(name) => createCarerMutation.mutateAsync({ body: { name, participant: participantId } }).then(({ id }) => id)} onAddLanguage={(name) => createLanguageMutation.mutateAsync({ body: { name, participant: participantId } }).then(({ id }) => id)} /> diff --git a/apps/frontend/src/components/questionnaire/calendar/EntryCalendar.tsx b/apps/frontend/src/components/questionnaire/calendar/EntryCalendar.tsx index f6265aeb..89db830a 100644 --- a/apps/frontend/src/components/questionnaire/calendar/EntryCalendar.tsx +++ b/apps/frontend/src/components/questionnaire/calendar/EntryCalendar.tsx @@ -41,6 +41,7 @@ export type EntryCalendarProps = { onDeleteEntry: (id: number) => Promise; carers: components["schemas"]["CarerDto"][]; languages: components["schemas"]["LanguageDto"][]; + templates: components["schemas"]["EntryTemplateDto"][]; onAddCarer: (value: string) => Promise; onAddLanguage: (value: string) => Promise; }; @@ -58,6 +59,7 @@ export function EntryCalendar({ onDeleteEntry, carers, languages, + templates, onAddCarer, onAddLanguage, }: EntryCalendarProps) { @@ -169,6 +171,7 @@ export function EntryCalendar({ entry={entryDraft} carers={carers} languages={languages} + templates={templates} actionLabel={entryUpdatingId ? t.actionUpdate : t.actionAdd} /> diff --git a/apps/frontend/src/components/questionnaire/calendar/EntryForm.tsx b/apps/frontend/src/components/questionnaire/calendar/EntryForm.tsx index 34e55451..310a55bf 100644 --- a/apps/frontend/src/components/questionnaire/calendar/EntryForm.tsx +++ b/apps/frontend/src/components/questionnaire/calendar/EntryForm.tsx @@ -1,10 +1,11 @@ -import { Button, Group, Stack, TimeInput, NumberInput, ActionIcon, IconMinus, isInRange, isNotEmpty, useForm, Switch } from "@quassel/ui"; +import { Button, Group, Stack, TimeInput, NumberInput, ActionIcon, IconMinus, isInRange, isNotEmpty, useForm, Switch, Flex } from "@quassel/ui"; import { i18n } from "../../../stores/i18n"; import { useStore } from "@nanostores/react"; import { useEffect, useState } from "react"; import { CarerSelect } from "../../CarerSelect"; import { LanguageSelect } from "../../LanguageSelect"; import { components } from "../../../api.gen"; +import { TemplateMenu } from "../../TemplateMenu"; export type EntryFormValues = { carer?: number; @@ -22,11 +23,11 @@ const messages = i18n("entityForm", { actionAddLanguage: "Add language", actionAddRecurringRule: "Add recurring rule", actionDelete: "Delete", - labelCarer: "Carer", labelLanguage: "Language", labelRecurringRulePrefix: "Recurs every", labelRecurringRuleSuffix: "weeks.", + labelTemplate: "From template", validationRatio: "Ratio must be between 1 and 100.", validationTotalRatio: "Total Ratio must always be 100%.", validationNotEmpty: "Field must not be empty.", @@ -40,10 +41,11 @@ type EntityFormProps = { entry?: Partial; carers: components["schemas"]["CarerDto"][]; languages: components["schemas"]["LanguageDto"][]; + templates: components["schemas"]["EntryTemplateDto"][]; actionLabel: string; }; -export function EntityForm({ onSave, onDelete, onAddCarer, onAddLanguage, actionLabel, entry, carers, languages }: EntityFormProps) { +export function EntityForm({ onSave, onDelete, onAddCarer, onAddLanguage, actionLabel, entry, carers, languages, templates }: EntityFormProps) { const t = useStore(messages); const f = useForm({ initialValues: { @@ -109,7 +111,19 @@ export function EntityForm({ onSave, onDelete, onAddCarer, onAddLanguage, action }} > - + + + { + f.setValues({ + carer: carer.id, + entryLanguages: entryLanguages.map(({ language, ratio }) => ({ language: language.id, ratio })), + }); + }} + /> + {f.getValues().entryLanguages.map((value, index) => ( diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 1089c0c6..ca21fcd1 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -10,6 +10,7 @@ import "@mantine/core/styles/CloseButton.css"; import "@mantine/core/styles/Group.css"; import "@mantine/core/styles/Loader.css"; import "@mantine/core/styles/Overlay.css"; +import "@mantine/core/styles/Menu.css"; import "@mantine/core/styles/ModalBase.css"; import "@mantine/core/styles/Modal.css"; import "@mantine/core/styles/Input.css"; @@ -66,6 +67,7 @@ export { Group, InputError, InputLabel, + Menu, Modal, NavLink, Paper, @@ -107,6 +109,7 @@ export { IconMapSearch, IconMinus, IconRepeat, + IconChevronDown, IconMaximize, IconMaximizeOff, } from "@tabler/icons-react";