implemented modifing of tasks of an event

This commit is contained in:
z1glr
2025-01-13 22:22:10 +00:00
parent 0685283007
commit a3c6fd685d
12 changed files with 468 additions and 288 deletions

View File

@@ -6,14 +6,14 @@ import (
type assignments map[string]*string type assignments map[string]*string
type assignemntDB struct { type eventAssignmentDB struct {
TaskName string `db:"taskName"` TaskName string `db:"taskName"`
UserName *string `db:"userName"` UserName *string `db:"userName"`
} }
func Event(eventID int) (assignments, error) { func Event(eventID int) (assignments, error) {
// get the assignments from the database // get the assignments from the database
var assignmentRows []assignemntDB var assignmentRows []eventAssignmentDB
if err := db.DB.Select(&assignmentRows, "SELECT USERS.name AS userName, TASKS.text AS taskName FROM USER_ASSIGNMENTS LEFT JOIN USERS ON USER_ASSIGNMENTS.userName = USERS.name LEFT JOIN TASKS ON USER_ASSIGNMENTS.taskID = TASKS.id WHERE USER_ASSIGNMENTS.eventID = ?", eventID); err != nil { if err := db.DB.Select(&assignmentRows, "SELECT USERS.name AS userName, TASKS.text AS taskName FROM USER_ASSIGNMENTS LEFT JOIN USERS ON USER_ASSIGNMENTS.userName = USERS.name LEFT JOIN TASKS ON USER_ASSIGNMENTS.taskID = TASKS.id WHERE USER_ASSIGNMENTS.eventID = ?", eventID); err != nil {
return nil, err return nil, err

View File

@@ -1,6 +1,8 @@
package events package events
import ( import (
"slices"
"github.com/johannesbuehl/golunteer/backend/pkg/db" "github.com/johannesbuehl/golunteer/backend/pkg/db"
"github.com/johannesbuehl/golunteer/backend/pkg/db/assignments" "github.com/johannesbuehl/golunteer/backend/pkg/db/assignments"
"github.com/johannesbuehl/golunteer/backend/pkg/db/availabilities" "github.com/johannesbuehl/golunteer/backend/pkg/db/availabilities"
@@ -18,7 +20,7 @@ type EventWithAvailabilities struct {
} }
type eventDataDB struct { type eventDataDB struct {
Id int `db:"id" json:"id"` ID int `db:"id" json:"id" validate:"required"`
Date string `db:"date" json:"date" validate:"required"` Date string `db:"date" json:"date" validate:"required"`
Description string `db:"description" json:"description"` Description string `db:"description" json:"description"`
} }
@@ -26,7 +28,7 @@ type eventDataDB struct {
// transform the database-entry to an Event // transform the database-entry to an Event
func (e eventDataDB) Event() (EventWithAssignment, error) { func (e eventDataDB) Event() (EventWithAssignment, error) {
// get the assignments associated with the event // get the assignments associated with the event
if assignemnts, err := assignments.Event(e.Id); err != nil { if assignemnts, err := assignments.Event(e.ID); err != nil {
return EventWithAssignment{}, err return EventWithAssignment{}, err
} else { } else {
return EventWithAssignment{ return EventWithAssignment{
@@ -42,7 +44,7 @@ func (e eventDataDB) EventWithAvailabilities() (EventWithAvailabilities, error)
return EventWithAvailabilities{}, err return EventWithAvailabilities{}, err
// get the availabilities // get the availabilities
} else if availabilities, err := availabilities.Event(e.Id); err != nil { } else if availabilities, err := availabilities.Event(e.ID); err != nil {
return EventWithAvailabilities{}, err return EventWithAvailabilities{}, err
} else { } else {
return EventWithAvailabilities{ return EventWithAvailabilities{
@@ -53,8 +55,9 @@ func (e eventDataDB) EventWithAvailabilities() (EventWithAvailabilities, error)
} }
type EventCreate struct { type EventCreate struct {
eventDataDB Date string `db:"date" json:"date" validate:"required"`
Tasks []int `json:"tasks" validate:"required,min=1"` Description string `db:"description" json:"description"`
Tasks []int `json:"tasks" validate:"required,min=1"`
} }
func Create(event EventCreate) error { func Create(event EventCreate) error {
@@ -91,6 +94,69 @@ func Create(event EventCreate) error {
return nil return nil
} }
type EventPatch struct {
eventDataDB
Tasks []int `json:"tasks" validate:"required,min=1"`
}
func Update(event EventPatch) error {
// update the event itself
if _, err := db.DB.NamedExec("UPDATE EVENTS SET description = :description, date = :date WHERE id = :id", event); err != nil {
return err
// get the tasks currently assigned to the event
} else {
type TaskID struct {
ID int `db:"taskID"`
}
var taskRows []TaskID
if err := db.DB.Select(&taskRows, "SELECT taskID FROM USER_ASSIGNMENTS WHERE eventID = ?", event.ID); err != nil {
return err
} else {
type Task struct {
TaskID
EventID int `db:"eventID"`
}
// extract the rows that need to be deleted
deleteRows := []Task{}
for _, row := range taskRows {
if !slices.Contains(event.Tasks, row.ID) {
deleteRows = append(deleteRows, Task{TaskID: row, EventID: event.ID})
}
}
// extract the rows that need to be created
createRows := []Task{}
for _, id := range event.Tasks {
if !slices.Contains(taskRows, TaskID{ID: id}) {
createRows = append(createRows, Task{TaskID: TaskID{ID: id}, EventID: event.ID})
}
}
// delete the no longer needed rows
if len(deleteRows) > 0 {
if _, err := db.DB.NamedExec("DELETE FROM USER_ASSIGNMENTS WHERE eventID = :eventID AND taskID = :taskID", deleteRows); err != nil {
return err
}
}
// create the new tasks
if len(createRows) > 0 {
if _, err := db.DB.NamedExec("INSERT INTO USER_ASSIGNMENTS (eventID, taskID) VALUES (:eventID, :taskID)", createRows); err != nil {
return err
}
}
return nil
}
}
}
func All() ([]eventDataDB, error) { func All() ([]eventDataDB, error) {
var dbRows []eventDataDB var dbRows []eventDataDB
@@ -110,7 +176,7 @@ func WithAssignments() ([]EventWithAssignment, error) {
for ii, e := range eventsDB { for ii, e := range eventsDB {
if ev, err := e.Event(); err != nil { if ev, err := e.Event(); err != nil {
logger.Logger.Error().Msgf("can't get assignments for event with id = %d: %v", e.Id, err) logger.Logger.Error().Msgf("can't get assignments for event with id = %d: %v", e.ID, err)
} else { } else {
events[ii] = ev events[ii] = ev
} }
@@ -129,7 +195,7 @@ func WithAvailabilities() ([]EventWithAvailabilities, error) {
for ii, e := range eventsDB { for ii, e := range eventsDB {
if ev, err := e.EventWithAvailabilities(); err != nil { if ev, err := e.EventWithAvailabilities(); err != nil {
logger.Logger.Error().Msgf("can't get availabilities for event with id = %d: %v", e.Id, err) logger.Logger.Error().Msgf("can't get availabilities for event with id = %d: %v", e.ID, err)
} else { } else {
events[ii] = ev events[ii] = ev
} }

View File

@@ -9,7 +9,7 @@ import (
) )
type tasksDB struct { type tasksDB struct {
Id int `db:"id"` ID int `db:"id"`
Text string `db:"text"` Text string `db:"text"`
Disabled bool `db:"disabled"` Disabled bool `db:"disabled"`
} }
@@ -40,7 +40,7 @@ func refresh() {
tasks := map[int]Task{} tasks := map[int]Task{}
for _, a := range tasksRaw { for _, a := range tasksRaw {
tasks[a.Id] = Task{ tasks[a.ID] = Task{
Text: a.Text, Text: a.Text,
Disabled: a.Disabled, Disabled: a.Disabled,
} }

View File

@@ -8,40 +8,68 @@ import (
func postEvent(args HandlerArgs) responseMessage { func postEvent(args HandlerArgs) responseMessage {
response := responseMessage{} response := responseMessage{}
// write the event // check admin
var body events.EventCreate if !args.User.Admin {
response.Status = fiber.StatusForbidden
// try to parse the body
if err := args.C.BodyParser(&body); err != nil {
response.Status = fiber.StatusBadRequest
logger.Log().Msgf("can't parse body: %v", err)
// validate the parsed body
} else if err := validate.Struct(body); err != nil {
response.Status = fiber.StatusBadRequest
logger.Log().Msgf("invalid body: %v", err)
// create the event
} else if err := events.Create(body); err != nil {
response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("can't create event: %v", err)
} else { } else {
// respond with the new events
if events, err := events.WithAssignments(); err != nil { // write the event
var body events.EventCreate
// try to parse the body
if err := args.C.BodyParser(&body); err != nil {
response.Status = fiber.StatusBadRequest
logger.Log().Msgf("can't parse body: %v", err)
// validate the parsed body
} else if err := validate.Struct(body); err != nil {
response.Status = fiber.StatusBadRequest
logger.Log().Msgf("invalid body: %v", err)
// create the event
} else if err := events.Create(body); err != nil {
response.Status = fiber.StatusInternalServerError response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("can't retrieve events: %v", err) logger.Error().Msgf("can't create event: %v", err)
} else {
response.Data = events
} }
} }
return response return response
} }
func patchEvent(args HandlerArgs) responseMessage {
response := responseMessage{}
// check admin
if !args.User.Admin {
response.Status = fiber.StatusForbidden
} else {
// parse the body
var body events.EventPatch
if err := args.C.BodyParser(&body); err != nil {
response.Status = fiber.StatusBadRequest
logger.Log().Msgf("can't parse body: %v", err)
// validate the body
} else if err := validate.Struct(body); err != nil {
response.Status = fiber.StatusBadRequest
logger.Log().Msgf("ivnalid body: %v", err)
// update the event
} else if err := events.Update(body); err != nil {
response.Status = fiber.StatusInternalServerError
logger.Error().Msgf("updating the event failed: %v", err)
}
}
return response
}
func getEventsAssignments(args HandlerArgs) responseMessage { func getEventsAssignments(args HandlerArgs) responseMessage {
response := responseMessage{} response := responseMessage{}

View File

@@ -69,6 +69,7 @@ func init() {
"GET": app.Get, "GET": app.Get,
"POST": app.Post, "POST": app.Post,
"PATCH": app.Patch, "PATCH": app.Patch,
"PUT": app.Put,
"DELETE": app.Delete, "DELETE": app.Delete,
} }
@@ -86,8 +87,11 @@ func init() {
"users": postUser, "users": postUser,
}, },
"PATCH": { "PATCH": {
"users/password": patchPassword, "users": patchUser,
"users": patchUser, "events": patchEvent,
},
"PUT": {
"users/password": putPassword,
}, },
"DELETE": { "DELETE": {
"event": deleteEvent, "event": deleteEvent,

View File

@@ -54,7 +54,7 @@ func postUser(args HandlerArgs) responseMessage {
return response return response
} }
func patchPassword(args HandlerArgs) responseMessage { func putPassword(args HandlerArgs) responseMessage {
response := responseMessage{} response := responseMessage{}
// parse the body // parse the body
var body users.UserChangePassword var body users.UserChangePassword

View File

@@ -19,7 +19,7 @@ export default function Account() {
async function changePassword(e: FormEvent<HTMLFormElement>) { async function changePassword(e: FormEvent<HTMLFormElement>) {
const data = Object.fromEntries(new FormData(e.currentTarget)); const data = Object.fromEntries(new FormData(e.currentTarget));
const result = await apiCall("PATCH", "users/password", undefined, data); const result = await apiCall("PUT", "users/password", undefined, data);
if (result.ok) { if (result.ok) {
setPassword(""); setPassword("");
@@ -28,9 +28,9 @@ export default function Account() {
return ( return (
<> <>
<h2 className="text-center text-4xl">Account</h2> <h2 className="mb-4 text-center text-4xl">Account</h2>
<div> <div>
<Card className="max-w-md"> <Card className="mx-auto max-w-md bg-accent-5" shadow="none">
<CardHeader> <CardHeader>
<h3 className="text-2xl">Change Password</h3> <h3 className="text-2xl">Change Password</h3>
</CardHeader> </CardHeader>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import AddEvent from "@/components/Event/AddEvent"; import AddEvent from "@/components/Event/AddEvent";
import EditEvent, { EventSubmitData } from "@/components/Event/EditEvent";
import LocalDate from "@/components/LocalDate"; import LocalDate from "@/components/LocalDate";
import { apiCall, getTasks } from "@/lib"; import { apiCall, getTasks } from "@/lib";
import { EventData } from "@/Zustand"; import { EventData } from "@/Zustand";
@@ -10,6 +11,7 @@ import {
Copy, Copy,
Edit, Edit,
NotAvailable, NotAvailable,
Renew,
TrashCan, TrashCan,
} from "@carbon/icons-react"; } from "@carbon/icons-react";
import { import {
@@ -59,6 +61,10 @@ function availability2Color(availability?: Availability) {
} }
export default function AdminPanel() { export default function AdminPanel() {
const [showAddEvent, setShowAddEvent] = useState(false);
const [editEvent, setEditEvent] = useState<EventData | undefined>();
const [deleteEvent, setDeleteEvent] = useState<EventData | undefined>();
// get the available tasks and craft them into the headers // get the available tasks and craft them into the headers
const headers = useAsyncList({ const headers = useAsyncList({
async load() { async load() {
@@ -115,14 +121,14 @@ export default function AdminPanel() {
}); });
// send a delete request to the backend and close the popup on success // send a delete request to the backend and close the popup on success
async function deleteEvent(eventId: number) { async function sendDeleteEvent() {
const result = await apiCall("DELETE", "event", { id: eventId }); if (deleteEvent !== undefined) {
const result = await apiCall("DELETE", "event", { id: deleteEvent.id });
if (result.ok) { if (result.ok) {
// store the received events // store the received events
events.reload(); events.reload();
}
setShowDeleteConfirm(false);
} }
} }
@@ -141,12 +147,20 @@ export default function AdminPanel() {
</LocalDate> </LocalDate>
); );
case "description": case "description":
return <span className="whitespace-pre-wrap italic">{event[key]}</span>; return (
<div className="max-w-32 whitespace-pre-wrap italic">
{event[key]}
</div>
);
case "actions": case "actions":
return ( return (
<div className="flex justify-end"> <div className="flex justify-end">
<ButtonGroup isIconOnly variant="light" size="sm"> <ButtonGroup isIconOnly variant="light" size="sm">
<Button> <Button
onPress={() => {
setEditEvent(event);
}}
>
<Tooltip content="Edit event"> <Tooltip content="Edit event">
<Edit /> <Edit />
</Tooltip> </Tooltip>
@@ -159,8 +173,7 @@ export default function AdminPanel() {
<Button <Button
color="danger" color="danger"
onPress={() => { onPress={() => {
setActiveEvent(event); setDeleteEvent(event);
setShowDeleteConfirm(true);
}} }}
> >
<Tooltip content="Delete event"> <Tooltip content="Delete event">
@@ -203,33 +216,6 @@ export default function AdminPanel() {
)} )}
</DropdownMenu> </DropdownMenu>
</Dropdown> </Dropdown>
// <Select
// aria-label={`User selection for task ${key} and event ${event.date}`}
// variant="underlined"
// fullWidth
// selectedKeys={new Set([event.tasks[key as string] ?? ""])}
// classNames={{
// popoverContent: "w-fit",
// value: "mr-6",
// label: "mr-6",
// }}
// className="[&_*]:overflow-visible"
// >
// {Object.entries(event.availabilities).map(
// ([volunteer, availability]) => (
// <SelectItem
// key={volunteer}
// // color={availability2Color(availability)}
// className={[
// // "text-" + availability2Color(availability),
// // availability2Tailwind(availability),
// ].join(" ")}
// >
// {volunteer} ({availability})
// </SelectItem>
// ),
// )}
// </Select>
); );
} else { } else {
return <NotAvailable className="mx-auto text-foreground-300" />; return <NotAvailable className="mx-auto text-foreground-300" />;
@@ -237,9 +223,16 @@ export default function AdminPanel() {
} }
} }
const [showAddEvent, setShowAddEvent] = useState(false); async function updateEvent(data: EventSubmitData) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const result = await apiCall("PATCH", "events", undefined, data);
const [activeEvent, setActiveEvent] = useState<EventData | undefined>();
if (result.ok) {
// clear the selected-event to hide the modal
setEditEvent(undefined);
events.reload();
}
}
const topContent = ( const topContent = (
<div> <div>
@@ -271,7 +264,7 @@ export default function AdminPanel() {
th: "font-subheadline text-xl text-accent-1 bg-transparent ", th: "font-subheadline text-xl text-accent-1 bg-transparent ",
thead: "[&>tr]:first:!shadow-border", thead: "[&>tr]:first:!shadow-border",
}} }}
className="w-fit" className="w-fit max-w-full"
> >
<TableHeader columns={headers.items}> <TableHeader columns={headers.items}>
{(task) => ( {(task) => (
@@ -301,45 +294,62 @@ export default function AdminPanel() {
onSuccess={() => events.reload()} onSuccess={() => events.reload()}
/> />
{activeEvent !== undefined ? ( <EditEvent
<Modal isOpen={editEvent !== undefined}
isOpen={showDeleteConfirm} onOpenChange={(isOpen) => (!isOpen ? setEditEvent(undefined) : null)}
onOpenChange={setShowDeleteConfirm} onSubmit={updateEvent}
shadow={"none" as "sm"} initialState={editEvent}
backdrop="blur" footer={
className="bg-accent-5" <Button
> color="primary"
<ModalContent> radius="full"
<ModalHeader> startContent={<Renew />}
<h1 className="text-2xl">Confirm event deletion</h1> type="submit"
</ModalHeader> >
<ModalBody> Update
The event{" "} </Button>
<span className="font-numbers text-accent-1"> }
<LocalDate options={{ dateStyle: "long", timeStyle: "short" }}> >
{activeEvent.date} Edit Event
</LocalDate> </EditEvent>
</span>{" "}
will be deleted. <Modal
</ModalBody> isOpen={!!deleteEvent}
<ModalFooter> onOpenChange={(isOpen) => (!isOpen ? setDeleteEvent(undefined) : null)}
<Button shadow={"none" as "sm"}
startContent={<TrashCan />} backdrop="blur"
color="danger" className="bg-accent-5"
onPress={() => deleteEvent(activeEvent.id)} >
> <ModalContent>
Delete event <ModalHeader>
</Button> <h1 className="text-2xl">Confirm event deletion</h1>
<Button </ModalHeader>
variant="bordered" <ModalBody>
onPress={() => setShowDeleteConfirm(false)} The event{" "}
> <span className="font-numbers text-accent-1">
Cancel <LocalDate options={{ dateStyle: "long", timeStyle: "short" }}>
</Button> {deleteEvent?.date}
</ModalFooter> </LocalDate>
</ModalContent> </span>{" "}
</Modal> will be deleted.
) : null} </ModalBody>
<ModalFooter>
<Button
variant="bordered"
onPress={() => setDeleteEvent(undefined)}
>
Cancel
</Button>
<Button
startContent={<TrashCan />}
color="danger"
onPress={() => sendDeleteEvent()}
>
Delete event
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div> </div>
); );
} }

View File

@@ -1,33 +1,7 @@
import { useEffect, useReducer, useState } from "react"; import { Button } from "@nextui-org/react";
import { Add } from "@carbon/icons-react"; import EditEvent, { EventSubmitData } from "./EditEvent";
import zustand from "../../Zustand"; import { apiCall } from "@/lib";
import { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date"; import { AddLarge } from "@carbon/icons-react";
import {
Button,
Checkbox,
CheckboxGroup,
DatePicker,
Form,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Textarea,
} from "@nextui-org/react";
import { apiCall, getTasks, Task } from "@/lib";
interface state {
date: ZonedDateTime;
description: string;
tasks: string[];
}
interface dispatchAction {
action: "set" | "reset";
value?: Partial<state>;
}
export default function AddEvent(props: { export default function AddEvent(props: {
className?: string; className?: string;
@@ -35,138 +9,32 @@ export default function AddEvent(props: {
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
onSuccess?: () => void; onSuccess?: () => void;
}) { }) {
// initial state for the inputs async function addEvent(data: EventSubmitData) {
const initialState: state = {
date: now(getLocalTimeZone()),
description: "",
tasks: [],
};
// handle state dispatches
function reducer(state: state, action: dispatchAction): state {
if (action.action === "reset") {
return initialState;
} else {
return { ...state, ...action.value };
}
}
const [state, dispatchState] = useReducer(reducer, initialState);
const [tasks, setTasks] = useState<Record<number, Task>>({});
// get the available tasks
useEffect(() => {
(async () => {
setTasks(await getTasks());
})();
}, []);
// sends the addEvent request to the backend
async function addEvent() {
const data = {
...state,
tasks: state.tasks.map((task) => parseInt(task)),
date: state.date.toAbsoluteString().slice(0, -1),
};
const result = await apiCall("POST", "events", undefined, data); const result = await apiCall("POST", "events", undefined, data);
if (result.ok) { if (result.ok) {
zustand.getState().setEvents(await result.json());
props.onOpenChange(false); props.onOpenChange(false);
props.onSuccess?.(); props.onSuccess?.();
} }
} }
// reset the state when the modal gets closed
useEffect(() => {
if (!props.isOpen) {
dispatchState({ action: "reset" });
}
}, [props.isOpen]);
return ( return (
<Modal <EditEvent
isOpen={props.isOpen} {...props}
shadow={"none" as "sm"} // somehow "none" isn't allowed onSubmit={(data) => void addEvent(data)}
onOpenChange={props.onOpenChange} footer={
backdrop="blur" <Button
classNames={{ color="primary"
base: "bg-accent-5 ", radius="full"
}} startContent={<AddLarge />}
type="submit"
>
Add
</Button>
}
> >
<Form Add Event
validationBehavior="native" </EditEvent>
onSubmit={(e) => {
e.preventDefault();
void addEvent();
}}
>
<ModalContent>
<ModalHeader>
<h1 className="text-center text-2xl">Add Event</h1>
</ModalHeader>
<ModalBody>
<DatePicker
isRequired
label="Event date"
name="date"
variant="bordered"
hideTimeZone
granularity="minute"
value={state.date}
onChange={(dt) =>
!!dt
? dispatchState({ action: "set", value: { date: dt } })
: null
}
/>
<Textarea
variant="bordered"
placeholder="Description"
name="description"
value={state.description}
onValueChange={(s) =>
dispatchState({ action: "set", value: { description: s } })
}
/>
<CheckboxGroup
value={state.tasks}
name="tasks"
onValueChange={(s) =>
dispatchState({ action: "set", value: { tasks: s } })
}
validate={(value) =>
value.length > 0 ? true : "Atleast one task must be selected"
}
>
{tasks !== undefined ? (
Object.entries(tasks)
.filter(([, task]) => !task.disabled)
.map(([id, task]) => (
<div key={id}>
<Checkbox value={id}>{task.text}</Checkbox>
</div>
))
) : (
<Spinner label="Loading" />
)}
</CheckboxGroup>
</ModalBody>
<ModalFooter>
<Button
color="primary"
radius="full"
startContent={<Add size={32} />}
type="submit"
>
Add
</Button>
</ModalFooter>
</ModalContent>
</Form>
</Modal>
); );
} }

View File

@@ -0,0 +1,202 @@
import React, { useEffect, useReducer, useState } from "react";
import {
getLocalTimeZone,
now,
parseDateTime,
toZoned,
ZonedDateTime,
} from "@internationalized/date";
import {
Checkbox,
CheckboxGroup,
DatePicker,
Form,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Spinner,
Textarea,
} from "@nextui-org/react";
import { getTasks, Task } from "@/lib";
import { EventData } from "@/Zustand";
export interface EventSubmitData {
id: number;
date: string;
description: string;
tasks: number[];
}
interface State {
date: ZonedDateTime;
description: string;
tasks: string[];
}
export default function EditEvent(props: {
children: React.ReactNode;
footer: React.ReactNode;
initialState?: EventData;
className?: string;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onSubmit?: (data: EventSubmitData) => void;
}) {
const [reverseTasksMap, setReverseTasksMap] = useState<
Record<string, string>
>({});
const [state, dispatchState] = useReducer(
dispatchStateHandler,
dispatchStateHandler({} as State, { action: "reset" }),
);
const [tasksMap, setTasksMap] = useState<Record<number, Task>>({});
// initialize the state
function initialState(): State {
if (props.initialState !== undefined && reverseTasksMap !== undefined) {
const { description, date, tasks } = props.initialState;
return {
description,
date: toZoned(parseDateTime(date), getLocalTimeZone()),
tasks: Object.keys(tasks).map((task) => reverseTasksMap[task]),
};
} else {
return {
date: now(getLocalTimeZone()),
description: "",
tasks: [],
};
}
}
// update the state if the initialState-prop changes
useEffect(() => {
if (props.initialState !== undefined) {
dispatchState({ action: "reset" });
}
}, [props.initialState]);
// handle dispatch-calls
function dispatchStateHandler(
state: State,
args: { action: "patch" | "reset"; value?: Partial<State> },
): State {
if (args.action === "reset") {
return initialState();
} else {
return {
...state,
...args.value,
};
}
}
// shortcut for patching the state
function patchState(values: Partial<State>) {
dispatchState({ action: "patch", value: values });
}
// handle state dispatches
// get the available tasks and initialize the state with them
useEffect(() => {
(async () => {
const tasks = await getTasks();
setTasksMap(tasks);
setReverseTasksMap(
Object.fromEntries(
Object.entries(tasks).map(([id, task]) => {
return [task.text, id];
}),
),
);
})();
}, []);
// sends the patch-event-request to the backend
function patchEvent() {
if (props.initialState !== undefined) {
const { description, tasks, date } = state;
const data: EventSubmitData = {
id: props.initialState?.id,
description,
tasks: tasks.map((task) => parseInt(task)),
date: date.toAbsoluteString().slice(0, -1),
};
props.onSubmit?.(data);
}
}
return (
<Modal
isOpen={props.isOpen}
shadow={"none" as "sm"} // somehow "none" isn't allowed
onOpenChange={props.onOpenChange}
backdrop="blur"
classNames={{
base: "bg-accent-5 ",
}}
>
<Form
validationBehavior="native"
onSubmit={(e) => {
e.preventDefault();
void patchEvent();
}}
>
<ModalContent>
<ModalHeader>
<h1 className="text-center text-2xl">{props.children}</h1>
</ModalHeader>
<ModalBody>
<DatePicker
isRequired
label="Event date"
name="date"
variant="bordered"
hideTimeZone
granularity="minute"
value={state.date}
onChange={(date) => (!!date ? patchState({ date }) : null)}
/>
<Textarea
variant="bordered"
placeholder="Description"
name="description"
value={state.description}
onValueChange={(description) => patchState({ description })}
/>
<CheckboxGroup
name="tasks"
value={state.tasks}
onValueChange={(tasks) => patchState({ tasks })}
validate={(value) =>
value.length > 0 ? true : "Atleast one task must be selected"
}
>
{tasksMap !== undefined ? (
Object.entries(tasksMap)
.filter(([, task]) => !task.disabled)
.map(([id, task]) => (
<div key={id}>
<Checkbox value={id}>{task.text}</Checkbox>
</div>
))
) : (
<Spinner label="Loading" />
)}
</CheckboxGroup>
</ModalBody>
<ModalFooter>{props.footer}</ModalFooter>
</ModalContent>
</Form>
</Modal>
);
}

View File

@@ -5,7 +5,7 @@ import { getLocalTimeZone, parseDateTime } from "@internationalized/date";
import { useLocale } from "@react-aria/i18n"; import { useLocale } from "@react-aria/i18n";
export default function LocalDate(props: { export default function LocalDate(props: {
children: string; children?: string;
className?: string; className?: string;
options: Intl.DateTimeFormatOptions; options: Intl.DateTimeFormatOptions;
}) { }) {
@@ -13,9 +13,11 @@ export default function LocalDate(props: {
return ( return (
<span className={props.className}> <span className={props.className}>
{formatter.format( {props.children !== undefined
parseDateTime(props.children).toDate(getLocalTimeZone()), ? formatter.format(
)} parseDateTime(props.children).toDate(getLocalTimeZone()),
)
: ""}
</span> </span>
); );
} }

View File

@@ -9,32 +9,32 @@ export type APICallResult<T extends object> = Response & {
export async function apiCall<K extends object>( export async function apiCall<K extends object>(
method: "GET", method: "GET",
api: string, api: string,
params?: QueryParams, query?: QueryParams,
): Promise<APICallResult<K>>; ): Promise<APICallResult<K>>;
export async function apiCall<K extends object>( export async function apiCall<K extends object>(
method: "POST" | "PATCH", method: "POST" | "PATCH" | "PUT",
api: string, api: string,
params?: QueryParams, query?: QueryParams,
body?: object, body?: object,
): Promise<APICallResult<K>>; ): Promise<APICallResult<K>>;
export async function apiCall<K extends object>( export async function apiCall<K extends object>(
method: "DELETE", method: "DELETE",
api: string, api: string,
params?: QueryParams, query?: QueryParams,
body?: object, body?: object,
): Promise<APICallResult<K>>; ): Promise<APICallResult<K>>;
export async function apiCall<K extends object>( export async function apiCall<K extends object>(
method: "GET" | "POST" | "PATCH" | "DELETE", method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
api: string, api: string,
params?: QueryParams, query?: QueryParams,
body?: object, body?: object,
): Promise<APICallResult<K>> { ): Promise<APICallResult<K>> {
let url = window.origin + "/api/" + api; let url = window.origin + "/api/" + api;
if (params) { if (query) {
const urlsearchparams = new URLSearchParams( const urlsearchparams = new URLSearchParams(
Object.fromEntries( Object.fromEntries(
Object.entries(params).map(([key, value]): [string, string] => { Object.entries(query).map(([key, value]): [string, string] => {
if (typeof value !== "string") { if (typeof value !== "string") {
return [key, value.toString()]; return [key, value.toString()];
} else { } else {
@@ -109,6 +109,6 @@ export async function getTasks(): Promise<Record<number, Task>> {
return tasks; return tasks;
} else { } else {
return []; return {};
} }
} }