Skip to content

Commit 76379f8

Browse files
authored
Merge pull request #196 from openscript-ch/12-develop-people-selection-interface-dropdown-multi-select-manual-input
12 develop people selection interface dropdown multi select manual input
2 parents 0e16156 + c1e1a58 commit 76379f8

File tree

21 files changed

+322
-102
lines changed

21 files changed

+322
-102
lines changed

.changeset/sixty-wolves-sell.md

+7
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+
Allow custom carers and languages per participant

apps/backend/src/defaults/carers/carer.dto.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ export class CarerDto {
1919
entries: number[];
2020
}
2121
export class CarerResponseDto extends CarerDto {}
22-
export class CarerCreationDto extends OmitType(CarerDto, ["id", "entries"]) {}
22+
export class CarerCreationDto extends OmitType(CarerDto, ["id", "entries", "participant"]) {
23+
participant?: number;
24+
}
2325
export class CarerMutationDto extends PartialType(CarerCreationDto) {}

apps/backend/src/defaults/carers/carers.controller.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Body, Controller, Delete, Get, Param, Patch, Post } from "@nestjs/common";
2-
import { ApiOperation, ApiTags, ApiUnprocessableEntityResponse } from "@nestjs/swagger";
1+
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common";
2+
import { ApiOperation, ApiQuery, ApiTags, ApiUnprocessableEntityResponse } from "@nestjs/swagger";
33
import { CarersService } from "./carers.service";
44
import { CarerCreationDto, CarerMutationDto, CarerResponseDto } from "./carer.dto";
55
import { ErrorResponseDto } from "../../common/dto/error.dto";
@@ -19,9 +19,10 @@ export class CarersController {
1919
}
2020

2121
@Get()
22+
@ApiQuery({ name: "participantId", required: false, type: Number })
2223
@ApiOperation({ summary: "Get all carers" })
23-
index(): Promise<CarerResponseDto[]> {
24-
return this.carersService.findAll();
24+
index(@Query("participantId") participantId?: number): Promise<CarerResponseDto[]> {
25+
return this.carersService.findAll(participantId);
2526
}
2627

2728
@Get(":id")

apps/backend/src/defaults/carers/carers.service.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export class CarersService {
1414

1515
async create(carerCreationDto: CarerCreationDto) {
1616
const carer = new Carer();
17-
carer.assign(carerCreationDto);
17+
carer.assign(carerCreationDto, { em: this.em });
1818

1919
try {
2020
await this.em.persist(carer).flush();
@@ -28,8 +28,12 @@ export class CarersService {
2828
return carer.toObject();
2929
}
3030

31-
async findAll() {
32-
return (await this.carerRepository.findAll()).map((carer) => carer.toObject());
31+
async findAll(participantId?: number) {
32+
return (
33+
await this.carerRepository.findAll({
34+
where: participantId ? { $or: [{ participant: null }, { participant: participantId }] } : { participant: null },
35+
})
36+
).map((carer) => carer.toObject());
3337
}
3438

3539
async findOne(id: number) {

apps/backend/src/defaults/languages/language.dto.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,7 @@ export class LanguageDto {
2424
entryLanguages: number[];
2525
}
2626
export class LanguageResponseDto extends LanguageDto {}
27-
export class LanguageCreationDto extends OmitType(LanguageDto, ["id", "entryLanguages"]) {}
27+
export class LanguageCreationDto extends OmitType(LanguageDto, ["id", "entryLanguages", "participant"]) {
28+
participant?: number;
29+
}
2830
export class LanguageMutationDto extends PartialType(LanguageCreationDto) {}

apps/backend/src/defaults/languages/languages.controller.ts

+5-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Body, Controller, Delete, Get, Param, Patch, Post } from "@nestjs/common";
2-
import { ApiOperation, ApiTags, ApiUnprocessableEntityResponse } from "@nestjs/swagger";
1+
import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from "@nestjs/common";
2+
import { ApiOperation, ApiQuery, ApiTags, ApiUnprocessableEntityResponse } from "@nestjs/swagger";
33
import { LanguagesService } from "./languages.service";
44
import { ErrorResponseDto } from "../../common/dto/error.dto";
55
import { Roles } from "../../system/users/roles.decorator";
@@ -19,9 +19,10 @@ export class LanguagesController {
1919
}
2020

2121
@Get()
22+
@ApiQuery({ name: "participantId", required: false, type: Number })
2223
@ApiOperation({ summary: "Get all languages" })
23-
index(): Promise<LanguageResponseDto[]> {
24-
return this.languagesService.findAll();
24+
index(@Query("participantId") participantId?: number): Promise<LanguageResponseDto[]> {
25+
return this.languagesService.findAll(participantId);
2526
}
2627

2728
@Get(":id")

apps/backend/src/defaults/languages/languages.service.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export class LanguagesService {
1414

1515
async create(languageCreationDto: LanguageCreationDto) {
1616
const language = new Language();
17-
language.assign(languageCreationDto);
17+
language.assign(languageCreationDto, { em: this.em });
1818

1919
try {
2020
await this.em.persist(language).flush();
@@ -28,8 +28,12 @@ export class LanguagesService {
2828
return language.toObject();
2929
}
3030

31-
async findAll() {
32-
return (await this.languageRepository.findAll()).map((language) => language.toObject());
31+
async findAll(participantId?: number) {
32+
return (
33+
await this.languageRepository.findAll({
34+
where: participantId ? { $or: [{ participant: null }, { participant: participantId }] } : { participant: null },
35+
})
36+
).map((language) => language.toObject());
3337
}
3438

3539
async findOne(id: number) {

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

+5-3
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class QuestionnairesService {
4747
throw e;
4848
}
4949

50-
return (await questionnaire.populate(["entries", "entries.carer", "entries.entryLanguages.language"])).toObject();
50+
return (await questionnaire.populate(["entries", "entries.carer", "entries.entryLanguages.language", "participant"])).toObject();
5151
}
5252

5353
async findAll() {
@@ -56,7 +56,9 @@ export class QuestionnairesService {
5656

5757
async findOne(id: number) {
5858
return (
59-
await this.questionnaireRepository.findOneOrFail(id, { populate: ["entries", "entries.carer", "entries.entryLanguages.language"] })
59+
await this.questionnaireRepository.findOneOrFail(id, {
60+
populate: ["entries", "entries.carer", "entries.entryLanguages.language", "participant"],
61+
})
6062
).toObject();
6163
}
6264

@@ -70,7 +72,7 @@ export class QuestionnairesService {
7072

7173
async update(id: number, questionnaireMutationDto: QuestionnaireMutationDto) {
7274
const questionnaire = await this.questionnaireRepository.findOneOrFail(id, {
73-
populate: ["entries", "entries.carer", "entries.entryLanguages.language"],
75+
populate: ["entries", "entries.carer", "entries.entryLanguages.language", "participant"],
7476
});
7577
questionnaire.assign(questionnaireMutationDto);
7678

apps/frontend/src/api.gen.ts

+17-13
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,14 @@ export interface components {
471471
*/
472472
role?: "ASSISTANT" | "ADMIN";
473473
};
474+
CarerCreationDto: {
475+
/**
476+
* @description The name of the carer
477+
* @example Grandmother
478+
*/
479+
name: string;
480+
participant?: number;
481+
};
474482
StudyDto: {
475483
/**
476484
* @description The id of the study (child id)
@@ -532,14 +540,6 @@ export interface components {
532540
carers: number[];
533541
languages: number[];
534542
};
535-
CarerCreationDto: {
536-
/**
537-
* @description The name of the carer
538-
* @example Grandmother
539-
*/
540-
name: string;
541-
participant?: components["schemas"]["ParticipantDto"];
542-
};
543543
CarerResponseDto: {
544544
/**
545545
* @description The id of the carer
@@ -560,7 +560,7 @@ export interface components {
560560
* @example Grandmother
561561
*/
562562
name?: string;
563-
participant?: components["schemas"]["ParticipantDto"];
563+
participant?: number;
564564
};
565565
LanguageCreationDto: {
566566
/**
@@ -573,7 +573,7 @@ export interface components {
573573
* @example de-DE
574574
*/
575575
ietfBcp47?: string;
576-
participant?: components["schemas"]["ParticipantDto"];
576+
participant?: number;
577577
};
578578
LanguageResponseDto: {
579579
/**
@@ -605,7 +605,7 @@ export interface components {
605605
* @example de-DE
606606
*/
607607
ietfBcp47?: string;
608-
participant?: components["schemas"]["ParticipantDto"];
608+
participant?: number;
609609
};
610610
ParticipantCreationDto: {
611611
/**
@@ -1345,7 +1345,9 @@ export interface operations {
13451345
};
13461346
CarersController_index: {
13471347
parameters: {
1348-
query?: never;
1348+
query?: {
1349+
participantId?: number;
1350+
};
13491351
header?: never;
13501352
path?: never;
13511353
cookie?: never;
@@ -1461,7 +1463,9 @@ export interface operations {
14611463
};
14621464
LanguagesController_index: {
14631465
parameters: {
1464-
query?: never;
1466+
query?: {
1467+
participantId?: number;
1468+
};
14651469
header?: never;
14661470
path?: never;
14671471
cookie?: never;
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { $api } from "../stores/api";
1+
import { components } from "../api.gen";
22
import { EntitySelect, EntitySelectProps } from "./EntitySelect";
33

4-
type CarerSelectProps = EntitySelectProps;
4+
type CarerSelectProps = EntitySelectProps & {
5+
data: components["schemas"]["CarerDto"][];
6+
};
57

6-
export function CarerSelect({ value, onChange, ...rest }: CarerSelectProps) {
7-
const { data } = $api.useQuery("get", "/carers");
8-
9-
return <EntitySelect value={value} onChange={onChange} {...rest} data={data} buildLabel={(carer) => carer.name} />;
8+
export function CarerSelect({ value, onChange, onAddNew, data, ...rest }: CarerSelectProps) {
9+
return <EntitySelect value={value} onChange={onChange} onAddNew={onAddNew} {...rest} data={data} labelKey="name" />;
1010
}
+88-11
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,99 @@
1-
import { Select, SelectProps } from "@quassel/ui";
1+
import { Combobox, TextInput, TextInputProps, useCombobox } from "@quassel/ui";
2+
import { useEffect, useState } from "react";
3+
import { i18n } from "../stores/i18n";
4+
import { useStore } from "@nanostores/react";
5+
import { params } from "@nanostores/i18n";
26

3-
export type EntitySelectProps = Omit<SelectProps, "value" | "onChange"> & {
7+
export type EntitySelectProps = Omit<TextInputProps, "value" | "onChange"> & {
48
value?: number;
5-
onChange?: (id?: number) => void;
9+
onChange?: (value?: number) => void;
10+
onAddNew?: (value: string) => Promise<number>;
611
};
712

13+
type StringKeys<T> = { [K in keyof T]-?: T[K] extends string ? K : never }[keyof T];
14+
815
type Props<T extends { id: number }> = Omit<EntitySelectProps, "data"> & {
916
data?: T[];
10-
buildLabel: (value: T) => string;
17+
onAddNew?: (value: string) => void;
18+
labelKey: StringKeys<T>;
1119
};
1220

13-
export function EntitySelect<T extends { id: number }>({ value, onChange, data, buildLabel, ...rest }: Props<T>) {
21+
const customValueKey = "CUSTOM_VALUE";
22+
23+
const messages = i18n("entitySelect", {
24+
actionCreateNew: params('Create new "{value}"'),
25+
});
26+
27+
export function EntitySelect<T extends { id: number }>({ value, onChange, data, labelKey, onAddNew, ...rest }: Props<T>) {
28+
const t = useStore(messages);
29+
30+
const combobox = useCombobox({
31+
onDropdownClose: () => combobox.resetSelectedOption(),
32+
});
33+
34+
const [searchValue, setSearchValue] = useState("");
35+
36+
const shouldFilterOptions = !data?.some((item) => item[labelKey] === searchValue);
37+
const filteredOptions =
38+
(shouldFilterOptions
39+
? data?.filter((item) => (item[labelKey] as string)?.toLowerCase().includes(searchValue.toLowerCase().trim()))
40+
: data) ?? [];
41+
42+
const options = filteredOptions?.map((item) => (
43+
<Combobox.Option key={item.id} value={item.id.toString()}>
44+
{item[labelKey] as string}
45+
</Combobox.Option>
46+
));
47+
48+
useEffect(() => {
49+
if (value && data) {
50+
const index = data.findIndex((item) => item.id === value);
51+
if (index !== -1) {
52+
setSearchValue(data[index][labelKey] as string);
53+
combobox.selectOption(index);
54+
}
55+
}
56+
}, [value, data]);
57+
58+
useEffect(() => {
59+
if (shouldFilterOptions) combobox.selectFirstOption();
60+
}, [searchValue]);
61+
1462
return (
15-
<Select
16-
value={value?.toString()}
17-
onChange={(value) => onChange?.(value ? parseInt(value) : undefined)}
18-
data={data?.map((entity) => ({ value: entity.id.toString(), label: buildLabel(entity) })) ?? []}
19-
{...rest}
20-
/>
63+
<Combobox
64+
store={combobox}
65+
onOptionSubmit={async (value) => {
66+
let id: number;
67+
if (value === customValueKey) {
68+
id = await onAddNew!(searchValue);
69+
} else {
70+
id = +value;
71+
}
72+
onChange?.(id);
73+
combobox.closeDropdown();
74+
}}
75+
>
76+
<Combobox.Target>
77+
<TextInput
78+
value={searchValue}
79+
onChange={({ target: { value } }) => {
80+
setSearchValue(value);
81+
}}
82+
onClick={() => combobox.openDropdown()}
83+
onFocus={() => combobox.openDropdown()}
84+
onBlur={() => combobox.closeDropdown()}
85+
{...rest}
86+
/>
87+
</Combobox.Target>
88+
89+
<Combobox.Dropdown>
90+
<Combobox.Options>
91+
{onAddNew && !options?.length && (
92+
<Combobox.Option value={customValueKey}>{t.actionCreateNew({ value: searchValue })}</Combobox.Option>
93+
)}
94+
{options}
95+
</Combobox.Options>
96+
</Combobox.Dropdown>
97+
</Combobox>
2198
);
2299
}
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { $api } from "../stores/api";
1+
import { components } from "../api.gen";
22
import { EntitySelect, EntitySelectProps } from "./EntitySelect";
33

4-
type LanguageSelectProps = EntitySelectProps;
4+
type LanguageSelectProps = EntitySelectProps & {
5+
data: components["schemas"]["LanguageDto"][];
6+
};
57

6-
export function LanguageSelect({ value, onChange, ...rest }: LanguageSelectProps) {
7-
const { data } = $api.useQuery("get", "/languages");
8-
9-
return <EntitySelect value={value} onChange={onChange} searchable {...rest} data={data} buildLabel={(language) => language.name} />;
8+
export function LanguageSelect({ value, onChange, data, onAddNew, ...rest }: LanguageSelectProps) {
9+
return <EntitySelect value={value} onChange={onChange} onAddNew={onAddNew} {...rest} data={data} labelKey="name" />;
1010
}

0 commit comments

Comments
 (0)