Skip to content

Commit c24e079

Browse files
authored
Merge pull request #208 from openscript-ch/40-optimize-for-tablet-and-desktop-usability
40 optimize for tablet and desktop usability
2 parents 41e0afe + f1bd698 commit c24e079

File tree

6 files changed

+181
-123
lines changed

6 files changed

+181
-123
lines changed

.changeset/brown-worms-sniff.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@quassel/frontend": patch
3+
---
4+
5+
Enhance UX of calendar event handling
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import FullCalendar from "@fullcalendar/react";
2+
import interactionPlugin from "@fullcalendar/interaction";
3+
import timeGridPlugin from "@fullcalendar/timegrid";
4+
import { EventChangeArg, EventInput } from "@fullcalendar/core";
5+
import { formatDate, getDateFromTimeAndWeekday, getTime, isSame, Modal, useDisclosure, useMantineTheme } from "@quassel/ui";
6+
import { QuestionnaireEntry } from "./QuestionnaireEntry";
7+
import { components } from "../../../api.gen";
8+
import { EntityForm, EntryFormValues } from "./EntryForm";
9+
import { useEffect, useState } from "react";
10+
import { i18n } from "../../../stores/i18n";
11+
import { useStore } from "@nanostores/react";
12+
13+
const calendarBaseConfig: FullCalendar["props"] = {
14+
allDaySlot: false,
15+
headerToolbar: false,
16+
slotMinTime: { hour: 5 },
17+
slotMaxTime: { hour: 23 },
18+
slotDuration: { hour: 1 },
19+
firstDay: 1,
20+
dayHeaderContent: ({ date }) => formatDate(date, "dddd"),
21+
locale: "de",
22+
expandRows: true,
23+
editable: true,
24+
selectAllow: ({ start, end }) => isSame("day", start, end),
25+
selectable: true,
26+
selectLongPressDelay: 200,
27+
eventLongPressDelay: 400,
28+
};
29+
30+
export type ExtendedEvent = EventInput & { extendedProps: { entryLanguages: components["schemas"]["EntryLanguageResponseDto"][] } };
31+
32+
export type EntryCalendarProps = {
33+
entries: components["schemas"]["QuestionnaireEntryDto"][];
34+
onAddEntry: (entry: EntryFormValues, weekday: number) => Promise<unknown>;
35+
onUpdateEntry: (id: number, entry: Partial<EntryFormValues>, weekday: number) => Promise<unknown>;
36+
onDeleteEntry: (id: number) => Promise<unknown>;
37+
carers: components["schemas"]["CarerDto"][];
38+
languages: components["schemas"]["LanguageDto"][];
39+
onAddCarer: (value: string) => Promise<number>;
40+
onAddLanguage: (value: string) => Promise<number>;
41+
};
42+
43+
const messages = i18n("entryCalendar", {
44+
actionAdd: "Add",
45+
actionUpdate: "Update",
46+
});
47+
48+
export function EntryCalendar({
49+
entries,
50+
onAddEntry,
51+
onUpdateEntry,
52+
onDeleteEntry,
53+
carers,
54+
languages,
55+
onAddCarer,
56+
onAddLanguage,
57+
}: EntryCalendarProps) {
58+
const theme = useMantineTheme();
59+
60+
const t = useStore(messages);
61+
62+
const [opened, { open, close }] = useDisclosure();
63+
64+
const [selectedWeekday, setSelectedWeekday] = useState<number>();
65+
const [entryUpdatingId, setEntryUpdadingId] = useState<number>();
66+
const [entryDraft, setEntryDraft] = useState<Partial<EntryFormValues>>();
67+
68+
const [events, setEvents] = useState<ExtendedEvent[]>([]);
69+
70+
useEffect(() => {
71+
if (entries) {
72+
setEvents(
73+
entries.map(({ startedAt, endedAt, weekday, carer, entryLanguages, id }) => ({
74+
id: id.toString(),
75+
start: getDateFromTimeAndWeekday(startedAt, weekday),
76+
end: getDateFromTimeAndWeekday(endedAt, weekday),
77+
title: carer.name,
78+
extendedProps: { entryLanguages },
79+
backgroundColor: carer.color ?? theme.colors[theme.primaryColor][4],
80+
borderColor: carer.color ?? theme.colors[theme.primaryColor][4],
81+
})) ?? []
82+
);
83+
}
84+
}, [entries]);
85+
86+
const handleEventChange = ({ event }: EventChangeArg) => {
87+
const { id, start, end } = event;
88+
setEvents(events.map((e) => (e.id === id ? { ...e, start: start!, end: end! } : e)));
89+
90+
onUpdateEntry(parseInt(id), { startedAt: getTime(start!), endedAt: getTime(end!) }, start!.getDay());
91+
};
92+
93+
const handleOnSave = async (entry: EntryFormValues) => {
94+
if (!selectedWeekday) return;
95+
96+
if (!entryUpdatingId) {
97+
await onAddEntry(entry, selectedWeekday);
98+
} else {
99+
await onUpdateEntry(entryUpdatingId, entry, selectedWeekday);
100+
}
101+
close();
102+
};
103+
104+
return (
105+
<>
106+
<Modal opened={opened} onClose={close} size="md">
107+
<EntityForm
108+
onAddCarer={onAddCarer}
109+
onAddLanguage={onAddLanguage}
110+
onSave={handleOnSave}
111+
onDelete={entryUpdatingId ? () => onDeleteEntry(entryUpdatingId).then(close) : undefined}
112+
entry={entryDraft}
113+
carers={carers}
114+
languages={languages}
115+
actionLabel={entryUpdatingId ? t.actionUpdate : t.actionAdd}
116+
/>
117+
</Modal>
118+
<FullCalendar
119+
{...calendarBaseConfig}
120+
plugins={[timeGridPlugin, interactionPlugin]}
121+
events={events}
122+
select={({ start, end }) => {
123+
setEntryDraft({ startedAt: getTime(start), endedAt: getTime(end) });
124+
setSelectedWeekday(start.getDay());
125+
open();
126+
}}
127+
eventClick={({ event }) => {
128+
const { carer, entryLanguages, id, weekday, ...rest } = entries?.find((entry) => entry.id.toString() === event.id) ?? {};
129+
130+
setEntryDraft({
131+
carer: carer?.id,
132+
entryLanguages: entryLanguages?.map(({ language, ...rest }) => ({ ...rest, language: language.id })),
133+
...rest,
134+
startedAt: getTime(event.start!),
135+
endedAt: getTime(event.end!),
136+
});
137+
setSelectedWeekday(weekday);
138+
setEntryUpdadingId(id);
139+
open();
140+
}}
141+
eventChange={handleEventChange}
142+
eventContent={({ event }) => <QuestionnaireEntry event={event} />}
143+
/>
144+
</>
145+
);
146+
}

apps/frontend/src/components/questionnaire/calendar/EntryForm.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,12 @@ export function EntityForm({ onSave, onDelete, onAddCarer, onAddLanguage, action
102102
};
103103

104104
return (
105-
<form onSubmit={f.onSubmit(onSave)}>
105+
<form
106+
onSubmit={(event) => {
107+
f.onSubmit(onSave)(event);
108+
event.stopPropagation();
109+
}}
110+
>
106111
<Stack>
107112
<CarerSelect data={carers} {...f.getInputProps("carer")} onAddNew={onAddCarer} placeholder={t.labelCarer} />
108113

apps/frontend/src/components/questionnaire/calendar/QuestionnaireEntry.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { EventImpl } from "@fullcalendar/core/internal";
2-
import { ExtendedEvent } from "../../../routes/_auth/questionnaire/_questionnaire/$id/entries";
32
import { IconRepeat, Stack, Text } from "@quassel/ui";
43
import { i18n } from "../../../stores/i18n";
54
import { params } from "@nanostores/i18n";
65
import { useStore } from "@nanostores/react";
6+
import { ExtendedEvent } from "./EntryCalendar";
77

88
type QuestionnaireEntryProps = {
99
event: EventImpl;

apps/frontend/src/routes/_auth/questionnaire/_questionnaire/$id/entries.tsx

Lines changed: 22 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,11 @@
1-
import {
2-
Button,
3-
formatDate,
4-
getDateFromTimeAndWeekday,
5-
Group,
6-
Stack,
7-
useMantineTheme,
8-
useDisclosure,
9-
Modal,
10-
getTime,
11-
notifications,
12-
} from "@quassel/ui";
1+
import { Button, Group, Stack, notifications } from "@quassel/ui";
132
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
143
import { i18n } from "../../../../../stores/i18n";
154
import { useStore } from "@nanostores/react";
165
import { $api } from "../../../../../stores/api";
17-
import FullCalendar from "@fullcalendar/react";
18-
import timeGridPlugin from "@fullcalendar/timegrid";
19-
import { EventInput } from "@fullcalendar/core";
20-
import interactionPlugin from "@fullcalendar/interaction";
21-
import { components } from "../../../../../api.gen";
22-
import { QuestionnaireEntry } from "../../../../../components/questionnaire/calendar/QuestionnaireEntry";
23-
import { EntityForm, EntryFormValues } from "../../../../../components/questionnaire/calendar/EntryForm";
24-
import { useState } from "react";
6+
import { EntryFormValues } from "../../../../../components/questionnaire/calendar/EntryForm";
257
import { useQueryClient } from "@tanstack/react-query";
26-
27-
export type ExtendedEvent = EventInput & {
28-
extendedProps: { entryLanguages: components["schemas"]["EntryLanguageResponseDto"][]; weeklyRecurring?: number };
29-
};
30-
31-
const calendarBaseConfig: FullCalendar["props"] = {
32-
allDaySlot: false,
33-
headerToolbar: false,
34-
slotMinTime: { hour: 5 },
35-
slotMaxTime: { hour: 23 },
36-
slotDuration: { hour: 1 },
37-
firstDay: 1,
38-
dayHeaderContent: ({ date }) => formatDate(date, "dddd"),
39-
locale: "de",
40-
expandRows: true,
41-
};
8+
import { EntryCalendar } from "../../../../../components/questionnaire/calendar/EntryCalendar";
429

4310
const messages = i18n("questionnaireEntries", {
4411
formAction: "Continue",
@@ -56,17 +23,10 @@ function QuestionnaireEntries() {
5623

5724
const c = useQueryClient();
5825

59-
const theme = useMantineTheme();
60-
const [opened, { open, close }] = useDisclosure();
61-
62-
const [selectedWeekday, setSelectedWeekday] = useState<number>();
63-
const [entryUpdatingId, setEntryUpdadingId] = useState<number>();
64-
const [entryDraft, setEntryDraft] = useState<Partial<EntryFormValues>>();
65-
6626
const createMutation = $api.useMutation("post", "/entries");
6727
const updateMutation = $api.useMutation("patch", "/entries/{id}");
6828
const deleteMutation = $api.useMutation("delete", "/entries/{id}");
69-
const { data: questionnaire, refetch } = $api.useSuspenseQuery("get", "/questionnaires/{id}", { params: { path: { id: p.id } } });
29+
const { data: questionnaire } = $api.useSuspenseQuery("get", "/questionnaires/{id}", { params: { path: p } });
7030

7131
const participantId = questionnaire.participant?.id;
7232

@@ -86,58 +46,34 @@ function QuestionnaireEntries() {
8646
},
8747
});
8848

89-
const events: ExtendedEvent[] =
90-
questionnaire.entries?.map(({ startedAt, endedAt, weekday, carer, entryLanguages, id, weeklyRecurring }) => ({
91-
id: id.toString(),
92-
start: getDateFromTimeAndWeekday(startedAt, weekday),
93-
end: getDateFromTimeAndWeekday(endedAt, weekday),
94-
title: carer.name,
95-
extendedProps: { entryLanguages, weeklyRecurring },
96-
backgroundColor: carer.color ?? theme.colors[theme.primaryColor][4],
97-
borderColor: carer.color ?? theme.colors[theme.primaryColor][4],
98-
})) ?? [];
99-
100-
const reset = () => {
101-
refetch();
102-
close();
103-
setSelectedWeekday(undefined);
104-
setEntryUpdadingId(undefined);
49+
const reloadEntries = () => {
50+
c.invalidateQueries($api.queryOptions("get", "/questionnaires/{id}", { params: { path: p } }));
10551
};
10652

107-
const handleCreate = ({ carer, ...rest }: EntryFormValues) => {
108-
if (selectedWeekday === undefined) return;
109-
53+
const handleCreate = ({ carer, ...rest }: EntryFormValues, weekday: number) => {
11054
const entryRequest = {
11155
...rest,
11256
carer: carer!,
113-
weekday: selectedWeekday,
57+
weekday,
11458
questionnaire: questionnaire.id,
11559
};
11660

117-
createMutation.mutate({ body: entryRequest }, { onSuccess: reset });
61+
return createMutation.mutateAsync({ body: entryRequest }, { onSuccess: reloadEntries });
11862
};
11963

120-
const handleUpdate = (id: number, { carer, ...rest }: Partial<EntryFormValues>, weekday?: number) => {
64+
const handleUpdate = (id: number, { carer, ...rest }: Partial<EntryFormValues>, weekday: number) => {
12165
const entryRequest = {
12266
...rest,
12367
carer: carer!,
12468
weekday,
12569
questionnaire: questionnaire.id,
12670
};
12771

128-
updateMutation.mutate({ body: entryRequest, params: { path: { id: id.toString() } } }, { onSuccess: reset });
72+
return updateMutation.mutateAsync({ body: entryRequest, params: { path: { id: id.toString() } } }, { onSuccess: reloadEntries });
12973
};
13074

13175
const handleDelete = (id: number) => {
132-
deleteMutation.mutate({ params: { path: { id: id.toString() } } }, { onSuccess: reset });
133-
};
134-
135-
const handleOnSave = (entry: EntryFormValues | Partial<EntryFormValues>) => {
136-
if (!entryUpdatingId) {
137-
handleCreate(entry as EntryFormValues);
138-
} else {
139-
handleUpdate(entryUpdatingId, entry);
140-
}
76+
return deleteMutation.mutateAsync({ params: { path: { id: id.toString() } } }, { onSuccess: reloadEntries });
14177
};
14278

14379
const handleSubmit = () => {
@@ -146,53 +82,19 @@ function QuestionnaireEntries() {
14682

14783
return (
14884
<>
149-
<Modal opened={opened} onClose={close} size="md">
150-
<EntityForm
151-
onAddCarer={(name) => createCarerMutation.mutateAsync({ body: { name, participant: participantId } }).then(({ id }) => id)}
152-
onAddLanguage={(name) => createLanguageMutation.mutateAsync({ body: { name, participant: participantId } }).then(({ id }) => id)}
153-
onSave={handleOnSave}
154-
onDelete={entryUpdatingId ? () => handleDelete(entryUpdatingId) : undefined}
155-
entry={entryDraft}
156-
carers={carers ?? []}
157-
languages={languages ?? []}
158-
actionLabel={t.addEntityLabel}
159-
/>
160-
</Modal>
16185
<form onSubmit={handleSubmit}>
16286
<Stack>
163-
<FullCalendar
164-
{...calendarBaseConfig}
165-
plugins={[timeGridPlugin, interactionPlugin]}
166-
editable
167-
events={events}
168-
selectable
169-
select={({ start, end }) => {
170-
setEntryDraft({ startedAt: getTime(start), endedAt: getTime(end) });
171-
setSelectedWeekday(start.getDay());
172-
open();
173-
}}
174-
eventClick={(args) => {
175-
const { carer, entryLanguages, id, weeklyRecurring } =
176-
questionnaire.entries?.find((entry) => entry.id.toString() === args.event.id) ?? {};
177-
178-
setEntryDraft({
179-
carer: carer?.id,
180-
startedAt: getTime(args.event.start!),
181-
endedAt: getTime(args.event.end!),
182-
entryLanguages: entryLanguages?.map(({ language, ...rest }) => ({ ...rest, language: language.id })),
183-
weeklyRecurring,
184-
});
185-
setEntryUpdadingId(id);
186-
open();
187-
}}
188-
eventResize={({ event: { id, start, end } }) => {
189-
handleUpdate(parseInt(id), { startedAt: getTime(start!), endedAt: getTime(end!) });
190-
}}
191-
eventDrop={({ event: { id, start, end } }) => {
192-
handleUpdate(parseInt(id), { startedAt: getTime(start!), endedAt: getTime(end!) }, start!.getDay());
193-
}}
194-
eventContent={({ event }) => <QuestionnaireEntry event={event} />}
87+
<EntryCalendar
88+
entries={questionnaire.entries ?? []}
89+
onAddEntry={handleCreate}
90+
onUpdateEntry={handleUpdate}
91+
onDeleteEntry={handleDelete}
92+
carers={carers ?? []}
93+
languages={languages ?? []}
94+
onAddCarer={(name) => createCarerMutation.mutateAsync({ body: { name, participant: participantId } }).then(({ id }) => id)}
95+
onAddLanguage={(name) => createLanguageMutation.mutateAsync({ body: { name, participant: participantId } }).then(({ id }) => id)}
19596
/>
97+
19698
<Group>
19799
<Link to="/questionnaire/$id/period" params={p}>
198100
<Button variant="light">{t.backAction}</Button>

libs/ui/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import "@mantine/notifications/styles.css";
3737
export { ThemeProvider } from "./theme/ThemeProvider";
3838

3939
// custom ui helpers
40-
export { formatDate, getTime, getDateFromTimeAndWeekday, getNext, isSameOrAfter } from "./utils/date";
40+
export { formatDate, getTime, getDateFromTimeAndWeekday, getNext, isSameOrAfter, isSame } from "./utils/date";
4141

4242
// custom components
4343
export { Brand } from "./components/Brand";

0 commit comments

Comments
 (0)