implemented modifing of tasks of an event
This commit is contained in:
@@ -6,14 +6,14 @@ import (
|
||||
|
||||
type assignments map[string]*string
|
||||
|
||||
type assignemntDB struct {
|
||||
type eventAssignmentDB struct {
|
||||
TaskName string `db:"taskName"`
|
||||
UserName *string `db:"userName"`
|
||||
}
|
||||
|
||||
func Event(eventID int) (assignments, error) {
|
||||
// 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 {
|
||||
return nil, err
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/johannesbuehl/golunteer/backend/pkg/db"
|
||||
"github.com/johannesbuehl/golunteer/backend/pkg/db/assignments"
|
||||
"github.com/johannesbuehl/golunteer/backend/pkg/db/availabilities"
|
||||
@@ -18,7 +20,7 @@ type EventWithAvailabilities 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"`
|
||||
Description string `db:"description" json:"description"`
|
||||
}
|
||||
@@ -26,7 +28,7 @@ type eventDataDB struct {
|
||||
// transform the database-entry to an Event
|
||||
func (e eventDataDB) Event() (EventWithAssignment, error) {
|
||||
// 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
|
||||
} else {
|
||||
return EventWithAssignment{
|
||||
@@ -42,7 +44,7 @@ func (e eventDataDB) EventWithAvailabilities() (EventWithAvailabilities, error)
|
||||
return EventWithAvailabilities{}, err
|
||||
|
||||
// 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
|
||||
} else {
|
||||
return EventWithAvailabilities{
|
||||
@@ -53,7 +55,8 @@ func (e eventDataDB) EventWithAvailabilities() (EventWithAvailabilities, error)
|
||||
}
|
||||
|
||||
type EventCreate struct {
|
||||
eventDataDB
|
||||
Date string `db:"date" json:"date" validate:"required"`
|
||||
Description string `db:"description" json:"description"`
|
||||
Tasks []int `json:"tasks" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
@@ -91,6 +94,69 @@ func Create(event EventCreate) error {
|
||||
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) {
|
||||
var dbRows []eventDataDB
|
||||
|
||||
@@ -110,7 +176,7 @@ func WithAssignments() ([]EventWithAssignment, error) {
|
||||
|
||||
for ii, e := range eventsDB {
|
||||
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 {
|
||||
events[ii] = ev
|
||||
}
|
||||
@@ -129,7 +195,7 @@ func WithAvailabilities() ([]EventWithAvailabilities, error) {
|
||||
|
||||
for ii, e := range eventsDB {
|
||||
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 {
|
||||
events[ii] = ev
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type tasksDB struct {
|
||||
Id int `db:"id"`
|
||||
ID int `db:"id"`
|
||||
Text string `db:"text"`
|
||||
Disabled bool `db:"disabled"`
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func refresh() {
|
||||
tasks := map[int]Task{}
|
||||
|
||||
for _, a := range tasksRaw {
|
||||
tasks[a.Id] = Task{
|
||||
tasks[a.ID] = Task{
|
||||
Text: a.Text,
|
||||
Disabled: a.Disabled,
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ import (
|
||||
func postEvent(args HandlerArgs) responseMessage {
|
||||
response := responseMessage{}
|
||||
|
||||
// check admin
|
||||
if !args.User.Admin {
|
||||
response.Status = fiber.StatusForbidden
|
||||
} else {
|
||||
|
||||
// write the event
|
||||
var body events.EventCreate
|
||||
|
||||
@@ -28,17 +33,40 @@ func postEvent(args HandlerArgs) responseMessage {
|
||||
response.Status = fiber.StatusInternalServerError
|
||||
|
||||
logger.Error().Msgf("can't create event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func patchEvent(args HandlerArgs) responseMessage {
|
||||
response := responseMessage{}
|
||||
|
||||
// check admin
|
||||
if !args.User.Admin {
|
||||
response.Status = fiber.StatusForbidden
|
||||
} else {
|
||||
// respond with the new events
|
||||
if events, err := events.WithAssignments(); err != nil {
|
||||
// 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("can't retrieve events: %v", err)
|
||||
} else {
|
||||
response.Data = events
|
||||
logger.Error().Msgf("updating the event failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ func init() {
|
||||
"GET": app.Get,
|
||||
"POST": app.Post,
|
||||
"PATCH": app.Patch,
|
||||
"PUT": app.Put,
|
||||
"DELETE": app.Delete,
|
||||
}
|
||||
|
||||
@@ -86,8 +87,11 @@ func init() {
|
||||
"users": postUser,
|
||||
},
|
||||
"PATCH": {
|
||||
"users/password": patchPassword,
|
||||
"users": patchUser,
|
||||
"events": patchEvent,
|
||||
},
|
||||
"PUT": {
|
||||
"users/password": putPassword,
|
||||
},
|
||||
"DELETE": {
|
||||
"event": deleteEvent,
|
||||
|
||||
@@ -54,7 +54,7 @@ func postUser(args HandlerArgs) responseMessage {
|
||||
return response
|
||||
}
|
||||
|
||||
func patchPassword(args HandlerArgs) responseMessage {
|
||||
func putPassword(args HandlerArgs) responseMessage {
|
||||
response := responseMessage{}
|
||||
// parse the body
|
||||
var body users.UserChangePassword
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Account() {
|
||||
async function changePassword(e: FormEvent<HTMLFormElement>) {
|
||||
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) {
|
||||
setPassword("");
|
||||
@@ -28,9 +28,9 @@ export default function Account() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-center text-4xl">Account</h2>
|
||||
<h2 className="mb-4 text-center text-4xl">Account</h2>
|
||||
<div>
|
||||
<Card className="max-w-md">
|
||||
<Card className="mx-auto max-w-md bg-accent-5" shadow="none">
|
||||
<CardHeader>
|
||||
<h3 className="text-2xl">Change Password</h3>
|
||||
</CardHeader>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import AddEvent from "@/components/Event/AddEvent";
|
||||
import EditEvent, { EventSubmitData } from "@/components/Event/EditEvent";
|
||||
import LocalDate from "@/components/LocalDate";
|
||||
import { apiCall, getTasks } from "@/lib";
|
||||
import { EventData } from "@/Zustand";
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
Copy,
|
||||
Edit,
|
||||
NotAvailable,
|
||||
Renew,
|
||||
TrashCan,
|
||||
} from "@carbon/icons-react";
|
||||
import {
|
||||
@@ -59,6 +61,10 @@ function availability2Color(availability?: Availability) {
|
||||
}
|
||||
|
||||
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
|
||||
const headers = useAsyncList({
|
||||
async load() {
|
||||
@@ -115,14 +121,14 @@ export default function AdminPanel() {
|
||||
});
|
||||
|
||||
// send a delete request to the backend and close the popup on success
|
||||
async function deleteEvent(eventId: number) {
|
||||
const result = await apiCall("DELETE", "event", { id: eventId });
|
||||
async function sendDeleteEvent() {
|
||||
if (deleteEvent !== undefined) {
|
||||
const result = await apiCall("DELETE", "event", { id: deleteEvent.id });
|
||||
|
||||
if (result.ok) {
|
||||
// store the received events
|
||||
events.reload();
|
||||
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,12 +147,20 @@ export default function AdminPanel() {
|
||||
</LocalDate>
|
||||
);
|
||||
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":
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<ButtonGroup isIconOnly variant="light" size="sm">
|
||||
<Button>
|
||||
<Button
|
||||
onPress={() => {
|
||||
setEditEvent(event);
|
||||
}}
|
||||
>
|
||||
<Tooltip content="Edit event">
|
||||
<Edit />
|
||||
</Tooltip>
|
||||
@@ -159,8 +173,7 @@ export default function AdminPanel() {
|
||||
<Button
|
||||
color="danger"
|
||||
onPress={() => {
|
||||
setActiveEvent(event);
|
||||
setShowDeleteConfirm(true);
|
||||
setDeleteEvent(event);
|
||||
}}
|
||||
>
|
||||
<Tooltip content="Delete event">
|
||||
@@ -203,33 +216,6 @@ export default function AdminPanel() {
|
||||
)}
|
||||
</DropdownMenu>
|
||||
</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 {
|
||||
return <NotAvailable className="mx-auto text-foreground-300" />;
|
||||
@@ -237,9 +223,16 @@ export default function AdminPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const [showAddEvent, setShowAddEvent] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [activeEvent, setActiveEvent] = useState<EventData | undefined>();
|
||||
async function updateEvent(data: EventSubmitData) {
|
||||
const result = await apiCall("PATCH", "events", undefined, data);
|
||||
|
||||
if (result.ok) {
|
||||
// clear the selected-event to hide the modal
|
||||
setEditEvent(undefined);
|
||||
|
||||
events.reload();
|
||||
}
|
||||
}
|
||||
|
||||
const topContent = (
|
||||
<div>
|
||||
@@ -271,7 +264,7 @@ export default function AdminPanel() {
|
||||
th: "font-subheadline text-xl text-accent-1 bg-transparent ",
|
||||
thead: "[&>tr]:first:!shadow-border",
|
||||
}}
|
||||
className="w-fit"
|
||||
className="w-fit max-w-full"
|
||||
>
|
||||
<TableHeader columns={headers.items}>
|
||||
{(task) => (
|
||||
@@ -301,10 +294,28 @@ export default function AdminPanel() {
|
||||
onSuccess={() => events.reload()}
|
||||
/>
|
||||
|
||||
{activeEvent !== undefined ? (
|
||||
<EditEvent
|
||||
isOpen={editEvent !== undefined}
|
||||
onOpenChange={(isOpen) => (!isOpen ? setEditEvent(undefined) : null)}
|
||||
onSubmit={updateEvent}
|
||||
initialState={editEvent}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
radius="full"
|
||||
startContent={<Renew />}
|
||||
type="submit"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Edit Event
|
||||
</EditEvent>
|
||||
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onOpenChange={setShowDeleteConfirm}
|
||||
isOpen={!!deleteEvent}
|
||||
onOpenChange={(isOpen) => (!isOpen ? setDeleteEvent(undefined) : null)}
|
||||
shadow={"none" as "sm"}
|
||||
backdrop="blur"
|
||||
className="bg-accent-5"
|
||||
@@ -317,29 +328,28 @@ export default function AdminPanel() {
|
||||
The event{" "}
|
||||
<span className="font-numbers text-accent-1">
|
||||
<LocalDate options={{ dateStyle: "long", timeStyle: "short" }}>
|
||||
{activeEvent.date}
|
||||
{deleteEvent?.date}
|
||||
</LocalDate>
|
||||
</span>{" "}
|
||||
will be deleted.
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
startContent={<TrashCan />}
|
||||
color="danger"
|
||||
onPress={() => deleteEvent(activeEvent.id)}
|
||||
>
|
||||
Delete event
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onPress={() => setShowDeleteConfirm(false)}
|
||||
onPress={() => setDeleteEvent(undefined)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
startContent={<TrashCan />}
|
||||
color="danger"
|
||||
onPress={() => sendDeleteEvent()}
|
||||
>
|
||||
Delete event
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,7 @@
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import { Add } from "@carbon/icons-react";
|
||||
import zustand from "../../Zustand";
|
||||
import { getLocalTimeZone, now, ZonedDateTime } from "@internationalized/date";
|
||||
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>;
|
||||
}
|
||||
import { Button } from "@nextui-org/react";
|
||||
import EditEvent, { EventSubmitData } from "./EditEvent";
|
||||
import { apiCall } from "@/lib";
|
||||
import { AddLarge } from "@carbon/icons-react";
|
||||
|
||||
export default function AddEvent(props: {
|
||||
className?: string;
|
||||
@@ -35,138 +9,32 @@ export default function AddEvent(props: {
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
// initial state for the inputs
|
||||
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),
|
||||
};
|
||||
|
||||
async function addEvent(data: EventSubmitData) {
|
||||
const result = await apiCall("POST", "events", undefined, data);
|
||||
|
||||
if (result.ok) {
|
||||
zustand.getState().setEvents(await result.json());
|
||||
|
||||
props.onOpenChange(false);
|
||||
|
||||
props.onSuccess?.();
|
||||
}
|
||||
}
|
||||
|
||||
// reset the state when the modal gets closed
|
||||
useEffect(() => {
|
||||
if (!props.isOpen) {
|
||||
dispatchState({ action: "reset" });
|
||||
}
|
||||
}, [props.isOpen]);
|
||||
|
||||
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 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>
|
||||
<EditEvent
|
||||
{...props}
|
||||
onSubmit={(data) => void addEvent(data)}
|
||||
footer={
|
||||
<Button
|
||||
color="primary"
|
||||
radius="full"
|
||||
startContent={<Add size={32} />}
|
||||
startContent={<AddLarge />}
|
||||
type="submit"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Form>
|
||||
</Modal>
|
||||
}
|
||||
>
|
||||
Add Event
|
||||
</EditEvent>
|
||||
);
|
||||
}
|
||||
|
||||
202
client/src/components/Event/EditEvent.tsx
Normal file
202
client/src/components/Event/EditEvent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { getLocalTimeZone, parseDateTime } from "@internationalized/date";
|
||||
import { useLocale } from "@react-aria/i18n";
|
||||
|
||||
export default function LocalDate(props: {
|
||||
children: string;
|
||||
children?: string;
|
||||
className?: string;
|
||||
options: Intl.DateTimeFormatOptions;
|
||||
}) {
|
||||
@@ -13,9 +13,11 @@ export default function LocalDate(props: {
|
||||
|
||||
return (
|
||||
<span className={props.className}>
|
||||
{formatter.format(
|
||||
{props.children !== undefined
|
||||
? formatter.format(
|
||||
parseDateTime(props.children).toDate(getLocalTimeZone()),
|
||||
)}
|
||||
)
|
||||
: ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,32 +9,32 @@ export type APICallResult<T extends object> = Response & {
|
||||
export async function apiCall<K extends object>(
|
||||
method: "GET",
|
||||
api: string,
|
||||
params?: QueryParams,
|
||||
query?: QueryParams,
|
||||
): Promise<APICallResult<K>>;
|
||||
export async function apiCall<K extends object>(
|
||||
method: "POST" | "PATCH",
|
||||
method: "POST" | "PATCH" | "PUT",
|
||||
api: string,
|
||||
params?: QueryParams,
|
||||
query?: QueryParams,
|
||||
body?: object,
|
||||
): Promise<APICallResult<K>>;
|
||||
export async function apiCall<K extends object>(
|
||||
method: "DELETE",
|
||||
api: string,
|
||||
params?: QueryParams,
|
||||
query?: QueryParams,
|
||||
body?: object,
|
||||
): Promise<APICallResult<K>>;
|
||||
export async function apiCall<K extends object>(
|
||||
method: "GET" | "POST" | "PATCH" | "DELETE",
|
||||
method: "GET" | "POST" | "PATCH" | "PUT" | "DELETE",
|
||||
api: string,
|
||||
params?: QueryParams,
|
||||
query?: QueryParams,
|
||||
body?: object,
|
||||
): Promise<APICallResult<K>> {
|
||||
let url = window.origin + "/api/" + api;
|
||||
|
||||
if (params) {
|
||||
if (query) {
|
||||
const urlsearchparams = new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]): [string, string] => {
|
||||
Object.entries(query).map(([key, value]): [string, string] => {
|
||||
if (typeof value !== "string") {
|
||||
return [key, value.toString()];
|
||||
} else {
|
||||
@@ -109,6 +109,6 @@ export async function getTasks(): Promise<Record<number, Task>> {
|
||||
|
||||
return tasks;
|
||||
} else {
|
||||
return [];
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user