From ffe5305a6b1c865a43f8da6026c63128e7146c35 Mon Sep 17 00:00:00 2001 From: Benson Cho <100653148+bcho892@users.noreply.github.com> Date: Thu, 27 Jun 2024 23:11:51 +1200 Subject: [PATCH] 520 frontend implement profile editing (#526) * working edit popups * fix undefined errors and lazy load * remove unused file * make reusable component for handling the modal submission logic * refactor panel opening logic * fix builds * fix phone bug * move all into submit handler instead --- .../composite/Booking/BookingContext.tsx | 6 +- .../ProfileCalendarCard.tsx | 31 ---- .../Profile/ProfileEdit/ProfileEdit.tsx | 134 +++++++++++++++--- client/src/components/utils/DateUtils.tsx | 10 +- client/src/firebase.ts | 14 +- .../src/pages/Profile/EditAdditionalPanel.tsx | 36 +++++ .../src/pages/Profile/EditPersonalPanel.tsx | 61 ++++++++ client/src/pages/Profile/Profile.tsx | 56 ++++++-- .../src/pages/Profile/WrappedProfileEdit.tsx | 81 +++++++++++ client/src/services/User/UserMutations.ts | 13 +- client/src/services/User/UserQueries.ts | 11 ++ client/src/services/User/UserService.ts | 10 ++ client/src/store/Store.tsx | 13 +- 13 files changed, 384 insertions(+), 92 deletions(-) delete mode 100644 client/src/components/composite/Profile/ProfileCalendarCard/ProfileCalendarCard.tsx create mode 100644 client/src/pages/Profile/EditAdditionalPanel.tsx create mode 100644 client/src/pages/Profile/EditPersonalPanel.tsx create mode 100644 client/src/pages/Profile/WrappedProfileEdit.tsx create mode 100644 client/src/services/User/UserQueries.ts diff --git a/client/src/components/composite/Booking/BookingContext.tsx b/client/src/components/composite/Booking/BookingContext.tsx index 0f14977a1..bf01e2ff9 100644 --- a/client/src/components/composite/Booking/BookingContext.tsx +++ b/client/src/components/composite/Booking/BookingContext.tsx @@ -64,9 +64,7 @@ export const BookingContextProvider = ({ const [allergies, setAllergies] = useState("") - const { mutateAsync: updateAllergies } = useEditSelfMutation({ - dietary_requirements: allergies - }) + const { mutateAsync: updateAllergies } = useEditSelfMutation() const getExistingSession = async () => { if (bookingPaymentData?.stripeClientSecret) navigate("/bookings/payment") @@ -87,7 +85,7 @@ export const BookingContextProvider = ({ startDate: Timestamp, endDate: Timestamp ) => { - await updateAllergies() + await updateAllergies({ dietary_requirements: allergies }) await mutateAsync( { startDate, endDate }, { diff --git a/client/src/components/composite/Profile/ProfileCalendarCard/ProfileCalendarCard.tsx b/client/src/components/composite/Profile/ProfileCalendarCard/ProfileCalendarCard.tsx deleted file mode 100644 index 340161d23..000000000 --- a/client/src/components/composite/Profile/ProfileCalendarCard/ProfileCalendarCard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Card, Typography, CardContent, Stack, Box } from "@mui/material" - -function ProfileCalendarCard() { - return ( -
- - - - - Calendar - - - - - -
- ) -} - -export default ProfileCalendarCard diff --git a/client/src/components/composite/Profile/ProfileEdit/ProfileEdit.tsx b/client/src/components/composite/Profile/ProfileEdit/ProfileEdit.tsx index b9cde8bae..d8122ba05 100644 --- a/client/src/components/composite/Profile/ProfileEdit/ProfileEdit.tsx +++ b/client/src/components/composite/Profile/ProfileEdit/ProfileEdit.tsx @@ -2,17 +2,47 @@ import { ReducedUserAdditionalInfo } from "models/User" import TextInput from "components/generic/TextInputComponent/TextInput" import Button from "components/generic/FigmaButtons/FigmaButton" import CloseButton from "assets/icons/x.svg?react" +import { Timestamp } from "firebase/firestore" +import { DateUtils, UnknownTimestamp } from "components/utils/DateUtils" +import { useState } from "react" interface IProfileEdit> { + /** + * The text to be displayed as the heading + */ title: string + /** + * Callback for when the X button is clicked + */ onClose: () => void fields: { + /** + * the **key** of the value in `ReducedUserAdditionalInfo` to display as a field + */ fieldName: keyof T - defaultFieldValue: string + /** + * The value to display in the field with no edits made + */ + defaultFieldValue?: ReducedUserAdditionalInfo[keyof ReducedUserAdditionalInfo] }[] + /** + * Callback that provides the fields that were changed in the edit form + * + * @param fields an object of all the changed fields + */ onEdit: (fields: Partial) => void + /** + * If there is an ongoing operation (i.e calling the edit endpoint) + */ + isPending?: boolean } +/** + * Gets a semantic name for the keys in user data + * + * @param originalName the key from `ReducedUserAdditionalInfo` to transform + * @returns a semantic name for the original key + */ const nameTransformer = ( originalName: keyof ReducedUserAdditionalInfo ): string => { @@ -50,14 +80,36 @@ const nameTransformer = ( } } +/** + * Panel for when profile needs to be edited. + * + * The fields displayed should be determened by the generic typing + * + * @example + + title="example" + /> + */ const ProfileEdit = >({ title, fields, - onClose + onClose, + onEdit, + isPending }: IProfileEdit) => { + const [currentFormState, setCurrentFormState] = + useState>() return ( -
-
+
+

{title}

{" "} >({ className="hover:fill-light-blue-100 ml-auto w-[15px] cursor-pointer" />
+
{ + e.preventDefault() + onEdit(currentFormState as T) + setCurrentFormState(undefined) + }} + > + {fields.map((field) => { + const defaultValue = field.defaultFieldValue + const isDate = field.fieldName === "date_of_birth" + const isTel = field.fieldName === "phone_number" + const isBool = + field.fieldName === "does_snowboarding" || + field.fieldName === "does_ski" + return ( + + setCurrentFormState({ + ...currentFormState, + [field.fieldName]: isDate + ? // Need to store as timestamp, not date which the input provides + Timestamp.fromDate( + DateUtils.convertLocalDateToUTCDate( + e.target.valueAsDate || new Date() + ) + ) + : // Does skiing/snowboarding + isBool + ? e.target.checked + : // Phone number + e.target.value + }) + } + defaultValue={ + isDate && defaultValue + ? DateUtils.formatDateForInput( + new Date( + DateUtils.timestampMilliseconds( + defaultValue as UnknownTimestamp + ) + ) + ) + : (defaultValue as string) + } + /> + ) + })} - {fields.map((field) => { - return ( - - ) - })} - -
- -
+
+ +
+
) diff --git a/client/src/components/utils/DateUtils.tsx b/client/src/components/utils/DateUtils.tsx index 07b144555..549904687 100644 --- a/client/src/components/utils/DateUtils.tsx +++ b/client/src/components/utils/DateUtils.tsx @@ -129,5 +129,13 @@ export const DateUtils = { * @param date a date object * @returns a date string in the nz format `dd-mm-yyyy` */ - formattedNzDate: (date: Date): string => date.toLocaleDateString("en-NZ") + formattedNzDate: (date: Date): string => date.toLocaleDateString("en-NZ"), + + /** + * @param date the date to put into format for input + * @returns the date string for date input to parse + */ + formatDateForInput: (date?: Date) => { + return date?.toLocaleDateString("en-CA", { timeZone: "Pacific/Auckland" }) + } } as const diff --git a/client/src/firebase.ts b/client/src/firebase.ts index 77f0d2e6b..cef958961 100644 --- a/client/src/firebase.ts +++ b/client/src/firebase.ts @@ -3,7 +3,7 @@ import { initializeApp, type FirebaseOptions } from "firebase/app" import { getFirestore, connectFirestoreEmulator } from "firebase/firestore" import { ParsedToken, getAuth } from "firebase/auth" import { UserClaims } from "models/User" -import fetchClient, { setToken } from "services/OpenApiFetchClient" +import { setToken } from "services/OpenApiFetchClient" import { StoreInstance } from "store/Store" import { MembershipPaymentStore } from "store/MembershipPayment" import queryClient from "services/QueryClient" @@ -52,17 +52,7 @@ auth.onIdTokenChanged(async (user) => { // update fetch client token to use setToken(token) - // retrieve and update cached user data - let userData - try { - const { data } = await fetchClient.GET("/users/self") - userData = data - } catch (error) { - console.error( - `Failed to fetch user data during auth token change: ${error}` - ) - } - StoreInstance.actions.setCurrentUser(user, userData, claims as UserClaims) + StoreInstance.actions.setCurrentUser(user, claims as UserClaims) }) export { auth, db } diff --git a/client/src/pages/Profile/EditAdditionalPanel.tsx b/client/src/pages/Profile/EditAdditionalPanel.tsx new file mode 100644 index 000000000..a4ae1bcba --- /dev/null +++ b/client/src/pages/Profile/EditAdditionalPanel.tsx @@ -0,0 +1,36 @@ +import { ReducedUserAdditionalInfo } from "models/User" +import { useSelfDataQuery } from "services/User/UserQueries" +import WrappedProfileEdit, { IGeneralProfileEdit } from "./WrappedProfileEdit" + +type EditAdditionalFields = Pick< + Partial, + "does_snowboarding" | "does_ski" | "dietary_requirements" +> + +/** + * Allows the user to edit the miscellaneous information. + */ +const EditAdditionalPanel = ({ isOpen, handleClose }: IGeneralProfileEdit) => { + const { data: currentUserData } = useSelfDataQuery() + return ( + + isOpen={isOpen} + handleClose={handleClose} + fields={[ + { + fieldName: "dietary_requirements", + defaultFieldValue: currentUserData?.dietary_requirements + }, + { + fieldName: "does_ski", + defaultFieldValue: currentUserData?.does_ski + }, + { + fieldName: "does_snowboarding", + defaultFieldValue: currentUserData?.does_snowboarding + } + ]} + > + ) +} +export default EditAdditionalPanel diff --git a/client/src/pages/Profile/EditPersonalPanel.tsx b/client/src/pages/Profile/EditPersonalPanel.tsx new file mode 100644 index 000000000..e5175bff1 --- /dev/null +++ b/client/src/pages/Profile/EditPersonalPanel.tsx @@ -0,0 +1,61 @@ +import { ReducedUserAdditionalInfo } from "models/User" +import { useSelfDataQuery } from "services/User/UserQueries" +import WrappedProfileEdit, { IGeneralProfileEdit } from "./WrappedProfileEdit" + +type EditPersonalFields = Pick< + Partial, + | "first_name" + | "last_name" + | "gender" + | "student_id" + | "date_of_birth" + | "phone_number" + | "emergency_contact" +> + +/** + * Displays the fields required to edit the personal section + * + * TODO: extend to include editing auth details (email etc) + */ +const EditPersonalPanel = ({ isOpen, handleClose }: IGeneralProfileEdit) => { + const { data: currentUserData } = useSelfDataQuery() + return ( + + isOpen={isOpen} + handleClose={handleClose} + fields={[ + { + fieldName: "first_name", + defaultFieldValue: currentUserData?.first_name + }, + { + fieldName: "last_name", + defaultFieldValue: currentUserData?.last_name + }, + + { + fieldName: "gender", + defaultFieldValue: currentUserData?.gender + }, + { + fieldName: "date_of_birth", + defaultFieldValue: currentUserData?.date_of_birth + }, + { + fieldName: "phone_number", + defaultFieldValue: currentUserData?.phone_number + }, + { + fieldName: "emergency_contact", + defaultFieldValue: currentUserData?.emergency_contact + }, + { + fieldName: "student_id", + defaultFieldValue: currentUserData?.student_id + } + ]} + > + ) +} +export default EditPersonalPanel diff --git a/client/src/pages/Profile/Profile.tsx b/client/src/pages/Profile/Profile.tsx index eb5a01135..b3a5c2c2b 100644 --- a/client/src/pages/Profile/Profile.tsx +++ b/client/src/pages/Profile/Profile.tsx @@ -7,7 +7,18 @@ import { useForceRefreshToken } from "hooks/useRefreshedToken" import { signOut } from "firebase/auth" import { auth } from "firebase" import { DateUtils } from "components/utils/DateUtils" -import { useEffect, useMemo } from "react" +import { + Suspense, + lazy, + useCallback, + useEffect, + useMemo, + useState +} from "react" +import { useSelfDataQuery } from "services/User/UserQueries" + +const AsyncEditPersonalPanel = lazy(() => import("./EditPersonalPanel")) +const AsyncEditAdditionalPanel = lazy(() => import("./EditAdditionalPanel")) const SignOutButton = () => { const navigate = useNavigate() @@ -16,8 +27,6 @@ const SignOutButton = () => { navigate("/login") } - useForceRefreshToken() - return (
("none") + + const closePanel = useCallback(() => { + setEditPanelOpen("none") + }, []) + + useForceRefreshToken() + useEffect(() => { if (!currentUser) { navigate("/login") @@ -83,8 +104,20 @@ export default function Profile() { }, [currentUserClaims]) return ( -
+
+ + + + + +
@@ -97,7 +130,9 @@ export default function Profile() {
{}} + onEdit={() => { + setEditPanelOpen("personal") + }} >
- {} : undefined} - > + {}} + onEdit={() => { + setEditPanelOpen("additional") + }} > void + /** + * additional information about the fields + */ + fields: { + /** + * The name of the **key** in ReducedUserAdditionalInformation that corresponds to + * the field to be displayed + */ + fieldName: keyof Partial + /** + * The value that should be displayed when no edits have been made to the field + * + * Likely would be the original infomation fetched about the user + */ + defaultFieldValue?: ReducedUserAdditionalInfo[keyof ReducedUserAdditionalInfo] + }[] +} + +/** + * Should be used for any component that wraps the `WrappedProfileEdit` + * + * Contains the `onClose` handler and the `isOpen` status of the modal + */ +export interface IGeneralProfileEdit + extends Omit {} + +/** + * Includes all the app-specfic callbacks for the `ProfileEdit` component + * + * handles all alerting and mutations, this should futher be wrapped to specify + * what fields from `ReducedUserAdditionalInfo` should be included + */ +const WrappedProfileEdit = >({ + isOpen, + handleClose, + fields +}: IWrappedProfileEdit) => { + const { mutateAsync, error, isPending, isSuccess } = useEditSelfMutation() + + useEffect(() => { + if (error) { + alert(error.message) + } + }, [error]) + + useEffect(() => { + if (isSuccess) { + handleClose() + } + }, [isSuccess]) + + return ( + + + title="Personal Details" + isPending={isPending} + onClose={handleClose} + fields={fields} + onEdit={async (userData) => { + await mutateAsync(userData) + }} + /> + + ) +} +export default WrappedProfileEdit diff --git a/client/src/services/User/UserMutations.ts b/client/src/services/User/UserMutations.ts index 4b450eb96..432cf77eb 100644 --- a/client/src/services/User/UserMutations.ts +++ b/client/src/services/User/UserMutations.ts @@ -2,7 +2,8 @@ import { useMutation } from "@tanstack/react-query" import UserService from "./UserService" import { sendPasswordResetEmail, signInWithCustomToken } from "firebase/auth" import { auth } from "firebase" -import { ReducedUserAdditionalInfo } from "models/User" +import queryClient from "services/QueryClient" +import { SELF_DATA_QUERY_KEY } from "./UserQueries" const SIGN_UP_USER_MUTATION_KEY = "signUpUser" as const @@ -29,11 +30,13 @@ export function useSignUpUserMutation(signUpType: SignUpType = "member") { }) } -export function useEditSelfMutation( - userData: Partial -) { +export function useEditSelfMutation() { return useMutation({ mutationKey: ["editSelf"], - mutationFn: () => UserService.editSelf(userData) + mutationFn: UserService.editSelf, + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: [SELF_DATA_QUERY_KEY] + }) }) } diff --git a/client/src/services/User/UserQueries.ts b/client/src/services/User/UserQueries.ts new file mode 100644 index 000000000..a89961e6c --- /dev/null +++ b/client/src/services/User/UserQueries.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query" +import UserService from "./UserService" + +export const SELF_DATA_QUERY_KEY = "get-self" as const + +export function useSelfDataQuery() { + return useQuery({ + queryKey: [SELF_DATA_QUERY_KEY], + queryFn: UserService.getSelfData + }) +} diff --git a/client/src/services/User/UserService.ts b/client/src/services/User/UserService.ts index 764ff51e1..76f9ac4db 100644 --- a/client/src/services/User/UserService.ts +++ b/client/src/services/User/UserService.ts @@ -7,6 +7,16 @@ export type SignUpUserBody = { } const UserService = { + getSelfData: async function () { + const { data, response } = await fetchClient.GET("/users/self") + if (!response.ok) { + throw new Error( + "There was a problem fetching the user data for the current user" + ) + } + + return data + }, signUpUser: async function (userData: SignUpUserBody) { // gets data from signup and returns data (all data needed after signing up) const { data, response } = await fetchClient.POST("/signup", { diff --git a/client/src/store/Store.tsx b/client/src/store/Store.tsx index ca0cf1c18..501324e7e 100644 --- a/client/src/store/Store.tsx +++ b/client/src/store/Store.tsx @@ -1,5 +1,5 @@ import { User } from "firebase/auth" -import { UserAdditionalInfo, UserClaims } from "models/User" +import { UserClaims } from "models/User" import { defaultRegistry, createStore, @@ -9,14 +9,12 @@ import { type State = { currentUser: User | null // firebase type - currentUserData?: UserAdditionalInfo currentUserClaims?: UserClaims } const defaultUserState = { currentUser: null, - currentUserClaims: undefined, - currentUserData: undefined + currentUserClaims: undefined } const initialState: State = { @@ -25,15 +23,10 @@ const initialState: State = { const actions = { setCurrentUser: - ( - user: User | null, - userData: UserAdditionalInfo | undefined, - userClaims: UserClaims | undefined - ): Action => + (user: User | null, userClaims: UserClaims | undefined): Action => ({ setState }) => { setState({ currentUser: user, - currentUserData: userData, currentUserClaims: userClaims }) },