diff --git a/app/client/components/Layout.tsx b/app/client/components/Layout.tsx index ce4caca5..f0180eb6 100644 --- a/app/client/components/Layout.tsx +++ b/app/client/components/Layout.tsx @@ -70,7 +70,7 @@ export const Layout = ({ {showAdminSidebar && ( -
+
)} diff --git a/app/client/components/admin/AdminNavBar.tsx b/app/client/components/admin/AdminNavBar.tsx index 66a5d516..f897c4c5 100644 --- a/app/client/components/admin/AdminNavBar.tsx +++ b/app/client/components/admin/AdminNavBar.tsx @@ -1,7 +1,7 @@ import { ArrowDownTrayIcon } from "@heroicons/react/24/solid"; import { Context, reverse } from "@reactivated"; import clsx from "clsx"; -import React, { useContext, useEffect, useRef } from "react"; +import React, { useContext } from "react"; interface AdminNavBarProps { showAsSidebar?: boolean; @@ -64,7 +64,10 @@ const navbarSections: AdminNavBarSection[] = [ }, { sectionTitle: "Rooms", - links: [{ linkTitle: "Upload rooms", href: reverse("rooms_import") }], + links: [ + { linkTitle: "List rooms", href: reverse("admin_rooms_list") }, + { linkTitle: "Import rooms", href: reverse("rooms_import") }, + ], }, { sectionTitle: "Boardgames", @@ -147,17 +150,6 @@ const navbarSections: AdminNavBarSection[] = [ export const AdminNavBar = ({ showAsSidebar }: AdminNavBarProps) => { const { request } = useContext(Context); - const currentPathLinkRef = useRef(null); - - useEffect(() => { - if (currentPathLinkRef.current) { - currentPathLinkRef.current.scrollIntoView({ - behavior: "instant", - block: "center", - }); - } - }, []); - return (
diff --git a/app/client/components/homePage/conferenceInfo/EventDates.tsx b/app/client/components/homePage/conferenceInfo/EventDates.tsx index f0c85a32..4cbf0613 100644 --- a/app/client/components/homePage/conferenceInfo/EventDates.tsx +++ b/app/client/components/homePage/conferenceInfo/EventDates.tsx @@ -9,7 +9,7 @@ interface EventDatesProps { export const EventDates = ({ startDate, endDate, title }: EventDatesProps) => { return ( -
+

{`${title}:`}

{`Start: ${getLocalDateTime(startDate)}`}
diff --git a/app/client/components/rooms/JoinLockedRoomDialog.tsx b/app/client/components/rooms/JoinLockedRoomDialog.tsx index 89d55cb4..5b97a0c5 100644 --- a/app/client/components/rooms/JoinLockedRoomDialog.tsx +++ b/app/client/components/rooms/JoinLockedRoomDialog.tsx @@ -1,17 +1,18 @@ import { Input } from "@headlessui/react"; +import { Context } from "@reactivated"; import { UseMutationResult } from "@tanstack/react-query"; import { AxiosResponse } from "axios"; -import React from "react"; +import React, { useContext } from "react"; import { CustomDialog } from "../CustomDialog"; import { LoadingContentSpinner } from "../LoadingContentSpinner"; -import { ApiErrorMessage } from "./ApiErrorMessage"; +import { ApiErrorMessage } from "./api/ApiErrorMessage"; interface JoinLockedRoomDialogProps { roomName: string; joinRoomMutation: UseMutationResult< AxiosResponse, Error, - string | undefined, + { userId: number; password?: string | undefined }, unknown >; dialogOpen: boolean; @@ -24,13 +25,17 @@ export const JoinLockedRoomDialog = ({ dialogOpen, closeDialog, }: JoinLockedRoomDialogProps) => { + const { user } = useContext(Context); const [typedPassword, setTypedPassword] = React.useState(""); const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); if (typedPassword !== "") { - joinRoomMutation.mutate(typedPassword, { onSuccess: closeDialog }); + joinRoomMutation.mutate( + { userId: user.id, password: typedPassword }, + { onSuccess: closeDialog }, + ); } }; diff --git a/app/client/components/rooms/RoomActions.tsx b/app/client/components/rooms/RoomActions.tsx deleted file mode 100644 index e78de776..00000000 --- a/app/client/components/rooms/RoomActions.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { - ArrowRightEndOnRectangleIcon, - ArrowRightStartOnRectangleIcon, - LockClosedIcon, - LockOpenIcon, -} from "@heroicons/react/24/outline"; -import clsx from "clsx"; -import React from "react"; -import { LoadingContentSpinner } from "../LoadingContentSpinner"; - -const ICON_CSS = "size-5"; - -interface RoomActionsProps { - isMyRoom: boolean; - isLocked: boolean; - canUnlock: boolean; - canEnter: boolean; - enterRoom: () => void; - leaveRoom: () => void; - lockRoom: () => void; - unlockRoom: () => void; - enterRoomPending: boolean; - leaveRoomPending: boolean; - lockRoomPending: boolean; - unlockRoomPending: boolean; -} - -export const RoomActions = ({ - isMyRoom, - isLocked, - canUnlock, - canEnter, - enterRoom, - leaveRoom, - lockRoom, - unlockRoom, - enterRoomPending, - leaveRoomPending, - lockRoomPending, - unlockRoomPending, -}: RoomActionsProps) => { - if (isMyRoom) { - const showUnlockButton = isLocked && canUnlock; - const showLockButton = !isLocked; - - return ( -
- {showUnlockButton && ( - - )} - - {showLockButton && ( - - )} - - -
- ); - } - - return ( - - ); -}; diff --git a/app/client/components/rooms/RoomCards.tsx b/app/client/components/rooms/RoomCards.tsx index d6fccebc..2872ada5 100644 --- a/app/client/components/rooms/RoomCards.tsx +++ b/app/client/components/rooms/RoomCards.tsx @@ -9,8 +9,8 @@ import { Context } from "@reactivated"; import { useQuery } from "@tanstack/react-query"; import React, { useContext } from "react"; import { Alert } from "../alert/Alert"; -import { ApiErrorMessage } from "./ApiErrorMessage"; -import { RoomCard } from "./RoomCard"; +import { ApiErrorMessage } from "./api/ApiErrorMessage"; +import { RoomCard } from "./card/RoomCard"; import { RoomsSortBy } from "./RoomsBar"; interface RoomCardsProps { @@ -20,6 +20,8 @@ interface RoomCardsProps { searchText: string; hideFullRooms: boolean; sortRoomsBy: RoomsSortBy; + + isAdmin?: boolean; } export const RoomCards = ({ @@ -27,6 +29,7 @@ export const RoomCards = ({ searchText, hideFullRooms, sortRoomsBy, + isAdmin, }: RoomCardsProps) => { const { user } = useContext(Context); @@ -115,9 +118,10 @@ export const RoomCards = ({
{sortedResults.map((room) => ( ))}
diff --git a/app/client/components/rooms/RoomMutations.tsx b/app/client/components/rooms/RoomMutations.tsx deleted file mode 100644 index 25025690..00000000 --- a/app/client/components/rooms/RoomMutations.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { - convertRoomApiDataToRoomData, - ROOM_QUERY_KEY, - RoomApiData, - RoomData, -} from "@client/utils/roomData"; -import { zosiaApi, zosiaApiRoutes } from "@client/utils/zosiaApi"; -import { Context } from "@reactivated"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { AxiosResponse } from "axios"; -import React, { useContext } from "react"; -import { showCustomToast } from "../CustomToast"; -import { ApiErrorMessage } from "./ApiErrorMessage"; - -export const useRoomMutations = (roomId: number, roomName: string) => { - const { user } = useContext(Context); - - const queryClient = useQueryClient(); - - const invalidateRoomData = () => { - queryClient.invalidateQueries({ queryKey: [ROOM_QUERY_KEY] }); - }; - - const onMutationSuccess = ( - data: AxiosResponse, - message: string, - ) => { - // Update rooms data with this specific room right after getting the response from server. - const updatedRoom = convertRoomApiDataToRoomData(data.data); - queryClient.setQueryData([ROOM_QUERY_KEY], (oldData: RoomData[]) => { - return oldData.map((room) => - room.id === updatedRoom.id ? updatedRoom : room, - ); - }); - showCustomToast("success", message); - - // Invalidate the rooms data to refetch it from the server and get the most recent data for other rooms. - invalidateRoomData(); - }; - - const onMutationError = (error: Error) => { - showCustomToast("error", ); - console.error(error); - - // Invalidate the rooms data to refetch it from the server and get the most recent data for all rooms. - invalidateRoomData(); - }; - - const joinRoomMutation = useMutation({ - mutationFn: async (password?: string) => { - return await zosiaApi.post( - zosiaApiRoutes.roomMember(roomId), - { - user: user.id, - password: password, - }, - ); - }, - onSuccess: (data) => - onMutationSuccess(data, `You've joined room ${roomName}.`), - onError: onMutationError, - }); - - const leaveRoomMutation = useMutation({ - mutationFn: async () => { - return await zosiaApi.delete( - zosiaApiRoutes.roomMember(roomId), - { - data: { user: user.id }, - }, - ); - }, - onSuccess: (data) => - onMutationSuccess(data, `You've left room ${roomName}.`), - onError: onMutationError, - }); - - const lockRoomMutation = useMutation({ - mutationFn: async () => { - return await zosiaApi.post(zosiaApiRoutes.lockRoom(roomId), { - user: user.id, - }); - }, - onSuccess: (data) => - onMutationSuccess( - data, - `You've locked room ${roomName}. Share the password with your friends.`, - ), - onError: onMutationError, - }); - - const unlockRoomMutation = useMutation({ - mutationFn: async () => { - return await zosiaApi.delete( - zosiaApiRoutes.lockRoom(roomId), - ); - }, - onSuccess: (data) => - onMutationSuccess( - data, - `You've unlocked room ${roomName}. Now everybody can join it.`, - ), - onError: onMutationError, - }); - - return { - joinRoomMutation, - leaveRoomMutation, - lockRoomMutation, - unlockRoomMutation, - }; -}; diff --git a/app/client/components/rooms/Rooms.tsx b/app/client/components/rooms/Rooms.tsx new file mode 100644 index 00000000..40a0908f --- /dev/null +++ b/app/client/components/rooms/Rooms.tsx @@ -0,0 +1,36 @@ +import { RoomData } from "@client/utils/roomData"; +import React, { useState } from "react"; +import { RoomCards } from "./RoomCards"; +import { RoomsBar, RoomsSortBy } from "./RoomsBar"; + +interface RoomsProps { + initialRoomsData: RoomData[]; + isAdmin?: boolean; +} + +export const Rooms = ({ initialRoomsData, isAdmin }: RoomsProps) => { + const [searchText, setSearchText] = useState(""); + const [hideFullRooms, setHideFullRooms] = useState(false); + const [sortRoomsBy, setSortRoomsBy] = useState("roomNumber"); + + return ( + <> + + + + ); +}; diff --git a/app/client/components/rooms/RoomsBar.tsx b/app/client/components/rooms/RoomsBar.tsx index 95ce3850..aff11440 100644 --- a/app/client/components/rooms/RoomsBar.tsx +++ b/app/client/components/rooms/RoomsBar.tsx @@ -1,6 +1,11 @@ -import { Checkbox, Field, Input, Label, Select } from "@headlessui/react"; +import { Checkbox, Field, Input, Label } from "@headlessui/react"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; -import React from "react"; +import React, { useState } from "react"; +import { BasicListbox } from "../forms/widgets/BasicListbox"; + +import { ArrowUpTrayIcon, PlusIcon } from "@heroicons/react/24/solid"; +import { reverse } from "@reactivated"; +import { RoomAddDialog } from "./admin/RoomAddDialog"; interface RoomsBarProps { searchText: string; @@ -9,6 +14,8 @@ interface RoomsBarProps { onHideFullRoomsChange: (hide: boolean) => void; sortRoomsBy: RoomsSortBy; onSortRoomsByChange: (sortBy: RoomsSortBy) => void; + + isAdmin?: boolean; } export type RoomsSortBy = "roomNumber" | "fullness"; @@ -20,42 +27,70 @@ export const RoomsBar = ({ onHideFullRoomsChange, sortRoomsBy, onSortRoomsByChange, + isAdmin, }: RoomsBarProps) => { + const [addRoomDialogOpen, setAddRoomDialogOpen] = useState(false); + return ( -
- - onSearchTextChange(e.target.value)} - /> - - - - - - - - - - - + onSearchTextChange(e.target.value)} + /> + + + + + + + + + + + + onSortRoomsByChange(newValue as RoomsSortBy) + } + multiple={false} + /> + +
+ {isAdmin && ( +
+ + + + Import rooms + + setAddRoomDialogOpen(false)} + /> +
+ )}
); }; diff --git a/app/client/components/rooms/admin/RoomAddDialog.tsx b/app/client/components/rooms/admin/RoomAddDialog.tsx new file mode 100644 index 00000000..e18fbf2c --- /dev/null +++ b/app/client/components/rooms/admin/RoomAddDialog.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { CustomDialog } from "../../CustomDialog"; +import { RoomPropertiesForm } from "./RoomPropertiesForm"; + +interface RoomAddDialogProps { + dialogOpen: boolean; + closeDialog: () => void; +} + +export const RoomAddDialog = ({ + dialogOpen, + closeDialog, +}: RoomAddDialogProps) => { + const title = "Add room"; + + return ( + + + + ); +}; diff --git a/app/client/components/rooms/admin/RoomDeleteConfirmationDialog.tsx b/app/client/components/rooms/admin/RoomDeleteConfirmationDialog.tsx new file mode 100644 index 00000000..11a06516 --- /dev/null +++ b/app/client/components/rooms/admin/RoomDeleteConfirmationDialog.tsx @@ -0,0 +1,44 @@ +import { CustomDialog } from "@client/components/CustomDialog"; +import { LoadingContentSpinner } from "@client/components/LoadingContentSpinner"; +import { UseMutationResult } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import React from "react"; + +interface RoomDeleteConfirmationDialogProps { + roomName: string; + deleteRoomMutation: UseMutationResult< + AxiosResponse, + Error, + void, + unknown + >; + dialogOpen: boolean; + closeDialog: () => void; +} + +export const RoomDeleteConfirmationDialog = ({ + roomName, + deleteRoomMutation, + dialogOpen, + closeDialog, +}: RoomDeleteConfirmationDialogProps) => { + return ( + + + + ); +}; diff --git a/app/client/components/rooms/admin/RoomEditDialog.tsx b/app/client/components/rooms/admin/RoomEditDialog.tsx new file mode 100644 index 00000000..f5017a7f --- /dev/null +++ b/app/client/components/rooms/admin/RoomEditDialog.tsx @@ -0,0 +1,43 @@ +import { RoomData } from "@client/utils/roomData"; +import { Tab, TabGroup, TabList, TabPanel, TabPanels } from "@headlessui/react"; +import React from "react"; +import { CustomDialog } from "../../CustomDialog"; +import { RoomMembersEdit } from "./RoomMembersEdit"; +import { RoomPropertiesForm } from "./RoomPropertiesForm"; + +interface RoomEditDialogProps { + roomData: RoomData; + dialogOpen: boolean; + closeDialog: () => void; +} + +export const RoomEditDialog = ({ + roomData, + dialogOpen, + closeDialog, +}: RoomEditDialogProps) => { + const title = "Edit room"; + + return ( + + + + Room properties + Members + + + + + + + + + + + + ); +}; diff --git a/app/client/components/rooms/admin/RoomMembersAdd.tsx b/app/client/components/rooms/admin/RoomMembersAdd.tsx new file mode 100644 index 00000000..e0fb9189 --- /dev/null +++ b/app/client/components/rooms/admin/RoomMembersAdd.tsx @@ -0,0 +1,97 @@ +import { RoomMember } from "@client/utils/roomData"; +import { UserApiData } from "@client/utils/userData"; +import { zosiaApi, zosiaApiRoutes } from "@client/utils/zosiaApi"; +import { + Combobox, + ComboboxButton, + ComboboxInput, + ComboboxOption, + ComboboxOptions, +} from "@headlessui/react"; +import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/solid"; +import { useQuery } from "@tanstack/react-query"; +import clsx from "clsx"; +import React, { useState } from "react"; + +interface RoomMembersAddProps { + members: RoomMember[]; + addMember: (memberId: number) => void; +} + +export const RoomMembersAdd = ({ members, addMember }: RoomMembersAddProps) => { + const [searchQuery, setSearchQuery] = useState(""); + const { isPending, isError, data, error } = useQuery({ + queryKey: ["users"], + queryFn: async () => { + const res = await zosiaApi.get<[UserApiData]>(zosiaApiRoutes.users); + return res.data; + }, + }); + + if (isPending) { + return ; + } + + if (isError) { + return
Error: {error.message}
; + } + + const filteredUsers = + searchQuery === "" + ? data + : data.filter((user) => + `${user.first_name} ${user.last_name}` + .toLowerCase() + .includes(searchQuery.toLowerCase()), + ); + + const isUserAlreadyMember = (userId: number) => { + return members.some((member) => member.id === userId); + }; + + const onChange = (user?: UserApiData) => { + if (user) { + addMember(user.id); + } + setSearchQuery(""); + }; + + return ( + setSearchQuery("")}> +
+ setSearchQuery(event.target.value)} + className="input input-bordered w-full" + /> + + + +
+ + {filteredUsers.map((user) => ( + + {user.first_name} {user.last_name} + + + ))} + +
+ ); +}; diff --git a/app/client/components/rooms/admin/RoomMembersEdit.tsx b/app/client/components/rooms/admin/RoomMembersEdit.tsx new file mode 100644 index 00000000..653fa751 --- /dev/null +++ b/app/client/components/rooms/admin/RoomMembersEdit.tsx @@ -0,0 +1,53 @@ +import { AdminTable } from "@client/components/admin/tables/AdminTable"; +import { RoomMember } from "@client/utils/roomData"; +import { TrashIcon } from "@heroicons/react/24/solid"; +import React from "react"; +import { useRoomMutations } from "../api/RoomMutations"; +import { RoomMembersAdd } from "./RoomMembersAdd"; + +interface RoomMembersEditProps { + roomID: number; + members: RoomMember[]; +} + +export const RoomMembersEdit = ({ roomID, members }: RoomMembersEditProps) => { + const { joinRoomMutation, leaveRoomMutation } = useRoomMutations(roomID); + + const deleteMember = (memberId: number) => { + leaveRoomMutation.mutate(memberId); + }; + + const addMember = (memberId: number) => { + joinRoomMutation.mutate({ userId: memberId }); + }; + + return ( +
+

Add user to room

+ + + + +

Users currently in room

+ + {members.map((member) => ( + + + {member.firstName} {member.lastName} + + + + + + + ))} + +
+ ); +}; diff --git a/app/client/components/rooms/admin/RoomPropertiesForm.tsx b/app/client/components/rooms/admin/RoomPropertiesForm.tsx new file mode 100644 index 00000000..924ef770 --- /dev/null +++ b/app/client/components/rooms/admin/RoomPropertiesForm.tsx @@ -0,0 +1,148 @@ +import { LoadingContentSpinner } from "@client/components/LoadingContentSpinner"; +import { RoomData } from "@client/utils/roomData"; +import React, { useState } from "react"; +import { useRoomMutations } from "../api/RoomMutations"; +import { RoomPropertiesFormFieldCheckbox } from "./RoomPropertiesFormFieldCheckbox"; +import { RoomPropertiesFormFieldInput } from "./RoomPropertiesFormFieldInput"; + +interface RoomPropertiesFormProps { + roomData?: RoomData; + closeDialog: () => void; + submitButtonLabel: string; +} + +export const RoomPropertiesForm = ({ + roomData, + closeDialog, + submitButtonLabel, +}: RoomPropertiesFormProps) => { + const [roomName, setRoomName] = useState(roomData ? roomData.name : ""); + const [roomDescription, setRoomDescription] = useState( + roomData ? roomData.description : "", + ); + + const [availableBedsSingle, setAvailableBedsSingle] = useState( + roomData ? roomData.availableBedsSingle : 0, + ); + const [availableBedsDouble, setAvailableBedsDouble] = useState( + roomData ? roomData.availableBedsDouble : 0, + ); + const [bedsSingle, setBedsSingle] = useState( + roomData ? roomData.bedsSingle : 0, + ); + const [bedsDouble, setBedsDouble] = useState( + roomData ? roomData.bedsDouble : 0, + ); + + const [roomHidden, setRoomHidden] = useState( + roomData ? roomData.hidden : false, + ); + + const { createRoomMutation, editRoomMutation } = useRoomMutations( + roomData ? roomData.id : -1, + roomName, + ); + + const resetFormState = () => { + setRoomName(""); + setRoomDescription(""); + setAvailableBedsSingle(0); + setAvailableBedsDouble(0); + setBedsSingle(0); + setBedsDouble(0); + setRoomHidden(false); + }; + + const onMutationSuccess = () => { + resetFormState(); + closeDialog(); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (roomData) { + editRoomMutation.mutate( + { + id: roomData.id, + name: roomName, + description: roomDescription, + available_beds_single: availableBedsSingle, + available_beds_double: availableBedsDouble, + beds_single: bedsSingle, + beds_double: bedsDouble, + hidden: roomHidden, + }, + { onSuccess: onMutationSuccess }, + ); + } else { + createRoomMutation.mutate( + { + name: roomName, + description: roomDescription, + available_beds_single: availableBedsSingle, + available_beds_double: availableBedsDouble, + beds_single: bedsSingle, + beds_double: bedsDouble, + hidden: roomHidden, + }, + { onSuccess: onMutationSuccess }, + ); + } + }; + + return ( + + + + setAvailableBedsSingle(parseInt(newValue))} + /> + setAvailableBedsDouble(parseInt(newValue))} + /> + setBedsSingle(parseInt(newValue))} + /> + setBedsDouble(parseInt(newValue))} + /> + + + + ); +}; diff --git a/app/client/components/rooms/admin/RoomPropertiesFormFieldCheckbox.tsx b/app/client/components/rooms/admin/RoomPropertiesFormFieldCheckbox.tsx new file mode 100644 index 00000000..19286b2f --- /dev/null +++ b/app/client/components/rooms/admin/RoomPropertiesFormFieldCheckbox.tsx @@ -0,0 +1,26 @@ +import { Checkbox, Field } from "@headlessui/react"; +import React from "react"; +import { RoomPropertiesFormFieldLabel } from "./RoomPropertiesFormFieldLabel"; + +interface RoomPropertiesFormFieldCheckboxProps { + label: string; + checked: boolean; + onChange: (newValue: boolean) => void; +} + +export const RoomPropertiesFormFieldCheckbox = ({ + label, + checked, + onChange, +}: RoomPropertiesFormFieldCheckboxProps) => { + return ( + + + + + ); +}; diff --git a/app/client/components/rooms/admin/RoomPropertiesFormFieldInput.tsx b/app/client/components/rooms/admin/RoomPropertiesFormFieldInput.tsx new file mode 100644 index 00000000..691c6742 --- /dev/null +++ b/app/client/components/rooms/admin/RoomPropertiesFormFieldInput.tsx @@ -0,0 +1,32 @@ +import { Field, Input } from "@headlessui/react"; +import React from "react"; +import { RoomPropertiesFormFieldLabel } from "./RoomPropertiesFormFieldLabel"; + +interface RoomPropertiesFormFieldInputProps { + value: string; + onChange: (newValue: string) => void; + type: React.HTMLInputTypeAttribute; + label: string; + required?: boolean; +} + +export const RoomPropertiesFormFieldInput = ({ + value, + onChange, + type, + label, + required, +}: RoomPropertiesFormFieldInputProps) => { + return ( + + + onChange(e.target.value)} + /> + + ); +}; diff --git a/app/client/components/rooms/admin/RoomPropertiesFormFieldLabel.tsx b/app/client/components/rooms/admin/RoomPropertiesFormFieldLabel.tsx new file mode 100644 index 00000000..8020fc74 --- /dev/null +++ b/app/client/components/rooms/admin/RoomPropertiesFormFieldLabel.tsx @@ -0,0 +1,16 @@ +import { Label } from "@headlessui/react"; +import React from "react"; + +interface RoomPropertiesFormFieldLabel { + label: string; +} + +export const RoomPropertiesFormFieldLabel = ({ + label, +}: RoomPropertiesFormFieldLabel) => { + return ( + + ); +}; diff --git a/app/client/components/rooms/ApiAxiosErrorMessage.tsx b/app/client/components/rooms/api/ApiAxiosErrorMessage.tsx similarity index 100% rename from app/client/components/rooms/ApiAxiosErrorMessage.tsx rename to app/client/components/rooms/api/ApiAxiosErrorMessage.tsx diff --git a/app/client/components/rooms/ApiErrorMessage.tsx b/app/client/components/rooms/api/ApiErrorMessage.tsx similarity index 100% rename from app/client/components/rooms/ApiErrorMessage.tsx rename to app/client/components/rooms/api/ApiErrorMessage.tsx diff --git a/app/client/components/rooms/api/RoomMutations.tsx b/app/client/components/rooms/api/RoomMutations.tsx new file mode 100644 index 00000000..56805a3c --- /dev/null +++ b/app/client/components/rooms/api/RoomMutations.tsx @@ -0,0 +1,162 @@ +import { + convertRoomApiDataToRoomData, + ROOM_QUERY_KEY, + RoomApiData, + RoomCreateApiData, + RoomData, + RoomEditApiData, +} from "@client/utils/roomData"; +import { zosiaApi, zosiaApiRoutes } from "@client/utils/zosiaApi"; +import { Context } from "@reactivated"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import React, { useContext } from "react"; +import { showCustomToast } from "../../CustomToast"; +import { ApiErrorMessage } from "./ApiErrorMessage"; + +export const useRoomMutations = (roomId: number, roomName?: string) => { + const { user } = useContext(Context); + + const queryClient = useQueryClient(); + + const invalidateRoomData = () => { + queryClient.invalidateQueries({ queryKey: [ROOM_QUERY_KEY] }); + }; + + const onMutationSuccess = ( + message: string, + data?: AxiosResponse, + ) => { + if (data) { + // Update rooms data with this specific room right after getting the response from server. + const updatedRoom = convertRoomApiDataToRoomData(data.data); + queryClient.setQueryData([ROOM_QUERY_KEY], (oldData: RoomData[]) => { + return oldData.map((room) => + room.id === updatedRoom.id ? updatedRoom : room, + ); + }); + } + + showCustomToast("success", message); + + // Invalidate the rooms data to refetch it from the server and get the most recent data for other rooms. + invalidateRoomData(); + }; + + const onMutationError = (error: Error) => { + showCustomToast("error", ); + console.error(error); + + // Invalidate the rooms data to refetch it from the server and get the most recent data for all rooms. + invalidateRoomData(); + }; + + const joinRoomMutation = useMutation({ + mutationFn: async (params: { userId: number; password?: string }) => { + return await zosiaApi.post( + zosiaApiRoutes.roomMember(roomId), + { + user: params.userId, + password: params.password, + }, + ); + }, + onSuccess: (data, { userId }) => + onMutationSuccess( + userId === user.id + ? `You've joined room ${data.data.name}.` + : `You've added user to room ${data.data.name}.`, + data, + ), + onError: onMutationError, + }); + + const leaveRoomMutation = useMutation({ + mutationFn: async (userId: number) => { + return await zosiaApi.delete( + zosiaApiRoutes.roomMember(roomId), + { + data: { user: userId }, + }, + ); + }, + onSuccess: (data, userId) => + onMutationSuccess( + userId === user.id + ? `You've left room ${data.data.name}.` + : `You've removed user from room ${data.data.name}.`, + data, + ), + onError: onMutationError, + }); + + const lockRoomMutation = useMutation({ + mutationFn: async () => { + return await zosiaApi.post(zosiaApiRoutes.lockRoom(roomId), { + user: user.id, + }); + }, + onSuccess: (data) => + onMutationSuccess( + `You've locked room ${data.data.name}. Share the password with your friends.`, + data, + ), + onError: onMutationError, + }); + + const unlockRoomMutation = useMutation({ + mutationFn: async () => { + return await zosiaApi.delete( + zosiaApiRoutes.lockRoom(roomId), + ); + }, + onSuccess: (data) => + onMutationSuccess( + `You've unlocked room ${data.data.name}. Now everybody can join it.`, + data, + ), + onError: onMutationError, + }); + + const createRoomMutation = useMutation({ + mutationFn: async (roomData: RoomCreateApiData) => { + return await zosiaApi.post(zosiaApiRoutes.rooms, roomData); + }, + onSuccess: (data) => + onMutationSuccess(`You've created room ${data.data.name}.`, data), + onError: onMutationError, + }); + + const deleteRoomMutation = useMutation({ + mutationFn: async () => { + return await zosiaApi.delete(zosiaApiRoutes.room(roomId)); + }, + onSuccess: () => + onMutationSuccess( + `You've deleted room ${roomName}. You should inform its inhabitants about this.`, + ), + onError: onMutationError, + }); + + const editRoomMutation = useMutation({ + mutationFn: async (roomData: RoomEditApiData) => { + return await zosiaApi.put( + zosiaApiRoutes.room(roomId), + roomData, + ); + }, + onSuccess: (data) => + onMutationSuccess(`You've edited room ${data.data.name}.`, data), + onError: onMutationError, + }); + + return { + joinRoomMutation, + leaveRoomMutation, + lockRoomMutation, + unlockRoomMutation, + createRoomMutation, + deleteRoomMutation, + editRoomMutation, + }; +}; diff --git a/app/client/components/rooms/card/RoomActions.tsx b/app/client/components/rooms/card/RoomActions.tsx new file mode 100644 index 00000000..068bb09e --- /dev/null +++ b/app/client/components/rooms/card/RoomActions.tsx @@ -0,0 +1,86 @@ +import { ArrowRightEndOnRectangleIcon } from "@heroicons/react/24/outline"; +import React from "react"; +import { LoadingContentSpinner } from "../../LoadingContentSpinner"; +import { RoomActionsAdmin } from "./RoomActionsAdmin"; +import { RoomActionsMyRoom } from "./RoomActionsMyRoom"; + +export const ROOM_ACTION_ICON_CSS = "size-5"; + +interface RoomActionsProps { + isAdmin?: boolean; + isMyRoom: boolean; + isLocked: boolean; + canUnlock: boolean; + canEnter: boolean; + enterRoom: () => void; + leaveRoom: () => void; + lockRoom: () => void; + unlockRoom: () => void; + deleteRoom: () => void; + editRoom: () => void; + + enterRoomPending: boolean; + leaveRoomPending: boolean; + lockRoomPending: boolean; + unlockRoomPending: boolean; + deleteRoomPending: boolean; + editRoomPending: boolean; +} + +export const RoomActions = ({ + isAdmin, + isMyRoom, + isLocked, + canUnlock, + canEnter, + enterRoom, + leaveRoom, + lockRoom, + unlockRoom, + deleteRoom, + editRoom, + enterRoomPending, + leaveRoomPending, + lockRoomPending, + unlockRoomPending, + deleteRoomPending, + editRoomPending, +}: RoomActionsProps) => { + if (isAdmin) { + return ( + + ); + } + + if (isMyRoom) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/app/client/components/rooms/card/RoomActionsAdmin.tsx b/app/client/components/rooms/card/RoomActionsAdmin.tsx new file mode 100644 index 00000000..00ee93ff --- /dev/null +++ b/app/client/components/rooms/card/RoomActionsAdmin.tsx @@ -0,0 +1,42 @@ +import { LoadingContentSpinner } from "@client/components/LoadingContentSpinner"; +import { PencilIcon, TrashIcon } from "@heroicons/react/24/solid"; +import React from "react"; +import { ROOM_ACTION_ICON_CSS } from "./RoomActions"; + +interface RoomActionsAdminProps { + deleteRoom: () => void; + editRoom: () => void; + deleteRoomPending: boolean; + editRoomPending: boolean; +} + +export const RoomActionsAdmin = ({ + deleteRoom, + editRoom, + deleteRoomPending, + editRoomPending, +}: RoomActionsAdminProps) => { + return ( +
+ + + +
+ ); +}; diff --git a/app/client/components/rooms/card/RoomActionsMyRoom.tsx b/app/client/components/rooms/card/RoomActionsMyRoom.tsx new file mode 100644 index 00000000..fc7b036b --- /dev/null +++ b/app/client/components/rooms/card/RoomActionsMyRoom.tsx @@ -0,0 +1,76 @@ +import { LoadingContentSpinner } from "@client/components/LoadingContentSpinner"; +import { + ArrowRightStartOnRectangleIcon, + LockClosedIcon, + LockOpenIcon, +} from "@heroicons/react/24/solid"; +import clsx from "clsx"; +import React from "react"; +import { ROOM_ACTION_ICON_CSS } from "./RoomActions"; + +interface RoomActionsMyRoomProps { + isLocked: boolean; + canUnlock: boolean; + leaveRoom: () => void; + lockRoom: () => void; + unlockRoom: () => void; + leaveRoomPending: boolean; + lockRoomPending: boolean; + unlockRoomPending: boolean; +} + +export const RoomActionsMyRoom = ({ + isLocked, + canUnlock, + leaveRoom, + lockRoom, + unlockRoom, + leaveRoomPending, + lockRoomPending, + unlockRoomPending, +}: RoomActionsMyRoomProps) => { + const showUnlockButton = isLocked && canUnlock; + const showLockButton = !isLocked; + + return ( +
+ {showUnlockButton && ( + + )} + + {showLockButton && ( + + )} + + +
+ ); +}; diff --git a/app/client/components/rooms/RoomCard.tsx b/app/client/components/rooms/card/RoomCard.tsx similarity index 67% rename from app/client/components/rooms/RoomCard.tsx rename to app/client/components/rooms/card/RoomCard.tsx index f18f5399..6b14a9ab 100644 --- a/app/client/components/rooms/RoomCard.tsx +++ b/app/client/components/rooms/card/RoomCard.tsx @@ -5,39 +5,53 @@ import { LockClosedIcon as LockClosedIconSolid } from "@heroicons/react/24/solid import { Context } from "@reactivated"; import clsx from "clsx"; import React, { useContext, useState } from "react"; -import { JoinLockedRoomDialog } from "./JoinLockedRoomDialog"; +import { RoomDeleteConfirmationDialog } from "../admin/RoomDeleteConfirmationDialog"; +import { RoomEditDialog } from "../admin/RoomEditDialog"; +import { useRoomMutations } from "../api/RoomMutations"; +import { JoinLockedRoomDialog } from "../JoinLockedRoomDialog"; import { RoomActions } from "./RoomActions"; import { RoomInfoPopover } from "./RoomInfoPopover"; import { RoomMembersCount } from "./RoomMembersCount"; -import { useRoomMutations } from "./RoomMutations"; interface RoomCardProps { roomData: RoomData; userIsInSomeRoomAlready: boolean; + isAdmin?: boolean; } export const RoomCard = ({ - roomData: { + roomData, + userIsInSomeRoomAlready, + isAdmin, +}: RoomCardProps) => { + const { user } = useContext(Context); + + const { id, name, - description, members, - lock, availableBedsSingle, availableBedsDouble, - }, - userIsInSomeRoomAlready, -}: RoomCardProps) => { - const { user } = useContext(Context); + description, + lock, + hidden, + } = roomData; const { joinRoomMutation, leaveRoomMutation, lockRoomMutation, unlockRoomMutation, + deleteRoomMutation, + editRoomMutation, } = useRoomMutations(id, name); const [roomPasswordDialogOpen, setRoomPasswordDialogOpen] = useState(false); + const [ + roomDeleteConfirmationDialogOpen, + setRoomDeleteConfirmationDialogOpen, + ] = useState(false); + const [roomEditDialogOpen, setRoomEditDialogOpen] = useState(false); const allPlaces = availableBedsSingle + availableBedsDouble * 2; const availablePlaces = allPlaces - members.length; @@ -52,24 +66,28 @@ export const RoomCard = ({ if (isLocked) { setRoomPasswordDialogOpen(true); } else { - joinRoomMutation.mutate(""); + joinRoomMutation.mutate({ userId: user.id }); } }; - const leaveRoom = () => leaveRoomMutation.mutate(); + const leaveRoom = () => leaveRoomMutation.mutate(user.id); const lockRoom = () => lockRoomMutation.mutate(); const unlockRoom = () => unlockRoomMutation.mutate(); + const deleteRoom = () => setRoomDeleteConfirmationDialogOpen(true); + const editRoom = () => setRoomEditDialogOpen(true); return ( ); }; diff --git a/app/client/components/rooms/RoomInfoPopover.tsx b/app/client/components/rooms/card/RoomInfoPopover.tsx similarity index 84% rename from app/client/components/rooms/RoomInfoPopover.tsx rename to app/client/components/rooms/card/RoomInfoPopover.tsx index 59337e65..a087c11e 100644 --- a/app/client/components/rooms/RoomInfoPopover.tsx +++ b/app/client/components/rooms/card/RoomInfoPopover.tsx @@ -6,6 +6,7 @@ import { PopoverPanel, } from "@headlessui/react"; import { ChevronUpIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import clsx from "clsx"; import React from "react"; interface RoomInfoPopoverProps { @@ -14,6 +15,7 @@ interface RoomInfoPopoverProps { availableBedsSingle: number; availableBedsDouble: number; description: string; + hidden?: boolean; } export const RoomInfoPopover = ({ @@ -22,6 +24,7 @@ export const RoomInfoPopover = ({ availableBedsSingle, availableBedsDouble, description, + hidden, }: RoomInfoPopoverProps) => { return ( @@ -32,12 +35,17 @@ export const RoomInfoPopover = ({ transition className="absolute bottom-0 left-0 z-50 h-full w-full origin-bottom-left transition duration-200 ease-in-out data-[closed]:scale-0 data-[closed]:opacity-0" > -
+