Skip to content

Commit 04f57d4

Browse files
authored
Merge pull request #200 from ystv/int-187-create-recurring-events
[INT-187] Create recurring events
2 parents 129b0ea + 429add5 commit 04f57d4

File tree

17 files changed

+485
-161
lines changed

17 files changed

+485
-161
lines changed

app/(authenticated)/calendar/[eventID]/EventActionsUI.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
ConditionalField,
1919
DatePickerField,
2020
SearchedMemberSelect,
21+
SelectField,
2122
TextAreaField,
2223
TextField,
2324
} from "@/components/FormFields";
@@ -58,6 +59,31 @@ function EditModal(props: { event: EventObjectType; close: () => void }) {
5859
{/*<CheckBoxField name="is_private" label="Private Event" />*/}
5960
<br />
6061
<CheckBoxField name="is_tentative" label="Tentative Event" />
62+
<br />
63+
<Alert
64+
variant="light"
65+
color="orange"
66+
icon={<TbAlertTriangle />}
67+
title="Recurring Event"
68+
className="mt-4"
69+
>
70+
This event is recurring. It is linked to a number of other events which
71+
can be upated at the same time, but changing the timings has to be done
72+
individually.
73+
</Alert>
74+
<br />
75+
<SelectField
76+
name="recurring_update_type"
77+
label="Events to Update"
78+
options={[
79+
{ type: "none", label: "Only update this event" },
80+
{ type: "future", label: "Update this and future events" },
81+
{ type: "past", label: "Update this and past events" },
82+
{ type: "all", label: "Update all events" },
83+
]}
84+
getOptionValue={(obj) => obj.type}
85+
renderOption={(obj) => obj.label}
86+
/>
6187
{props.event.event_type === "public" && (
6288
<Alert
6389
variant="light"

app/(authenticated)/calendar/[eventID]/actions.ts

Lines changed: 79 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,26 +36,88 @@ export const editEvent = wrapServerAction(
3636
if (!data.success) {
3737
return zodErrorResponse(data.error);
3838
}
39-
const result = await Calendar.updateEvent(eventID, data.data, me.user_id);
40-
if (!result.ok) {
41-
switch (result.reason) {
42-
case "kit_clash":
43-
return {
44-
ok: false,
45-
errors: {
46-
root: "The changed dates would result in a kit clash. Please contact the Tech Team.",
47-
},
48-
};
39+
40+
var eventsToUpdate = [];
41+
42+
if (
43+
data.data.recurring_update_type &&
44+
data.data.recurring_update_type != "none"
45+
) {
46+
const recurringEvent = await Calendar.getRecurringEventFromEvent(eventID);
47+
48+
if (!recurringEvent) {
49+
return {
50+
ok: false,
51+
errors: {
52+
root: "Attempted to update a recurring event but wasn't able to find the link.",
53+
},
54+
};
55+
}
56+
57+
var recurringEventsToUpdate: { event_id: number }[] = [];
58+
59+
const event = recurringEvent?.events.find(
60+
(ev) => ev.event_id == eventID,
61+
)!;
62+
63+
switch (data.data.recurring_update_type) {
64+
case "all":
65+
recurringEventsToUpdate = recurringEvent?.events;
66+
break;
67+
case "future":
68+
recurringEventsToUpdate = recurringEvent?.events.filter(
69+
(ev) => ev.start_date >= event.start_date,
70+
);
71+
break;
72+
73+
case "past":
74+
recurringEventsToUpdate = recurringEvent?.events.filter(
75+
(ev) => ev.start_date <= event.start_date,
76+
);
77+
break;
78+
4979
default:
50-
return {
51-
ok: false,
52-
errors: {
53-
root: "An unknown error occurred (" + result.reason + ")",
54-
},
55-
};
80+
break;
5681
}
82+
83+
eventsToUpdate.push(...recurringEventsToUpdate);
84+
}
85+
eventsToUpdate.push({ event_id: eventID });
86+
for (const { event_id: eventIDToUpdate } of eventsToUpdate) {
87+
let updateData: Calendar.EventUpdateFields;
88+
if (eventIDToUpdate != eventID) {
89+
const { recurring_update_type, start_date, end_date, ...rest } =
90+
data.data;
91+
updateData = rest;
92+
} else {
93+
const { recurring_update_type, ...rest } = data.data;
94+
updateData = rest;
95+
}
96+
const result = await Calendar.updateEvent(
97+
eventIDToUpdate,
98+
updateData,
99+
me.user_id,
100+
);
101+
if (!result.ok) {
102+
switch (result.reason) {
103+
case "kit_clash":
104+
return {
105+
ok: false,
106+
errors: {
107+
root: "The changed dates would result in a kit clash. Please contact the Tech Team.",
108+
},
109+
};
110+
default:
111+
return {
112+
ok: false,
113+
errors: {
114+
root: "An unknown error occurred (" + result.reason + ")",
115+
},
116+
};
117+
}
118+
}
119+
revalidatePath(`/calendar/${eventIDToUpdate}`);
57120
}
58-
revalidatePath(`/calendar/${eventID}`);
59121
revalidatePath("calendar");
60122
return { ok: true };
61123
},

app/(authenticated)/calendar/[eventID]/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const EditEventSchema = z
1111
is_private: z.boolean(),
1212
is_tentative: z.boolean(),
1313
host: z.coerce.number().optional(),
14+
recurring_update_type: z.enum(["none", "past", "future", "all"]).optional(),
1415
})
1516
.refine((val) => isBefore(val.start_date, val.end_date), {
1617
message: "End date must be after start date",

app/(authenticated)/calendar/new/actions.ts

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -113,21 +113,31 @@ export const createEvent = wrapServerAction(
113113
}
114114
}
115115

116-
const evt = await Calendar.createEvent(
117-
{
118-
name: payload.data.name,
119-
description: payload.data.description,
120-
event_type: payload.data.type,
121-
start_date: payload.data.startDate,
122-
end_date: payload.data.endDate,
123-
location: payload.data.location,
124-
is_private: payload.data.private,
125-
is_tentative: payload.data.tentative,
126-
host: payload.data.host,
127-
slack_channel_id: slack_channel_id,
128-
},
129-
me.user_id,
130-
);
116+
var evt: Calendar.EventObjectType | undefined;
117+
118+
const eventCreatePayload = {
119+
name: payload.data.name,
120+
description: payload.data.description,
121+
event_type: payload.data.type,
122+
start_date: payload.data.startDate,
123+
end_date: payload.data.endDate,
124+
location: payload.data.location,
125+
is_private: payload.data.private,
126+
is_tentative: payload.data.tentative,
127+
host: payload.data.host,
128+
slack_channel_id: slack_channel_id,
129+
};
130+
131+
if (payload.data.is_recurring && payload.data.recurring_dates.length > 0) {
132+
evt = await Calendar.createRecurringEvent(
133+
eventCreatePayload,
134+
me.user_id,
135+
payload.data.recurring_dates,
136+
);
137+
} else {
138+
evt = await Calendar.createEvent(eventCreatePayload, me.user_id);
139+
}
140+
131141
revalidatePath("calendar");
132142
return {
133143
ok: true,

app/(authenticated)/calendar/new/form.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"use client";
2+
23
import Form, { FormAction } from "@/components/Form";
34
import { schema } from "./schema";
45
import {
56
CheckBoxField,
67
ConditionalField,
78
DatePickerField,
8-
MemberSelect,
9+
MultiDatePickerField,
910
SearchedMemberSelect,
1011
SegmentedField,
1112
TextAreaField,
@@ -77,6 +78,15 @@ export function CreateEventForm(props: {
7778

7879
<br />
7980
<CheckBoxField name="tentative" label="Tentative Event" />
81+
<br />
82+
<CheckBoxField name="is_recurring" label="Recurring Event" />
83+
<ConditionalField
84+
referencedFieldName="is_recurring"
85+
condition={(t) => t === true}
86+
>
87+
<br />
88+
<MultiDatePickerField label="Recurring Dates" name="recurring_dates" />
89+
</ConditionalField>
8090
</Form>
8191
);
8292
}

app/(authenticated)/calendar/new/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export const schema = zfd
3030
.nullable()
3131
.transform((v) => (v === "" ? null : v))
3232
.default(null),
33+
is_recurring: z.boolean(),
34+
recurring_dates: z.array(z.coerce.date()),
3335
})
3436
.refine((val) => isBefore(val.startDate, val.endDate), {
3537
message: "End date must be after start date",

app/(authenticated)/user/me/UserPreferences.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ function PrefWrapper<K extends keyof ReqPrefs>(
4242
renderField: (_: {
4343
value: ReqPrefs[K];
4444
disabled: boolean;
45-
onChange: (nv: ReqPrefs[K]) => unknown;
45+
// onChange: (nv: ReqPrefs[K]) => unknown;
46+
onChange: (nv: string) => unknown;
4647
}) => ReactNode;
4748
},
4849
) {
@@ -54,9 +55,10 @@ function PrefWrapper<K extends keyof ReqPrefs>(
5455
return props.renderField({
5556
value: optimistic,
5657
disabled: isPending,
57-
onChange: (nv) => {
58-
setOptimistic(nv);
58+
onChange: (str_nv) => {
59+
const nv = str_nv as unknown as ReqPrefs[K];
5960
startTransition(async () => {
61+
setOptimistic(nv);
6062
await changePreference(props.field, nv);
6163
notifications.show({
6264
message: "Preferences saved!",

components/FormFields.tsx

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ import {
2323
Chip,
2424
Space,
2525
Stack,
26+
InputError,
27+
useMatches,
28+
Center,
2629
} from "@mantine/core";
27-
import { DateTimePicker } from "@mantine/dates";
30+
import { DatePicker, DateTimePicker } from "@mantine/dates";
2831
import { useMembers } from "@/components/FormFieldPreloadedData";
2932
import { getUserName } from "@/components/UserHelpers";
3033
import dayjs from "dayjs";
@@ -119,6 +122,78 @@ export function DatePickerField(props: {
119122
);
120123
}
121124

125+
export function MultiDatePickerField(props: {
126+
name: string;
127+
defaultValue?: Date[] | string[];
128+
label: string;
129+
required?: boolean;
130+
}) {
131+
const heh = useMatches({
132+
xs: false,
133+
sm: true,
134+
});
135+
136+
const controller = useController({
137+
name: props.name,
138+
defaultValue: props.defaultValue
139+
? (() => {
140+
if (
141+
Array.isArray(props.defaultValue) &&
142+
props.defaultValue.length > 0
143+
) {
144+
if (props.defaultValue.at(0) instanceof Date)
145+
return (props.defaultValue as Date[]).map((v: Date) => {
146+
const date = dayjs(v);
147+
return date
148+
.add(date.utcOffset(), "minutes")
149+
.format("YYYY-MM-DD");
150+
});
151+
return props.defaultValue;
152+
}
153+
})()
154+
: undefined,
155+
});
156+
const dv = useMemo(() => {
157+
if (!controller.field.value) {
158+
return [];
159+
}
160+
try {
161+
return controller.field.value.map((v: string) => {
162+
const date = dayjs(v);
163+
return date.add(date.utcOffset(), "minutes").toDate();
164+
});
165+
// return new Date(controller.field.value);
166+
} catch (e) {
167+
return [];
168+
}
169+
}, [controller.field.value]);
170+
return (
171+
<Stack>
172+
<InputLabel required={props.required}>{props.label}</InputLabel>
173+
<Center>
174+
<DatePicker
175+
type="multiple"
176+
value={dv}
177+
onChange={(v) =>
178+
controller.field.onChange(
179+
v?.map((v) => {
180+
const date = dayjs(v);
181+
return date
182+
.add(date.utcOffset(), "minutes")
183+
.set("hour", 0)
184+
.set("minute", 0)
185+
.format("YYYY-MM-DD");
186+
}),
187+
)
188+
}
189+
numberOfColumns={heh ? 2 : 1}
190+
/>
191+
</Center>
192+
<InputError>{controller.fieldState.error?.message as string}</InputError>
193+
</Stack>
194+
);
195+
}
196+
122197
export function CheckBoxField(props: { name: string; label?: string }) {
123198
const ctx = useFormContext();
124199
return <Checkbox {...ctx.register(props.name)} label={props.label} />;
@@ -234,7 +309,7 @@ export function SelectField<TObj extends {}>(props: {
234309
label?: string;
235310
renderOption: (obj: TObj) => string;
236311
getOptionValue: (obj: TObj) => string;
237-
filter: (obj: TObj, filter: string) => boolean;
312+
filter?: (obj: TObj, filter: string) => boolean;
238313
nullable?: boolean;
239314
}) {
240315
const ctx = useFormContext();

components/UserMenu.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Image from "next/image";
44
import {
55
Box,
66
Center,
7+
MantineColorScheme,
78
Menu,
89
SegmentedControl,
910
useMantineColorScheme,
@@ -34,7 +35,9 @@ export function UserMenu({ userAvatar }: { userAvatar: string }) {
3435
<Menu.Label>Theme</Menu.Label>
3536
<SegmentedControl
3637
value={colorScheme}
37-
onChange={setColorScheme}
38+
onChange={(str) =>
39+
setColorScheme(str as unknown as MantineColorScheme)
40+
}
3841
className="min-w-full"
3942
data={[
4043
{

0 commit comments

Comments
 (0)