From 88f7bc2cbc4b4380398cba3ce8a0dd7e2ee4193f Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Thu, 3 Oct 2024 13:51:38 -0400 Subject: [PATCH 01/86] Multiple emails, semi working with js --- .../MyAccount/NewSettings/EmailForm.tsx | 159 ++++++++++++++++++ .../NewSettings/NewAccountSettingsTab.tsx | 23 +++ src/components/MyAccount/ProfileTabs.tsx | 3 +- .../MyAccount/Settings/AccountSettingsTab.tsx | 2 + src/components/MyAccount/TimedLogoutModal.tsx | 32 ++-- 5 files changed, 202 insertions(+), 17 deletions(-) create mode 100644 src/components/MyAccount/NewSettings/EmailForm.tsx create mode 100644 src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx diff --git a/src/components/MyAccount/NewSettings/EmailForm.tsx b/src/components/MyAccount/NewSettings/EmailForm.tsx new file mode 100644 index 000000000..60cf98e9c --- /dev/null +++ b/src/components/MyAccount/NewSettings/EmailForm.tsx @@ -0,0 +1,159 @@ +import { + Icon, + TextInput, + Text, + Flex, + Button, +} from "@nypl/design-system-react-components" +import { useState } from "react" + +const EmailForm = ({ patronData }) => { + console.log(patronData.emails) + const [emails, setEmails] = useState(patronData?.emails || []) + const [isEditing, setIsEditing] = useState(false) + const [error, setError] = useState(false) + const [tempEmails, setTempEmails] = useState([...emails]) + + const validateEmail = (email) => { + const emailRegex = /^[^@]+@[^@]+\.[^@]+$/ + return emailRegex.test(email) + } + + const handleInputChange = (e, index) => { + const { value } = e.target + const updatedEmails = [...tempEmails] + updatedEmails[index] = value + setTempEmails(updatedEmails) + setError(!validateEmail(value)) + } + + const saveEmails = () => { + const hasInvalidEmail = tempEmails.some((email) => !validateEmail(email)) + if (hasInvalidEmail) { + setError(true) + return + } + submitEmails(tempEmails) + setEmails(tempEmails) + setIsEditing(false) + } + + const submitEmails = async (emails) => { + const response = await fetch( + `/research/research-catalog/api/account/settings/${patronData.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ emails }), + } + ) + if (response.status === 200) { + console.log("yay") + } else { + console.log(response) + } + } + + const cancelEditing = () => { + setTempEmails([...emails]) + setIsEditing(false) + setError(false) + } + + const addEmailField = () => { + setTempEmails([...tempEmails, ""]) + } + + return ( + <> + + + + Email + + + + {isEditing ? ( + + {tempEmails.map((email, index) => ( + + handleInputChange(e, index)} + isRequired + isClearable + sx={{ marginRight: "xl", width: "300px" }} + /> + {index !== 0 && ( + + )} + + ))} + + + ) : ( + + {emails[0]} + + + )} + + {isEditing && ( + + + + + )} + + ) +} + +export default EmailForm diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx new file mode 100644 index 000000000..6af7ff76a --- /dev/null +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -0,0 +1,23 @@ +import { SkeletonLoader, Flex } from "@nypl/design-system-react-components" +import { useContext, useState } from "react" +import { PatronDataContext } from "../../../context/PatronDataContext" +import EmailForm from "./EmailForm" + +const NewAccountSettingsTab = () => { + const { + patronDataLoading, + getMostUpdatedSierraAccountData, + updatedAccountData: { patron, pickupLocations }, + } = useContext(PatronDataContext) + const [isLoading, setIsLoading] = useState(false) + + return isLoading ? ( + + ) : ( + + + + ) +} + +export default NewAccountSettingsTab diff --git a/src/components/MyAccount/ProfileTabs.tsx b/src/components/MyAccount/ProfileTabs.tsx index b223d48c4..d573320c7 100644 --- a/src/components/MyAccount/ProfileTabs.tsx +++ b/src/components/MyAccount/ProfileTabs.tsx @@ -7,6 +7,7 @@ import RequestsTab from "./RequestsTab/RequestsTab" import FeesTab from "./FeesTab/FeesTab" import { PatronDataContext } from "../../context/PatronDataContext" import { useContext } from "react" +import NewAccountSettingsTab from "./NewSettings/NewAccountSettingsTab" interface ProfileTabsPropsType { activePath: string @@ -49,7 +50,7 @@ const ProfileTabs = ({ activePath }: ProfileTabsPropsType) => { : []), { label: "Account settings", - content: , + content: , urlPath: "settings", }, ] diff --git a/src/components/MyAccount/Settings/AccountSettingsTab.tsx b/src/components/MyAccount/Settings/AccountSettingsTab.tsx index fff07890a..de54c58e9 100644 --- a/src/components/MyAccount/Settings/AccountSettingsTab.tsx +++ b/src/components/MyAccount/Settings/AccountSettingsTab.tsx @@ -61,6 +61,7 @@ const AccountSettingsTab = () => { e.preventDefault() setIsLoading(true) const payload = parseAccountSettingsPayload(e.target, patron) + console.log(payload) const response = await fetch( `/research/research-catalog/api/account/settings/${patron.id}`, { @@ -71,6 +72,7 @@ const AccountSettingsTab = () => { body: JSON.stringify(payload), } ) + console.log(response) if (response.status === 200) { await getMostUpdatedSierraAccountData() setCurrentlyEditing(false) diff --git a/src/components/MyAccount/TimedLogoutModal.tsx b/src/components/MyAccount/TimedLogoutModal.tsx index 8ccc99fba..1d5a4ab9a 100644 --- a/src/components/MyAccount/TimedLogoutModal.tsx +++ b/src/components/MyAccount/TimedLogoutModal.tsx @@ -48,23 +48,23 @@ const TimedLogoutModal = ({ // logOutAndRedirect() // } - useEffect(() => { - const timeout = setTimeout(() => { - const { minutes, seconds } = buildTimeLeft(expirationTime) - if (minutes < timeoutWindow) setOpen(true) - setTimeUntilExpiration({ - minutes, - seconds, - }) - }, 1000) - return () => { - clearTimeout(timeout) - } - }) + // useEffect(() => { + // const timeout = setTimeout(() => { + // const { minutes, seconds } = buildTimeLeft(expirationTime) + // if (minutes < timeoutWindow) setOpen(true) + // setTimeUntilExpiration({ + // minutes, + // seconds, + // }) + // }, 1000) + // return () => { + // clearTimeout(timeout) + // } + // }) - if (timeUntilExpiration.minutes <= 0 && timeUntilExpiration.seconds <= 0) { - logOutAndRedirect() - } + // if (timeUntilExpiration.minutes <= 0 && timeUntilExpiration.seconds <= 0) { + // logOutAndRedirect() + // } if (!open) return null return ( From ebdc5a26a474fb9f20c922d2351d017e0588e771 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:08:14 -0400 Subject: [PATCH 02/86] Cleaning up form and form validation --- .../MyAccount/NewSettings/EmailForm.tsx | 217 +++++++++++------- .../NewSettings/NewAccountSettingsTab.tsx | 35 ++- 2 files changed, 157 insertions(+), 95 deletions(-) diff --git a/src/components/MyAccount/NewSettings/EmailForm.tsx b/src/components/MyAccount/NewSettings/EmailForm.tsx index 60cf98e9c..e41141a06 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.tsx @@ -5,13 +5,15 @@ import { Flex, Button, } from "@nypl/design-system-react-components" -import { useState } from "react" +import { useContext, useState } from "react" +import { PatronDataContext } from "../../../context/PatronDataContext" -const EmailForm = ({ patronData }) => { - console.log(patronData.emails) +const EmailForm = ({ patronData, setIsLoading, setIsSuccess }) => { + const { getMostUpdatedSierraAccountData } = useContext(PatronDataContext) const [emails, setEmails] = useState(patronData?.emails || []) const [isEditing, setIsEditing] = useState(false) const [error, setError] = useState(false) + const [tempEmails, setTempEmails] = useState([...emails]) const validateEmail = (email) => { @@ -19,40 +21,66 @@ const EmailForm = ({ patronData }) => { return emailRegex.test(email) } + const handleRemoveEmail = (index) => { + const updatedEmails = tempEmails.filter((_, i) => i !== index) + setTempEmails(updatedEmails) + + // Immediately revalidate remaining emails. + const hasInvalidEmail = updatedEmails.some((email) => !validateEmail(email)) + setError(hasInvalidEmail) + } + const handleInputChange = (e, index) => { const { value } = e.target const updatedEmails = [...tempEmails] updatedEmails[index] = value setTempEmails(updatedEmails) - setError(!validateEmail(value)) - } - const saveEmails = () => { - const hasInvalidEmail = tempEmails.some((email) => !validateEmail(email)) - if (hasInvalidEmail) { + // The first email entry cannot be empty. + if (index === 0 && (!value || !validateEmail(value))) { setError(true) - return + } else { + const hasInvalidEmail = updatedEmails.some( + (email) => !validateEmail(email) + ) + setError(hasInvalidEmail) } - submitEmails(tempEmails) - setEmails(tempEmails) - setIsEditing(false) } - const submitEmails = async (emails) => { - const response = await fetch( - `/research/research-catalog/api/account/settings/${patronData.id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ emails }), + const handleClearableCallback = (index) => { + const updatedEmails = [...tempEmails] + updatedEmails[index] = "" + setTempEmails(updatedEmails) + setError(true) + } + + const submitEmails = async () => { + setIsLoading(true) + const validEmails = tempEmails.filter((email) => validateEmail(email)) + + try { + const response = await fetch( + `/research/research-catalog/api/account/settings/${patronData.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ emails: validEmails }), + } + ) + + if (response.status === 200) { + await getMostUpdatedSierraAccountData() + setIsSuccess(true) + setIsEditing(false) + } else { + setTempEmails([...emails]) } - ) - if (response.status === 200) { - console.log("yay") - } else { - console.log(response) + } catch (error) { + console.error("Error submitting emails:", error) + } finally { + setIsLoading(false) } } @@ -63,79 +91,94 @@ const EmailForm = ({ patronData }) => { } const addEmailField = () => { - setTempEmails([...tempEmails, ""]) + const updatedEmails = [...tempEmails, ""] + setTempEmails(updatedEmails) + + // Immediately revalidate all emails. + const hasInvalidEmail = updatedEmails.some((email) => !validateEmail(email)) + setError(hasInvalidEmail) } return ( - <> - + + Email - - {isEditing ? ( - - {tempEmails.map((email, index) => ( - - handleInputChange(e, index)} - isRequired - isClearable - sx={{ marginRight: "xl", width: "300px" }} - /> - {index !== 0 && ( - - )} - + {isEditing ? ( + + {tempEmails.map((email, index) => ( + + handleInputChange(e, index)} + isRequired + isClearable + isClearableCallback={() => handleClearableCallback(index)} + sx={{ width: "300px" }} + /> + {index !== 0 && ( + + )} + + ))} + + + ) : ( + + + {emails.map((email, index) => ( + + {email}{" "} + {index === 0 && (P)} + ))} - - ) : ( - - {emails[0]} - - - )} - + + + )} {isEditing && ( - + )} - + ) } diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index 6af7ff76a..a7baa0fa4 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -1,22 +1,41 @@ -import { SkeletonLoader, Flex } from "@nypl/design-system-react-components" +import { + SkeletonLoader, + Flex, + Banner, +} from "@nypl/design-system-react-components" import { useContext, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" import EmailForm from "./EmailForm" const NewAccountSettingsTab = () => { const { - patronDataLoading, - getMostUpdatedSierraAccountData, - updatedAccountData: { patron, pickupLocations }, + updatedAccountData: { patron }, } = useContext(PatronDataContext) const [isLoading, setIsLoading] = useState(false) + const [isSuccess, setIsSuccess] = useState(false) return isLoading ? ( - + ) : ( - - - + <> + {isSuccess && ( + Your changes were saved. + } + type="positive" + sx={{ marginTop: "m" }} + /> + )} + + + + ) } From 1f1565eb87fe08558cc1e1f1ccd11149c5df5c19 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Fri, 4 Oct 2024 14:08:35 -0400 Subject: [PATCH 03/86] Unit tests for email form --- .../MyAccount/NewSettings/EmailForm.test.tsx | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/components/MyAccount/NewSettings/EmailForm.test.tsx diff --git a/src/components/MyAccount/NewSettings/EmailForm.test.tsx b/src/components/MyAccount/NewSettings/EmailForm.test.tsx new file mode 100644 index 000000000..5c10d6397 --- /dev/null +++ b/src/components/MyAccount/NewSettings/EmailForm.test.tsx @@ -0,0 +1,135 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import EmailForm from "./EmailForm" +import { filteredPickupLocations } from "../../../../__test__/fixtures/processedMyAccountData" +import { PatronDataProvider } from "../../../context/PatronDataContext" +import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" + +describe("email form", () => { + const mockSetIsLoading = jest.fn() + const mockSetIsSuccess = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + global.fetch = jest.fn().mockResolvedValue({ + json: async () => { + console.log("Updated") + }, + status: 200, + } as Response) + }) + + const component = ( + + + + ) + + it("renders correctly with initial email", () => { + render(component) + + expect(screen.getByText("streganonna@gmail.com")).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument() + }) + + it("allows editing when edit button is clicked", () => { + render(component) + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + expect(screen.getAllByLabelText("Update email")[0]).toBeInTheDocument() + expect( + screen.getByDisplayValue("streganonna@gmail.com") + ).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument() + expect( + screen.getByRole("button", { name: /save changes/i }) + ).toBeInTheDocument() + }) + + it("validates email input correctly", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getAllByLabelText("Update email")[0] + fireEvent.change(input, { target: { value: "invalid-email" } }) + + expect( + screen.getByText("Please enter a valid email address.") + ).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled() + }) + + it("allows adding a new email field", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + fireEvent.click( + screen.getByRole("button", { name: /\+ add an email address/i }) + ) + + expect(screen.getAllByLabelText("Update email").length).toBe(3) + }) + + it("removes an email when delete icon is clicked", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + fireEvent.click(screen.getByLabelText("Remove email")) + + expect( + screen.queryByDisplayValue("spaghettigrandma@gmail.com") + ).not.toBeInTheDocument() + }) + + it("calls submitEmails with valid data", async () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getAllByLabelText("Update email")[0] + fireEvent.change(input, { target: { value: "newemail@example.com" } }) + + fireEvent.click(screen.getByRole("button", { name: /save changes/i })) + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)) + + expect(fetch).toHaveBeenCalledWith( + "/research/research-catalog/api/account/settings/6742743", + expect.objectContaining({ + body: '{"emails":["newemail@example.com","spaghettigrandma@gmail.com"]}', + headers: { "Content-Type": "application/json" }, + method: "PUT", + }) + ) + }) + + it("cancels editing and reverts state", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getAllByLabelText("Update email")[0] + fireEvent.change(input, { target: { value: "modified@example.com" } }) + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })) + + expect(screen.getByText("streganonna@gmail.com")).toBeInTheDocument() + expect( + screen.queryByDisplayValue("modified@example.com") + ).not.toBeInTheDocument() + }) +}) From 06e1343ab0988c4d9c8b07dee5619d445b7ca399 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:30:11 -0400 Subject: [PATCH 04/86] With no js form validation --- pages/account/[[...index]].tsx | 50 ++++++++++++-- pages/api/account/settings/[id].ts | 36 ++++++++++ .../NewSettings/NewAccountSettingsTab.tsx | 22 +++++-- .../MyAccount/NewSettings/NoJsEmailForm.tsx | 65 +++++++++++++++++++ 4 files changed, 162 insertions(+), 11 deletions(-) create mode 100644 src/components/MyAccount/NewSettings/NoJsEmailForm.tsx diff --git a/pages/account/[[...index]].tsx b/pages/account/[[...index]].tsx index 094a92085..cb8415405 100644 --- a/pages/account/[[...index]].tsx +++ b/pages/account/[[...index]].tsx @@ -1,4 +1,4 @@ -import { Text } from "@nypl/design-system-react-components" +import { Banner, Text } from "@nypl/design-system-react-components" import Head from "next/head" import Layout from "../../src/components/Layout/Layout" @@ -18,6 +18,7 @@ interface MyAccountPropsType { isAuthenticated: boolean tabsPath?: string renderAuthServerError?: boolean + storedQueryString? } export default function MyAccount({ @@ -25,6 +26,7 @@ export default function MyAccount({ accountData, isAuthenticated, tabsPath, + storedQueryString, }: MyAccountPropsType) { const errorRetrievingPatronData = !accountData.patron @@ -50,6 +52,31 @@ export default function MyAccount({ assistance. ) + + function formValidationMessage(storedQueryString) { + const queryString = decodeURIComponent(storedQueryString) + const type = queryString.split("=")[0] || "" + const message = queryString.split("=")[1] || "" + if (type === "success") { + return ( + + ) + } else { + return ( + + ) + } + } useEffect(() => { resetCountdown() // to avoid a reference error on document in the modal, wait to render it @@ -76,6 +103,7 @@ export default function MyAccount({ serverError ) : ( + {storedQueryString && formValidationMessage(storedQueryString)} )} @@ -111,16 +139,25 @@ export async function getServerSideProps({ req, res }) { }, } } - // Parsing path from url to pass to ProfileTabs. + + // Parsing path and query from URL const tabsPathRegex = /\/account\/(.+)/ const match = req.url.match(tabsPathRegex) + const queryString = req.url.split("?")[1] || "" // Extract query string const tabsPath = match ? match[1] : null const id = patronTokenResponse.decodedPatron.sub + try { const { checkouts, holds, patron, fines, pickupLocations } = await getPatronData(id) - /* Redirecting invalid paths (including /overdues if user has none) and - // cleaning extra parts off valid paths. */ + // Collect query string from non-JS form submission. + if (queryString) { + res.setHeader( + "Set-Cookie", + `queryParams=${encodeURIComponent(queryString)}; Path=/; HttpOnly` + ) + } + // Redirecting invalid paths and cleaning extra parts off valid paths. if (tabsPath) { const allowedPaths = ["items", "requests", "overdues", "settings"] if ( @@ -137,7 +174,7 @@ export async function getServerSideProps({ req, res }) { const matchedPath = allowedPaths.find((path) => tabsPath.startsWith(path) ) - if (tabsPath != matchedPath) { + if (tabsPath !== matchedPath) { return { redirect: { destination: "/account/" + matchedPath, @@ -147,11 +184,14 @@ export async function getServerSideProps({ req, res }) { } } } + + const storedQueryString = req.cookies.queryParams || "" return { props: { accountData: { checkouts, holds, patron, fines, pickupLocations }, tabsPath, isAuthenticated, + storedQueryString, renderAuthServerError: !redirectBasedOnNyplAccountRedirects, }, } diff --git a/pages/api/account/settings/[id].ts b/pages/api/account/settings/[id].ts index f6c4af046..c1ef37087 100644 --- a/pages/api/account/settings/[id].ts +++ b/pages/api/account/settings/[id].ts @@ -22,6 +22,42 @@ export default async function handler( if (req.method == "GET") { responseMessage = "Please make a PUT request to this endpoint." } + if (req.method === "POST" && req.body._method === "PUT") { + const patronId = req.query.id as string + const patronData = req.body.emails + + const emailRegex = /^[^@]+@[^@]+\.[^@]+$/ + const invalidEmails = patronData.filter((email) => !emailRegex.test(email)) + + if (invalidEmails.length > 0) { + const errorMessage = `Please enter a valid email. Invalid emails: ${invalidEmails.join( + ", " + )}` + res.writeHead(302, { + Location: `/research/research-catalog/account/settings?error=${encodeURIComponent( + errorMessage + )}`, + }) + res.end() + return + } + + if (patronId == cookiePatronId) { + const response = await updatePatronSettings(patronId, { + emails: patronData, + }) + responseStatus = response.status + responseMessage = response.message + } else { + responseStatus = 403 + responseMessage = "Authenticated patron does not match request" + } + res.writeHead(302, { + Location: "/research/research-catalog/account/settings?success=true", + }) + res.end() + return + } if (req.method == "PUT") { /** We get the patron id from the request: */ const patronId = req.query.id as string diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index a7baa0fa4..9f71623fc 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -3,9 +3,10 @@ import { Flex, Banner, } from "@nypl/design-system-react-components" -import { useContext, useState } from "react" +import { useContext, useEffect, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" import EmailForm from "./EmailForm" +import NoJsEmailForm from "./NoJsEmailForm" const NewAccountSettingsTab = () => { const { @@ -13,6 +14,11 @@ const NewAccountSettingsTab = () => { } = useContext(PatronDataContext) const [isLoading, setIsLoading] = useState(false) const [isSuccess, setIsSuccess] = useState(false) + const [isJsEnabled, setIsJsEnabled] = useState(false) + + useEffect(() => { + setIsJsEnabled(true) + }, []) return isLoading ? ( @@ -29,11 +35,15 @@ const NewAccountSettingsTab = () => { /> )} - + {isJsEnabled ? ( + + ) : ( + + )} ) diff --git a/src/components/MyAccount/NewSettings/NoJsEmailForm.tsx b/src/components/MyAccount/NewSettings/NoJsEmailForm.tsx new file mode 100644 index 000000000..168146b5c --- /dev/null +++ b/src/components/MyAccount/NewSettings/NoJsEmailForm.tsx @@ -0,0 +1,65 @@ +import { Button, Icon, TextInput } from "@nypl/design-system-react-components" + +const NoJsEmailForm = ({ patronData }) => { + const tempEmails = patronData?.emails || [] + + return ( + <> +
+ +
+

+ Email +

+
+ {tempEmails.map((email, index) => ( + + ))} +
+ +
+
+ + ) +} + +export default NoJsEmailForm From 54e668bebbea5bffedbbb6e71149006959d236f0 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:26:21 -0400 Subject: [PATCH 05/86] Clean up components --- pages/account/[[...index]].tsx | 34 ++----- pages/api/account/settings/[id].ts | 67 ++++++++++--- .../MyAccount/NewSettings/EmailForm.tsx | 7 +- .../MyAccount/NewSettings/NoJsEmailForm.tsx | 94 ++++++++++++++----- .../NewSettings/NoJsFormValidationMessage.tsx | 26 +++++ 5 files changed, 163 insertions(+), 65 deletions(-) create mode 100644 src/components/MyAccount/NewSettings/NoJsFormValidationMessage.tsx diff --git a/pages/account/[[...index]].tsx b/pages/account/[[...index]].tsx index cb8415405..fca3eec1a 100644 --- a/pages/account/[[...index]].tsx +++ b/pages/account/[[...index]].tsx @@ -13,6 +13,7 @@ import TimedLogoutModal from "../../src/components/MyAccount/TimedLogoutModal" import { getIncrementedTime } from "../../src/utils/cookieUtils" import { useEffect, useState } from "react" import { getPatronData } from "../api/account/[id]" +import FormValidationMessage from "../../src/components/MyAccount/NewSettings/NoJsFormValidationMessage" interface MyAccountPropsType { accountData: MyAccountPatronData isAuthenticated: boolean @@ -53,30 +54,6 @@ export default function MyAccount({ ) - function formValidationMessage(storedQueryString) { - const queryString = decodeURIComponent(storedQueryString) - const type = queryString.split("=")[0] || "" - const message = queryString.split("=")[1] || "" - if (type === "success") { - return ( - - ) - } else { - return ( - - ) - } - } useEffect(() => { resetCountdown() // to avoid a reference error on document in the modal, wait to render it @@ -103,7 +80,9 @@ export default function MyAccount({ serverError ) : ( - {storedQueryString && formValidationMessage(storedQueryString)} + {storedQueryString && ( + + )} )} @@ -143,7 +122,8 @@ export async function getServerSideProps({ req, res }) { // Parsing path and query from URL const tabsPathRegex = /\/account\/(.+)/ const match = req.url.match(tabsPathRegex) - const queryString = req.url.split("?")[1] || "" // Extract query string + const queryString = req.url.split("?")[1] || null + console.log(queryString) const tabsPath = match ? match[1] : null const id = patronTokenResponse.decodedPatron.sub @@ -186,6 +166,8 @@ export async function getServerSideProps({ req, res }) { } const storedQueryString = req.cookies.queryParams || "" + // Removing query string cookie so it doesn't persist on reload. + res.setHeader("Set-Cookie", "queryParams=; Path=/; HttpOnly; Max-Age=0") return { props: { accountData: { checkouts, holds, patron, fines, pickupLocations }, diff --git a/pages/api/account/settings/[id].ts b/pages/api/account/settings/[id].ts index c1ef37087..4be582bf4 100644 --- a/pages/api/account/settings/[id].ts +++ b/pages/api/account/settings/[id].ts @@ -22,13 +22,27 @@ export default async function handler( if (req.method == "GET") { responseMessage = "Please make a PUT request to this endpoint." } - if (req.method === "POST" && req.body._method === "PUT") { + + // Account settings form submission without JS. + if (req.method === "POST") { + const action = req.body.delete ? "delete" : "save" const patronId = req.query.id as string - const patronData = req.body.emails + const patronData = req.body.emails.filter((email) => email.trim() !== "") + if (action === "delete") { + const indexToDelete = parseInt(req.body.delete, 10) + if (indexToDelete >= 0) { + patronData.splice(indexToDelete, 1) + } + } const emailRegex = /^[^@]+@[^@]+\.[^@]+$/ - const invalidEmails = patronData.filter((email) => !emailRegex.test(email)) + // Filter out invalid emails + const invalidEmails = patronData.filter( + (email) => email && !emailRegex.test(email) + ) + + // If there are invalid emails, redirect back with an error message if (invalidEmails.length > 0) { const errorMessage = `Please enter a valid email. Invalid emails: ${invalidEmails.join( ", " @@ -42,22 +56,47 @@ export default async function handler( return } - if (patronId == cookiePatronId) { - const response = await updatePatronSettings(patronId, { - emails: patronData, + // Remove duplicate emails while keeping the first + const uniqueEmails = patronData.reduce((acc, email) => { + const normalizedEmail = email.toLowerCase() + if (!acc.includes(normalizedEmail)) { + acc.push(normalizedEmail) + } + return acc + }, []) + + const duplicateEmails = patronData.length !== uniqueEmails.length + + if (duplicateEmails) { + const errorMessage = "Cannot use duplicate emails." + res.writeHead(302, { + Location: `/research/research-catalog/account/settings?error=${encodeURIComponent( + errorMessage + )}`, }) - responseStatus = response.status - responseMessage = response.message - } else { - responseStatus = 403 - responseMessage = "Authenticated patron does not match request" + res.end() + return } - res.writeHead(302, { - Location: "/research/research-catalog/account/settings?success=true", + + const response = await updatePatronSettings(patronId, { + emails: patronData.filter((email) => email !== ""), }) + if (response.status === 200) { + res.writeHead(302, { + Location: "/research/research-catalog/account/settings?success=true", + }) + } else { + const errorMessage = `Error updating emails: ${response.message}` + res.writeHead(302, { + Location: `/research/research-catalog/account/settings?error=${encodeURIComponent( + errorMessage + )}`, + }) + } + res.end() - return } + if (req.method == "PUT") { /** We get the patron id from the request: */ const patronId = req.query.id as string diff --git a/src/components/MyAccount/NewSettings/EmailForm.tsx b/src/components/MyAccount/NewSettings/EmailForm.tsx index e41141a06..bb1dd5c77 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.tsx @@ -149,7 +149,12 @@ const EmailForm = ({ patronData, setIsLoading, setIsSuccess }) => { id="add-button" buttonType="text" onClick={addEmailField} - sx={{ justifyContent: "flex-start", width: "300px" }} + size="large" + sx={{ + justifyContent: "flex-start", + width: "300px", + padding: "xxs", + }} > + Add an email address diff --git a/src/components/MyAccount/NewSettings/NoJsEmailForm.tsx b/src/components/MyAccount/NewSettings/NoJsEmailForm.tsx index 168146b5c..285553418 100644 --- a/src/components/MyAccount/NewSettings/NoJsEmailForm.tsx +++ b/src/components/MyAccount/NewSettings/NoJsEmailForm.tsx @@ -1,4 +1,9 @@ -import { Button, Icon, TextInput } from "@nypl/design-system-react-components" +import { + Button, + Icon, + TextInput, + Text, +} from "@nypl/design-system-react-components" const NoJsEmailForm = ({ patronData }) => { const tempEmails = patronData?.emails || [] @@ -10,52 +15,93 @@ const NoJsEmailForm = ({ patronData }) => { method="POST" style={{ width: "100%" }} > -
-

- Email -

+

+ Email +

+
{tempEmails.map((email, index) => ( - + style={{ + display: "flex", + flexDirection: "row", + alignItems: "center", + gap: "8px", + }} + > + + {index === 0 && (P)} + {index > 0 && ( + + )} +
))} + + {" "} + Add an email address:{" "} + + + +
+
- diff --git a/src/components/MyAccount/NewSettings/NoJsFormValidationMessage.tsx b/src/components/MyAccount/NewSettings/NoJsFormValidationMessage.tsx new file mode 100644 index 000000000..215ea8746 --- /dev/null +++ b/src/components/MyAccount/NewSettings/NoJsFormValidationMessage.tsx @@ -0,0 +1,26 @@ +import { Banner } from "@nypl/design-system-react-components" + +const FormValidationMessage = ({ storedQueryString }) => { + const queryString = decodeURIComponent(storedQueryString) + const type = queryString.split("=")[0] || "" + const message = queryString.split("=")[1] || "" + if (type === "success") { + return ( + + ) + } else { + return ( + + ) + } +} + +export default FormValidationMessage From 36dd68b1343c47525324b2ba3b422360a561f183 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:27:35 -0400 Subject: [PATCH 06/86] Add the logout modal back in --- src/components/MyAccount/TimedLogoutModal.tsx | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/components/MyAccount/TimedLogoutModal.tsx b/src/components/MyAccount/TimedLogoutModal.tsx index 1d5a4ab9a..6850462b1 100644 --- a/src/components/MyAccount/TimedLogoutModal.tsx +++ b/src/components/MyAccount/TimedLogoutModal.tsx @@ -41,30 +41,30 @@ const TimedLogoutModal = ({ buildTimeLeft(expirationTime) ) - // if ( - // typeof document !== "undefined" && - // !document.cookie.includes("accountPageExp") - // ) { - // logOutAndRedirect() - // } + if ( + typeof document !== "undefined" && + !document.cookie.includes("accountPageExp") + ) { + logOutAndRedirect() + } - // useEffect(() => { - // const timeout = setTimeout(() => { - // const { minutes, seconds } = buildTimeLeft(expirationTime) - // if (minutes < timeoutWindow) setOpen(true) - // setTimeUntilExpiration({ - // minutes, - // seconds, - // }) - // }, 1000) - // return () => { - // clearTimeout(timeout) - // } - // }) + useEffect(() => { + const timeout = setTimeout(() => { + const { minutes, seconds } = buildTimeLeft(expirationTime) + if (minutes < timeoutWindow) setOpen(true) + setTimeUntilExpiration({ + minutes, + seconds, + }) + }, 1000) + return () => { + clearTimeout(timeout) + } + }) - // if (timeUntilExpiration.minutes <= 0 && timeUntilExpiration.seconds <= 0) { - // logOutAndRedirect() - // } + if (timeUntilExpiration.minutes <= 0 && timeUntilExpiration.seconds <= 0) { + logOutAndRedirect() + } if (!open) return null return ( From 441110fab3438ef38c8dbd449d9c96691f395fb5 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 8 Oct 2024 16:46:28 -0400 Subject: [PATCH 07/86] Cleaning up again for readability --- pages/account/[[...index]].tsx | 7 ++- pages/api/account/settings/[id].ts | 6 +-- .../MyAccount/NewSettings/EmailForm.tsx | 50 +++++++++---------- .../MyAccount/NewSettings/NoJsEmailForm.tsx | 4 +- .../NewSettings/NoJsFormValidationMessage.tsx | 24 +++------ .../MyAccount/Settings/AccountSettingsTab.tsx | 2 - 6 files changed, 39 insertions(+), 54 deletions(-) diff --git a/pages/account/[[...index]].tsx b/pages/account/[[...index]].tsx index fca3eec1a..2a8c96290 100644 --- a/pages/account/[[...index]].tsx +++ b/pages/account/[[...index]].tsx @@ -1,4 +1,4 @@ -import { Banner, Text } from "@nypl/design-system-react-components" +import { Text } from "@nypl/design-system-react-components" import Head from "next/head" import Layout from "../../src/components/Layout/Layout" @@ -123,7 +123,6 @@ export async function getServerSideProps({ req, res }) { const tabsPathRegex = /\/account\/(.+)/ const match = req.url.match(tabsPathRegex) const queryString = req.url.split("?")[1] || null - console.log(queryString) const tabsPath = match ? match[1] : null const id = patronTokenResponse.decodedPatron.sub @@ -154,7 +153,7 @@ export async function getServerSideProps({ req, res }) { const matchedPath = allowedPaths.find((path) => tabsPath.startsWith(path) ) - if (tabsPath !== matchedPath) { + if (tabsPath != matchedPath) { return { redirect: { destination: "/account/" + matchedPath, @@ -165,8 +164,8 @@ export async function getServerSideProps({ req, res }) { } } + // Saving the string and removing the cookie so it doesn't persist on reload. const storedQueryString = req.cookies.queryParams || "" - // Removing query string cookie so it doesn't persist on reload. res.setHeader("Set-Cookie", "queryParams=; Path=/; HttpOnly; Max-Age=0") return { props: { diff --git a/pages/api/account/settings/[id].ts b/pages/api/account/settings/[id].ts index 4be582bf4..7d54fdba7 100644 --- a/pages/api/account/settings/[id].ts +++ b/pages/api/account/settings/[id].ts @@ -58,9 +58,9 @@ export default async function handler( // Remove duplicate emails while keeping the first const uniqueEmails = patronData.reduce((acc, email) => { - const normalizedEmail = email.toLowerCase() - if (!acc.includes(normalizedEmail)) { - acc.push(normalizedEmail) + const lowercaseEmail = email.toLowerCase() + if (!acc.includes(lowercaseEmail)) { + acc.push(lowercaseEmail) } return acc }, []) diff --git a/src/components/MyAccount/NewSettings/EmailForm.tsx b/src/components/MyAccount/NewSettings/EmailForm.tsx index bb1dd5c77..2fcc01134 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.tsx @@ -21,15 +21,6 @@ const EmailForm = ({ patronData, setIsLoading, setIsSuccess }) => { return emailRegex.test(email) } - const handleRemoveEmail = (index) => { - const updatedEmails = tempEmails.filter((_, i) => i !== index) - setTempEmails(updatedEmails) - - // Immediately revalidate remaining emails. - const hasInvalidEmail = updatedEmails.some((email) => !validateEmail(email)) - setError(hasInvalidEmail) - } - const handleInputChange = (e, index) => { const { value } = e.target const updatedEmails = [...tempEmails] @@ -47,6 +38,24 @@ const EmailForm = ({ patronData, setIsLoading, setIsSuccess }) => { } } + const handleRemoveEmail = (index) => { + const updatedEmails = tempEmails.filter((_, i) => i !== index) + setTempEmails(updatedEmails) + + // Immediately revalidate remaining emails. + const hasInvalidEmail = updatedEmails.some((email) => !validateEmail(email)) + setError(hasInvalidEmail) + } + + const handleAddEmail = () => { + const updatedEmails = [...tempEmails, ""] + setTempEmails(updatedEmails) + + // Immediately revalidate all emails. + const hasInvalidEmail = updatedEmails.some((email) => !validateEmail(email)) + setError(hasInvalidEmail) + } + const handleClearableCallback = (index) => { const updatedEmails = [...tempEmails] updatedEmails[index] = "" @@ -54,6 +63,12 @@ const EmailForm = ({ patronData, setIsLoading, setIsSuccess }) => { setError(true) } + const cancelEditing = () => { + setTempEmails([...emails]) + setIsEditing(false) + setError(false) + } + const submitEmails = async () => { setIsLoading(true) const validEmails = tempEmails.filter((email) => validateEmail(email)) @@ -84,21 +99,6 @@ const EmailForm = ({ patronData, setIsLoading, setIsSuccess }) => { } } - const cancelEditing = () => { - setTempEmails([...emails]) - setIsEditing(false) - setError(false) - } - - const addEmailField = () => { - const updatedEmails = [...tempEmails, ""] - setTempEmails(updatedEmails) - - // Immediately revalidate all emails. - const hasInvalidEmail = updatedEmails.some((email) => !validateEmail(email)) - setError(hasInvalidEmail) - } - return ( @@ -148,7 +148,7 @@ const EmailForm = ({ patronData, setIsLoading, setIsSuccess }) => { - )} - - ))} - - Add an email address: - - - -
- -
- - - - ) -} - -export default NoJsEmailForm diff --git a/src/components/MyAccount/NewSettings/NoJsFormValidationMessage.tsx b/src/components/MyAccount/NewSettings/NoJsFormValidationMessage.tsx deleted file mode 100644 index e7484f180..000000000 --- a/src/components/MyAccount/NewSettings/NoJsFormValidationMessage.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Banner } from "@nypl/design-system-react-components" - -const FormValidationMessage = ({ storedQueryString }) => { - const queryString = decodeURIComponent(storedQueryString) - const type = queryString.split("=")[0] || "" - const message = queryString.split("=")[1] || "" - return ( - - ) -} - -export default FormValidationMessage From 5cf94f98b249909ab382001e2573166396dff38c Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Thu, 10 Oct 2024 12:32:38 -0400 Subject: [PATCH 10/86] Missed one --- pages/account/[[...index]].tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/pages/account/[[...index]].tsx b/pages/account/[[...index]].tsx index 79c5aafbb..e2825ef5e 100644 --- a/pages/account/[[...index]].tsx +++ b/pages/account/[[...index]].tsx @@ -18,7 +18,6 @@ interface MyAccountPropsType { isAuthenticated: boolean tabsPath?: string renderAuthServerError?: boolean - storedQueryString? } export default function MyAccount({ From dea43071f0c8ae88afcfeb92cd35778c7e2b910e Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 15 Oct 2024 10:29:21 -0400 Subject: [PATCH 11/86] Updating loading and pulling out components --- .../MyAccount/NewSettings/EmailForm.tsx | 235 ++++++++++-------- .../NewSettings/NewAccountSettingsTab.tsx | 17 +- .../NewSettings/SaveCancelButtons.tsx | 33 +++ 3 files changed, 170 insertions(+), 115 deletions(-) create mode 100644 src/components/MyAccount/NewSettings/SaveCancelButtons.tsx diff --git a/src/components/MyAccount/NewSettings/EmailForm.tsx b/src/components/MyAccount/NewSettings/EmailForm.tsx index 2fcc01134..37efd08ed 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.tsx @@ -4,13 +4,17 @@ import { Text, Flex, Button, + SkeletonLoader, } from "@nypl/design-system-react-components" import { useContext, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" +import SaveCancelButtons from "./SaveCancelButtons" -const EmailForm = ({ patronData, setIsLoading, setIsSuccess }) => { - const { getMostUpdatedSierraAccountData } = useContext(PatronDataContext) +const EmailForm = ({ patronData, setIsSuccess }) => { + const { patronDataLoading, getMostUpdatedSierraAccountData } = + useContext(PatronDataContext) const [emails, setEmails] = useState(patronData?.emails || []) + const [isLoading, setIsLoading] = useState(false) const [isEditing, setIsEditing] = useState(false) const [error, setError] = useState(false) @@ -27,8 +31,9 @@ const EmailForm = ({ patronData, setIsLoading, setIsSuccess }) => { updatedEmails[index] = value setTempEmails(updatedEmails) - // The first email entry cannot be empty. - if (index === 0 && (!value || !validateEmail(value))) { + const firstEmailEmpty = index === 0 + + if (firstEmailEmpty && (!value || !validateEmail(value))) { setError(true) } else { const hasInvalidEmail = updatedEmails.some( @@ -100,107 +105,135 @@ const EmailForm = ({ patronData, setIsLoading, setIsSuccess }) => { } return ( - - - - - Email - - - {isEditing ? ( - - {tempEmails.map((email, index) => ( - - handleInputChange(e, index)} - isRequired - isClearable - isClearableCallback={() => handleClearableCallback(index)} - sx={{ width: "300px" }} - /> - {index !== 0 && ( - - )} - - ))} - - + <> + {patronDataLoading || isLoading ? ( + ) : ( - - - {emails.map((email, index) => ( - - {email}{" "} - {index === 0 && (P)} - - ))} + + + + + Email + - - - )} - {isEditing && ( - - - + {isEditing ? ( + + {tempEmails.map((email, index) => ( + + handleInputChange(e, index)} + isRequired + isClearable + isClearableCallback={() => handleClearableCallback(index)} + /> + {index !== 0 && ( + + )} + + ))} + + + ) : ( + + + {emails.map((email, index) => ( + + {email}{" "} + {index === 0 && emails.length > 1 && ( + + (P) + + )} + + ))} + + + + )} + {isEditing && ( + + )} )} - + ) } diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index a7baa0fa4..8ae326418 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -1,8 +1,4 @@ -import { - SkeletonLoader, - Flex, - Banner, -} from "@nypl/design-system-react-components" +import { Flex, Banner } from "@nypl/design-system-react-components" import { useContext, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" import EmailForm from "./EmailForm" @@ -11,12 +7,9 @@ const NewAccountSettingsTab = () => { const { updatedAccountData: { patron }, } = useContext(PatronDataContext) - const [isLoading, setIsLoading] = useState(false) const [isSuccess, setIsSuccess] = useState(false) - return isLoading ? ( - - ) : ( + return ( <> {isSuccess && ( { /> )} - + ) diff --git a/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx b/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx new file mode 100644 index 000000000..7f2ff88f7 --- /dev/null +++ b/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx @@ -0,0 +1,33 @@ +import { ButtonGroup, Button } from "@nypl/design-system-react-components" + +const SaveCancelButtons = ({ error, onCancel, onSave }) => { + return ( + + + + + ) +} + +export default SaveCancelButtons From 4fe76add61914c452c6f904ca834486c0048c366 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 15 Oct 2024 10:29:35 -0400 Subject: [PATCH 12/86] Import fixes --- .../MyAccount/NewSettings/EmailForm.test.tsx | 13 +++++++------ src/components/MyAccount/ProfileTabs.tsx | 1 - src/components/MyAccount/TimedLogoutModal.tsx | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/MyAccount/NewSettings/EmailForm.test.tsx b/src/components/MyAccount/NewSettings/EmailForm.test.tsx index 5c10d6397..3b64a65fd 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.test.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.test.tsx @@ -25,11 +25,7 @@ describe("email form", () => { pickupLocations: filteredPickupLocations, }} > - + ) @@ -54,6 +50,9 @@ describe("email form", () => { expect( screen.getByRole("button", { name: /save changes/i }) ).toBeInTheDocument() + expect( + screen.getByRole("button", { name: /edit/i }) + ).not.toBeInTheDocument() }) it("validates email input correctly", () => { @@ -80,7 +79,9 @@ describe("email form", () => { screen.getByRole("button", { name: /\+ add an email address/i }) ) - expect(screen.getAllByLabelText("Update email").length).toBe(3) + expect(screen.getAllByLabelText("Update email").length).toBe( + processedPatron.emails.length + 1 + ) }) it("removes an email when delete icon is clicked", () => { diff --git a/src/components/MyAccount/ProfileTabs.tsx b/src/components/MyAccount/ProfileTabs.tsx index d573320c7..508ce12b6 100644 --- a/src/components/MyAccount/ProfileTabs.tsx +++ b/src/components/MyAccount/ProfileTabs.tsx @@ -1,7 +1,6 @@ import { Tabs, Text } from "@nypl/design-system-react-components" import { useRouter } from "next/router" -import AccountSettingsTab from "./Settings/AccountSettingsTab" import CheckoutsTab from "./CheckoutsTab/CheckoutsTab" import RequestsTab from "./RequestsTab/RequestsTab" import FeesTab from "./FeesTab/FeesTab" diff --git a/src/components/MyAccount/TimedLogoutModal.tsx b/src/components/MyAccount/TimedLogoutModal.tsx index 6850462b1..8ccc99fba 100644 --- a/src/components/MyAccount/TimedLogoutModal.tsx +++ b/src/components/MyAccount/TimedLogoutModal.tsx @@ -41,12 +41,12 @@ const TimedLogoutModal = ({ buildTimeLeft(expirationTime) ) - if ( - typeof document !== "undefined" && - !document.cookie.includes("accountPageExp") - ) { - logOutAndRedirect() - } + // if ( + // typeof document !== "undefined" && + // !document.cookie.includes("accountPageExp") + // ) { + // logOutAndRedirect() + // } useEffect(() => { const timeout = setTimeout(() => { From a1105fe67331ec93d7533d98da4f34e54b7dba8e Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 15 Oct 2024 10:32:51 -0400 Subject: [PATCH 13/86] Typo --- src/components/MyAccount/NewSettings/EmailForm.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/MyAccount/NewSettings/EmailForm.test.tsx b/src/components/MyAccount/NewSettings/EmailForm.test.tsx index 3b64a65fd..99b500db7 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.test.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.test.tsx @@ -5,7 +5,6 @@ import { PatronDataProvider } from "../../../context/PatronDataContext" import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" describe("email form", () => { - const mockSetIsLoading = jest.fn() const mockSetIsSuccess = jest.fn() beforeEach(() => { From 75776708888f69506ccdcf469845515b3389f3ae Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 15 Oct 2024 10:34:57 -0400 Subject: [PATCH 14/86] New version --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 27bf0b5ab..fdf1c279c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "research-catalog", - "version": "1.2.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "research-catalog", - "version": "1.2.1", + "version": "1.3.2", "dependencies": { "@nypl/design-system-react-components": "3.4.0", "@nypl/nypl-data-api-client": "1.0.5", From 08fda6d5259f38db7c568ec95e49bc2e8556a6b6 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 15 Oct 2024 10:39:04 -0400 Subject: [PATCH 15/86] Adding failure banner --- .../MyAccount/NewSettings/EmailForm.test.tsx | 7 ++++++- .../MyAccount/NewSettings/EmailForm.tsx | 3 ++- .../NewSettings/NewAccountSettingsTab.tsx | 19 ++++++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/components/MyAccount/NewSettings/EmailForm.test.tsx b/src/components/MyAccount/NewSettings/EmailForm.test.tsx index 99b500db7..5bda9db23 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.test.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.test.tsx @@ -6,6 +6,7 @@ import { processedPatron } from "../../../../__test__/fixtures/processedMyAccoun describe("email form", () => { const mockSetIsSuccess = jest.fn() + const mockSetIsFailure = jest.fn() beforeEach(() => { jest.clearAllMocks() @@ -24,7 +25,11 @@ describe("email form", () => { pickupLocations: filteredPickupLocations, }} > - + ) diff --git a/src/components/MyAccount/NewSettings/EmailForm.tsx b/src/components/MyAccount/NewSettings/EmailForm.tsx index 37efd08ed..6a59056e8 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.tsx @@ -10,7 +10,7 @@ import { useContext, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" import SaveCancelButtons from "./SaveCancelButtons" -const EmailForm = ({ patronData, setIsSuccess }) => { +const EmailForm = ({ patronData, setIsSuccess, setIsFailure }) => { const { patronDataLoading, getMostUpdatedSierraAccountData } = useContext(PatronDataContext) const [emails, setEmails] = useState(patronData?.emails || []) @@ -95,6 +95,7 @@ const EmailForm = ({ patronData, setIsSuccess }) => { setIsSuccess(true) setIsEditing(false) } else { + setIsFailure(true) setTempEmails([...emails]) } } catch (error) { diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index 8ae326418..753bd1a04 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -8,6 +8,7 @@ const NewAccountSettingsTab = () => { updatedAccountData: { patron }, } = useContext(PatronDataContext) const [isSuccess, setIsSuccess] = useState(false) + const [isFailure, setIsFailure] = useState(false) return ( <> @@ -21,8 +22,24 @@ const NewAccountSettingsTab = () => { sx={{ marginTop: "m" }} /> )} + {isFailure && ( + + Your changes were not saved. + + } + type="warning" + sx={{ marginTop: "m" }} + /> + )} - + ) From 4641d6495338b15ed48f1cf9a56049816e83d920 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 15 Oct 2024 10:44:01 -0400 Subject: [PATCH 16/86] Fixing test --- src/components/MyAccount/NewSettings/EmailForm.test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/MyAccount/NewSettings/EmailForm.test.tsx b/src/components/MyAccount/NewSettings/EmailForm.test.tsx index 5bda9db23..b7073c6aa 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.test.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.test.tsx @@ -54,9 +54,7 @@ describe("email form", () => { expect( screen.getByRole("button", { name: /save changes/i }) ).toBeInTheDocument() - expect( - screen.getByRole("button", { name: /edit/i }) - ).not.toBeInTheDocument() + expect(screen.queryByText(/edit/)).not.toBeInTheDocument() }) it("validates email input correctly", () => { From 83bbf82baae8294ebd48731da464b52f75947e81 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 15 Oct 2024 11:19:06 -0400 Subject: [PATCH 17/86] Splitting out form label component --- .../MyAccount/NewSettings/EmailForm.tsx | 15 ++----------- .../NewSettings/SettingsFormLabel.tsx | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 13 deletions(-) create mode 100644 src/components/MyAccount/NewSettings/SettingsFormLabel.tsx diff --git a/src/components/MyAccount/NewSettings/EmailForm.tsx b/src/components/MyAccount/NewSettings/EmailForm.tsx index 6a59056e8..7da8f080c 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.tsx @@ -9,6 +9,7 @@ import { import { useContext, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" import SaveCancelButtons from "./SaveCancelButtons" +import SettingsFormLabel from "./SettingsFormLabel" const EmailForm = ({ patronData, setIsSuccess, setIsFailure }) => { const { patronDataLoading, getMostUpdatedSierraAccountData } = @@ -115,19 +116,7 @@ const EmailForm = ({ patronData, setIsSuccess, setIsFailure }) => { alignItems="flex-start" width="100%" > - - - - Email - - + {isEditing ? ( { + return ( + + + + {text} + + + ) +} + +export default SettingsFormLabel From 828da6249fc18a414be3c53b9eec150bb1731330 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:25:54 -0400 Subject: [PATCH 18/86] Edwin updates --- .../MyAccount/NewSettings/EmailForm.tsx | 24 ++++++++++--------- .../NewSettings/NewAccountSettingsTab.tsx | 18 ++++---------- .../NewSettings/SaveCancelButtons.tsx | 14 +++++++++-- ...ettingsFormLabel.tsx => SettingsLabel.tsx} | 4 ++-- 4 files changed, 32 insertions(+), 28 deletions(-) rename src/components/MyAccount/NewSettings/{SettingsFormLabel.tsx => SettingsLabel.tsx} (83%) diff --git a/src/components/MyAccount/NewSettings/EmailForm.tsx b/src/components/MyAccount/NewSettings/EmailForm.tsx index 7da8f080c..f5fbededa 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.tsx @@ -9,11 +9,10 @@ import { import { useContext, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" import SaveCancelButtons from "./SaveCancelButtons" -import SettingsFormLabel from "./SettingsFormLabel" +import SettingsLabel from "./SettingsLabel" const EmailForm = ({ patronData, setIsSuccess, setIsFailure }) => { - const { patronDataLoading, getMostUpdatedSierraAccountData } = - useContext(PatronDataContext) + const { getMostUpdatedSierraAccountData } = useContext(PatronDataContext) const [emails, setEmails] = useState(patronData?.emails || []) const [isLoading, setIsLoading] = useState(false) const [isEditing, setIsEditing] = useState(false) @@ -77,8 +76,8 @@ const EmailForm = ({ patronData, setIsSuccess, setIsFailure }) => { const submitEmails = async () => { setIsLoading(true) + setIsEditing(false) const validEmails = tempEmails.filter((email) => validateEmail(email)) - try { const response = await fetch( `/research/research-catalog/api/account/settings/${patronData.id}`, @@ -94,7 +93,6 @@ const EmailForm = ({ patronData, setIsSuccess, setIsFailure }) => { if (response.status === 200) { await getMostUpdatedSierraAccountData() setIsSuccess(true) - setIsEditing(false) } else { setIsFailure(true) setTempEmails([...emails]) @@ -108,7 +106,7 @@ const EmailForm = ({ patronData, setIsSuccess, setIsFailure }) => { return ( <> - {patronDataLoading || isLoading ? ( + {isLoading ? ( ) : ( { alignItems="flex-start" width="100%" > - + {isEditing ? ( { { size="large" sx={{ justifyContent: "flex-start", - width: { base: "-webkit-fill-available", md: "300px" }, + width: { base: "87%", md: "300px" }, padding: "xxs", }} > @@ -178,7 +180,7 @@ const EmailForm = ({ patronData, setIsSuccess, setIsFailure }) => { alignItems="flex-start" > - {emails.map((email, index) => ( + {tempEmails.map((email, index) => ( { )} diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index 753bd1a04..61072a1a4 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -12,25 +12,17 @@ const NewAccountSettingsTab = () => { return ( <> - {isSuccess && ( - Your changes were saved. - } - type="positive" - sx={{ marginTop: "m" }} - /> - )} - {isFailure && ( + {(isSuccess || isFailure) && ( - Your changes were not saved. + {isFailure + ? "Your changes were not saved." + : "Your changes were saved."} } - type="warning" + type={isFailure ? "warning" : "positive"} sx={{ marginTop: "m" }} /> )} diff --git a/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx b/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx index 7f2ff88f7..589e8a0f7 100644 --- a/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx +++ b/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx @@ -1,6 +1,16 @@ import { ButtonGroup, Button } from "@nypl/design-system-react-components" -const SaveCancelButtons = ({ error, onCancel, onSave }) => { +type SaveCancelButtonProps = { + isDisabled: boolean + onCancel: () => void + onSave: () => void +} + +const SaveCancelButtons = ({ + isDisabled, + onCancel, + onSave, +}: SaveCancelButtonProps) => { return ( { - )} - +
+ + handleInputChange(e, index)} + isRequired + isClearable + isClearableCallback={() => handleClearableCallback(index)} + /> + {index !== 0 && ( + + )} + +
))} + )} +
+ + ))} + +
+ ) : isEmail || tempInputs.length != 0 ? ( + + + {tempInputs.map((input, index) => ( + + {input}{" "} + {index === 0 && inputs.length > 1 && ( + + (P) + + )} + + ))} + + + + ) : ( + + )} + + {isEditing && ( + + )} +
+ )} + + ) +} + +export default PhoneEmailForm diff --git a/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx b/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx index 589e8a0f7..fbc471d3c 100644 --- a/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx +++ b/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx @@ -4,12 +4,14 @@ type SaveCancelButtonProps = { isDisabled: boolean onCancel: () => void onSave: () => void + inputType: "phones" | "emails" } const SaveCancelButtons = ({ isDisabled, onCancel, onSave, + inputType, }: SaveCancelButtonProps) => { return ( - )} -
- - ))} - -
- ) : ( - - - {tempEmails.map((email, index) => ( - - {email}{" "} - {index === 0 && emails.length > 1 && ( - - (P) - - )} - - ))} - - - - )} - {isEditing && ( - - )} -
- )} - - ) -} - -export default EmailForm diff --git a/src/components/MyAccount/NewSettings/PhoneForm.test.tsx b/src/components/MyAccount/NewSettings/PhoneForm.test.tsx new file mode 100644 index 000000000..ddc1d9297 --- /dev/null +++ b/src/components/MyAccount/NewSettings/PhoneForm.test.tsx @@ -0,0 +1,157 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { filteredPickupLocations } from "../../../../__test__/fixtures/processedMyAccountData" +import { PatronDataProvider } from "../../../context/PatronDataContext" +import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" +import PhoneEmailForm from "./PhoneEmailForm" + +describe("phone form", () => { + const mockSetIsSuccess = jest.fn() + const mockSetIsFailure = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + global.fetch = jest.fn().mockResolvedValue({ + json: async () => { + console.log("Updated") + }, + status: 200, + } as Response) + }) + + const component = ( + + + + ) + + it("renders correctly with initial phone", () => { + render(component) + + expect( + screen.getByText(processedPatron.phones[0].number) + ).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument() + }) + + it("allows editing when edit button is clicked", () => { + render(component) + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + expect(screen.getAllByLabelText("Update phones")[0]).toBeInTheDocument() + expect( + screen.getByDisplayValue(processedPatron.phones[0].number) + ).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument() + expect( + screen.getByRole("button", { name: /save changes/i }) + ).toBeInTheDocument() + expect(screen.queryByText(/edit/)).not.toBeInTheDocument() + }) + + it("validates phone input correctly", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getAllByLabelText("Update phones")[0] + fireEvent.change(input, { target: { value: "invalid-phone" } }) + + expect( + screen.getByText("Please enter a valid and unique phone number.") + ).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled() + }) + + it("allows adding a new phone field", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + fireEvent.click( + screen.getByRole("button", { name: /\+ add a phone number/i }) + ) + + expect(screen.getAllByLabelText("Update phones").length).toBe( + processedPatron.phones.length + 1 + ) + }) + + it("removes a phone when delete icon is clicked", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + fireEvent.click(screen.getByLabelText("Remove phone")) + + expect( + screen.queryByDisplayValue(processedPatron.phones[0].number) + ).not.toBeInTheDocument() + }) + + it.skip("calls submit with valid data", async () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getAllByLabelText("Update phones")[0] + fireEvent.change(input, { target: { value: "1234" } }) + + fireEvent.click(screen.getByRole("button", { name: /save changes/i })) + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)) + + expect(fetch).toHaveBeenCalledWith( + "/research/research-catalog/api/account/settings/6742743", + expect.objectContaining({ + body: '{"phones":[{number:"1234", type: "t"}]}', + headers: { "Content-Type": "application/json" }, + method: "PUT", + }) + ) + }) + + it("cancels editing and reverts state", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getAllByLabelText("Update phones")[0] + fireEvent.change(input, { target: { value: "4534" } }) + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })) + + expect(screen.getByText("123-456-7890")).toBeInTheDocument() + expect(screen.queryByDisplayValue("4534")).not.toBeInTheDocument() + }) + + it("shows add phone number when there's no more phone numbers", async () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + fireEvent.click(screen.getByLabelText("Remove phone")) + + fireEvent.click(screen.getByRole("button", { name: /save changes/i })) + + await expect( + screen.queryByText(processedPatron.phones[0].number) + ).not.toBeInTheDocument() + + await waitFor(() => + expect(screen.getByText("+ Add a phone number")).toBeInTheDocument() + ) + }) +}) From b99c329ff772a9e894dcdb95e3be086bc94b5bc7 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 22 Oct 2024 12:58:16 -0700 Subject: [PATCH 25/86] Fixing nested p tag --- src/components/MyAccount/NewSettings/PhoneEmailForm.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx b/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx index 67005a33a..9cb3c5b6f 100644 --- a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx +++ b/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx @@ -239,16 +239,16 @@ const PhoneEmailForm = ({ > {input}{" "} {index === 0 && inputs.length > 1 && ( - (P) - + )} ))} From 2271fa7986cf14499adaacc46efa7786e60dd59d Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 29 Oct 2024 14:53:13 -0400 Subject: [PATCH 26/86] Clean up --- src/components/MyAccount/NewSettings/PhoneEmailForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx b/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx index 9cb3c5b6f..8f56dbe1d 100644 --- a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx +++ b/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx @@ -15,8 +15,8 @@ import type { Patron } from "../../../types/myAccountTypes" interface PhoneEmailFormProps { patronData: Patron - setIsSuccess - setIsFailure + setIsSuccess: (boolean) => void + setIsFailure: (boolean) => void inputType: "phones" | "emails" } From 116ef9b488d21f23d75eab5b24a43561333b268d Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:01:38 -0400 Subject: [PATCH 27/86] inline checks moved into formUtils --- .../MyAccount/NewSettings/PhoneEmailForm.tsx | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx b/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx index 8f56dbe1d..6d0b58822 100644 --- a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx +++ b/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx @@ -39,16 +39,21 @@ const PhoneEmailForm = ({ const [tempInputs, setTempInputs] = useState([...inputs]) + const formUtils = { + regex: isEmail ? /^[^@]+@[^@]+\.[^@]+$/ : /^\+?[1-9]\d{1,14}$/, + labelText: `Update ${inputType}`, + addButtonLabel: isEmail ? "+ Add an email address" : "+ Add a phone number", + errorMessage: `Please enter a valid and unique ${ + isEmail ? "email address" : "phone number" + }.`, + icon: `communication${isEmail ? "Email" : "Call"}`, + inputLabel: isEmail ? "Email" : "Phone", + } + const validateInput = (currentInput, inputs) => { const isInputUnique = inputs.filter((input) => input === currentInput).length === 1 - if (isEmail) { - const emailRegex = /^[^@]+@[^@]+\.[^@]+$/ - return emailRegex.test(currentInput) && isInputUnique - } else { - const phoneRegex = /^\+?[1-9]\d{1,14}$/ - return phoneRegex.test(currentInput) && isInputUnique - } + return formUtils.regex.test(currentInput) && isInputUnique } const handleInputChange = (e, index) => { @@ -76,8 +81,6 @@ const PhoneEmailForm = ({ const handleRemove = (index) => { const updatedInputs = tempInputs.filter((_, i) => i !== index) setTempInputs(updatedInputs) - - // Immediately revalidate remaining inputs. const hasInvalidInput = updatedInputs.some( (input) => !validateInput(input, updatedInputs) ) @@ -87,8 +90,6 @@ const PhoneEmailForm = ({ const handleAdd = () => { const updatedInputs = [...tempInputs, ""] setTempInputs(updatedInputs) - - // Immediately revalidate remaining inputs. const hasInvalidInput = updatedInputs.some( (input) => !validateInput(input, updatedInputs) ) @@ -160,10 +161,7 @@ const PhoneEmailForm = ({ alignItems="flex-start" width="100%" > - + {isEditing ? ( handleInputChange(e, index)} isRequired isClearable @@ -199,12 +195,12 @@ const PhoneEmailForm = ({ /> {(!isEmail || index !== 0) && ( )} @@ -222,7 +218,7 @@ const PhoneEmailForm = ({ padding: "xxs", }} > - {isEmail ? "+ Add an email address" : "+ Add a phone number"} + {formUtils.addButtonLabel} ) : isEmail || tempInputs.length != 0 ? ( @@ -285,7 +281,7 @@ const PhoneEmailForm = ({ paddingLeft: { base: "m", md: "unset" }, }} > - + Add a phone number + {formUtils.addButtonLabel} )} @@ -302,5 +298,4 @@ const PhoneEmailForm = ({ ) } - export default PhoneEmailForm From dbf459820e7a974557def13b06138ebae3dc234d Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Thu, 31 Oct 2024 12:03:18 -0400 Subject: [PATCH 28/86] Recommenting for clarity --- src/components/MyAccount/NewSettings/PhoneEmailForm.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx b/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx index 6d0b58822..8ccfc74cf 100644 --- a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx +++ b/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx @@ -81,6 +81,8 @@ const PhoneEmailForm = ({ const handleRemove = (index) => { const updatedInputs = tempInputs.filter((_, i) => i !== index) setTempInputs(updatedInputs) + + // Immediately revalidate remaining inputs. const hasInvalidInput = updatedInputs.some( (input) => !validateInput(input, updatedInputs) ) @@ -90,6 +92,8 @@ const PhoneEmailForm = ({ const handleAdd = () => { const updatedInputs = [...tempInputs, ""] setTempInputs(updatedInputs) + + // Immediately revalidate remaining inputs. const hasInvalidInput = updatedInputs.some( (input) => !validateInput(input, updatedInputs) ) From ac68ff8527312bc7cb305edaa1772d85f77771d4 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Fri, 1 Nov 2024 13:42:57 -0400 Subject: [PATCH 29/86] Home library and notification form component, plus tests --- .../NewSettings/HomeLibraryForm.test.tsx | 106 ++++++++++ .../HomeLibraryNotificationForm.tsx | 189 ++++++++++++++++++ .../NewSettings/NewAccountSettingsTab.tsx | 17 +- .../NewSettings/NotificationForm.test.tsx | 112 +++++++++++ 4 files changed, 423 insertions(+), 1 deletion(-) create mode 100644 src/components/MyAccount/NewSettings/HomeLibraryForm.test.tsx create mode 100644 src/components/MyAccount/NewSettings/HomeLibraryNotificationForm.tsx create mode 100644 src/components/MyAccount/NewSettings/NotificationForm.test.tsx diff --git a/src/components/MyAccount/NewSettings/HomeLibraryForm.test.tsx b/src/components/MyAccount/NewSettings/HomeLibraryForm.test.tsx new file mode 100644 index 000000000..e8e78725d --- /dev/null +++ b/src/components/MyAccount/NewSettings/HomeLibraryForm.test.tsx @@ -0,0 +1,106 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { filteredPickupLocations } from "../../../../__test__/fixtures/processedMyAccountData" +import { PatronDataProvider } from "../../../context/PatronDataContext" +import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" +import { pickupLocations } from "../../../../__test__/fixtures/rawSierraAccountData" +import HomeLibraryNotificationForm from "./HomeLibraryNotificationForm" + +describe("home library form", () => { + const mockSetIsSuccess = jest.fn() + const mockSetIsFailure = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + global.fetch = jest.fn().mockResolvedValue({ + json: async () => { + console.log("Updated") + }, + status: 200, + } as Response) + }) + + const component = ( + + + + ) + + it("renders correctly with initial location", () => { + render(component) + + expect( + screen.getByText(processedPatron.homeLibrary.name) + ).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument() + }) + + it("allows editing when edit button is clicked", () => { + render(component) + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + expect(screen.getByLabelText("Update home library")).toBeInTheDocument() + expect( + screen.getByDisplayValue(processedPatron.homeLibrary.name) + ).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument() + expect( + screen.getByRole("button", { name: /save changes/i }) + ).toBeInTheDocument() + expect(screen.queryByText(/edit/)).not.toBeInTheDocument() + }) + + it("calls submit with valid data", async () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getByLabelText("Update home library") + fireEvent.change(input, { + target: { value: "Belmont" }, + }) + + fireEvent.click(screen.getByRole("button", { name: /save changes/i })) + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)) + + expect(fetch).toHaveBeenCalledWith( + "/research/research-catalog/api/account/settings/6742743", + { + body: '{"homeLibraryCode":"be "}', + headers: { "Content-Type": "application/json" }, + method: "PUT", + } + ) + }) + + it("cancels editing and reverts state", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getByLabelText("Update home library") + fireEvent.change(input, { + target: { value: "Belmont" }, + }) + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })) + + expect( + screen.getByText("SNFL (formerly Mid-Manhattan)") + ).toBeInTheDocument() + expect(screen.queryByDisplayValue("Belmont")).not.toBeInTheDocument() + }) +}) diff --git a/src/components/MyAccount/NewSettings/HomeLibraryNotificationForm.tsx b/src/components/MyAccount/NewSettings/HomeLibraryNotificationForm.tsx new file mode 100644 index 000000000..101d439cd --- /dev/null +++ b/src/components/MyAccount/NewSettings/HomeLibraryNotificationForm.tsx @@ -0,0 +1,189 @@ +import { useContext, useState } from "react" +import { PatronDataContext } from "../../../context/PatronDataContext" +import { + Button, + Flex, + Icon, + Select, + SkeletonLoader, + Text, +} from "@nypl/design-system-react-components" +import SettingsLabel from "./SettingsLabel" +import SaveCancelButtons from "./SaveCancelButtons" +import type { Patron, SierraCodeName } from "../../../types/myAccountTypes" + +interface HomeLibraryNotificationFormProps { + type: "library" | "notification" + patronData: Patron + setIsSuccess: (boolean) => void + setIsFailure: (boolean) => void + pickupLocations: SierraCodeName[] +} + +const HomeLibraryNotificationForm = ({ + type, + patronData, + setIsSuccess, + setIsFailure, + pickupLocations, +}: HomeLibraryNotificationFormProps) => { + const { getMostUpdatedSierraAccountData } = useContext(PatronDataContext) + const [isLoading, setIsLoading] = useState(false) + const [isEditing, setIsEditing] = useState(false) + + const notificationPreferenceMap = [ + { code: "z", name: "Email" }, + { code: "p", name: "Phone" }, + { code: "-", name: "None" }, + ] + + const sortedPickupLocations = [ + patronData.homeLibrary, + ...pickupLocations.filter( + (loc) => loc.code.trim() !== patronData.homeLibrary.code.trim() + ), + ] + + const options = + type === "notification" ? notificationPreferenceMap : sortedPickupLocations + + const formUtils = { + icon: type === "notification" ? "communicationChatBubble" : "actionHome", + label: type === "notification" ? "Notification preference" : "Home library", + selectorId: + type === "notification" + ? "notification-preference-selector" + : "update-home-library-selector", + body: (code) => + type === "notification" + ? { + fixedFields: { "268": { label: "Notice Preference", value: code } }, + } + : { homeLibraryCode: `${code}` }, + } + + const [selection, setSelection] = useState( + type === "notification" + ? notificationPreferenceMap.find( + (pref) => pref.code === patronData.notificationPreference + )?.name + : patronData.homeLibrary.name + ) + const [tempSelection, setTempSelection] = useState(selection) + + const handleSelectChange = (event) => { + setTempSelection(event.target.value) + } + + const cancelEditing = () => { + setIsEditing(false) + } + + const submitSelection = async () => { + setIsLoading(true) + setIsEditing(false) + + const code = + type === "notification" + ? notificationPreferenceMap.find((pref) => pref.name === tempSelection) + ?.code + : pickupLocations.find((loc) => loc.name === tempSelection)?.code + + const body = formUtils.body(code) + + try { + const response = await fetch( + `/research/research-catalog/api/account/settings/${patronData.id}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + } + ) + + if (response.status === 200) { + await getMostUpdatedSierraAccountData() + setIsSuccess(true) + setSelection(tempSelection) + setTempSelection(tempSelection) + } else { + setIsFailure(true) + setTempSelection(tempSelection) + } + } catch (error) { + console.error("Error submitting", error) + } finally { + setIsLoading(false) + } + } + + return ( + <> + {isLoading ? ( + + ) : ( + + + {isEditing ? ( + + + + ) : ( + + + {selection} + + + + )} + {isEditing && ( + + )} + + )} + + ) +} + +export default HomeLibraryNotificationForm diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index e1dc90f9d..342208a17 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -2,10 +2,11 @@ import { Flex, Banner } from "@nypl/design-system-react-components" import { useContext, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" import PhoneEmailForm from "./PhoneEmailForm" +import HomeLibraryNotificationForm from "./HomeLibraryNotificationForm" const NewAccountSettingsTab = () => { const { - updatedAccountData: { patron }, + updatedAccountData: { patron, pickupLocations }, } = useContext(PatronDataContext) const [isSuccess, setIsSuccess] = useState(false) const [isFailure, setIsFailure] = useState(false) @@ -39,6 +40,20 @@ const NewAccountSettingsTab = () => { setIsFailure={setIsFailure} inputType="emails" /> + +
) diff --git a/src/components/MyAccount/NewSettings/NotificationForm.test.tsx b/src/components/MyAccount/NewSettings/NotificationForm.test.tsx new file mode 100644 index 000000000..eda29fc16 --- /dev/null +++ b/src/components/MyAccount/NewSettings/NotificationForm.test.tsx @@ -0,0 +1,112 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { filteredPickupLocations } from "../../../../__test__/fixtures/processedMyAccountData" +import { PatronDataProvider } from "../../../context/PatronDataContext" +import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" +import { pickupLocations } from "../../../../__test__/fixtures/rawSierraAccountData" +import HomeLibraryNotificationForm from "./HomeLibraryNotificationForm" + +describe("notification preference form", () => { + const mockSetIsSuccess = jest.fn() + const mockSetIsFailure = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + global.fetch = jest.fn().mockResolvedValue({ + json: async () => { + console.log("Updated") + }, + status: 200, + } as Response) + }) + + const notificationPreferenceMap = [ + { code: "z", name: "Email" }, + { code: "p", name: "Phone" }, + { code: "-", name: "None" }, + ] + + const processedPatronPref = notificationPreferenceMap.find( + (pref) => pref.code === processedPatron.notificationPreference + )?.name + + const component = ( + + + + ) + + it("renders correctly with initial preference", () => { + render(component) + + expect(screen.getByText(processedPatronPref)).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument() + }) + + it("allows editing when edit button is clicked", () => { + render(component) + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + expect( + screen.getByLabelText("Update notification preference") + ).toBeInTheDocument() + expect(screen.getByDisplayValue(processedPatronPref)).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument() + expect( + screen.getByRole("button", { name: /save changes/i }) + ).toBeInTheDocument() + expect(screen.queryByText(/edit/)).not.toBeInTheDocument() + }) + + it("calls submit with valid data", async () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getByLabelText("Update notification preference") + fireEvent.change(input, { + target: { value: "None" }, + }) + + fireEvent.click(screen.getByRole("button", { name: /save changes/i })) + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1)) + + expect(fetch).toHaveBeenCalledWith( + "/research/research-catalog/api/account/settings/6742743", + { + body: '{"fixedFields":{"268":{"label":"Notice Preference","value":"-"}}}', + headers: { "Content-Type": "application/json" }, + method: "PUT", + } + ) + }) + + it("cancels editing and reverts state", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getByLabelText("Update notification preference") + fireEvent.change(input, { + target: { value: "None" }, + }) + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })) + + expect(screen.getByText(processedPatronPref)).toBeInTheDocument() + expect(screen.queryByDisplayValue("None")).not.toBeInTheDocument() + }) +}) From 420f93f4c787fa23f584e273f75ba365385ce530 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Fri, 1 Nov 2024 13:43:11 -0400 Subject: [PATCH 30/86] Styling and readme corrections --- accountREADME.md | 2 +- src/components/MyAccount/NewSettings/PhoneEmailForm.tsx | 6 +++++- src/components/MyAccount/NewSettings/PhoneForm.test.tsx | 8 ++++---- .../MyAccount/NewSettings/SaveCancelButtons.tsx | 4 ++-- src/components/MyAccount/NewSettings/SettingsLabel.tsx | 3 +-- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/accountREADME.md b/accountREADME.md index c88db4eb2..e91a8b8f6 100644 --- a/accountREADME.md +++ b/accountREADME.md @@ -41,7 +41,7 @@ Route parameter is the hold ID. Request body requires the **patron ID**, and can exampleBody: { patronId: '123456', freeze: true, - pickupLocation: 'sn', + homeLibraryCode: 'sn', }, ``` diff --git a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx b/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx index 8ccfc74cf..88189b7cd 100644 --- a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx +++ b/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx @@ -235,7 +235,11 @@ const PhoneEmailForm = ({ {tempInputs.map((input, index) => ( {input}{" "} {index === 0 && inputs.length > 1 && ( diff --git a/src/components/MyAccount/NewSettings/PhoneForm.test.tsx b/src/components/MyAccount/NewSettings/PhoneForm.test.tsx index ddc1d9297..55fa9c985 100644 --- a/src/components/MyAccount/NewSettings/PhoneForm.test.tsx +++ b/src/components/MyAccount/NewSettings/PhoneForm.test.tsx @@ -101,7 +101,7 @@ describe("phone form", () => { ).not.toBeInTheDocument() }) - it.skip("calls submit with valid data", async () => { + it("calls submit with valid data", async () => { render(component) fireEvent.click(screen.getByRole("button", { name: /edit/i })) @@ -115,11 +115,11 @@ describe("phone form", () => { expect(fetch).toHaveBeenCalledWith( "/research/research-catalog/api/account/settings/6742743", - expect.objectContaining({ - body: '{"phones":[{number:"1234", type: "t"}]}', + { + body: '{"phones":[{"number":"1234","type":"t"}]}', headers: { "Content-Type": "application/json" }, method: "PUT", - }) + } ) }) diff --git a/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx b/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx index fbc471d3c..e6bd00711 100644 --- a/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx +++ b/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx @@ -1,10 +1,10 @@ import { ButtonGroup, Button } from "@nypl/design-system-react-components" type SaveCancelButtonProps = { - isDisabled: boolean + isDisabled?: boolean onCancel: () => void onSave: () => void - inputType: "phones" | "emails" + inputType?: "phones" | "emails" } const SaveCancelButtons = ({ diff --git a/src/components/MyAccount/NewSettings/SettingsLabel.tsx b/src/components/MyAccount/NewSettings/SettingsLabel.tsx index fd4a2df74..9595accf0 100644 --- a/src/components/MyAccount/NewSettings/SettingsLabel.tsx +++ b/src/components/MyAccount/NewSettings/SettingsLabel.tsx @@ -5,7 +5,7 @@ const SettingsLabel = ({ icon, text }) => { @@ -15,7 +15,6 @@ const SettingsLabel = ({ icon, text }) => { sx={{ fontWeight: "500", marginBottom: 0, - marginRight: { base: "l", lg: "200px" }, }} > {text} From d099abdd4bbde340ffd1a423957205451014efea Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:01:58 -0500 Subject: [PATCH 31/86] Renaming form components --- .../MyAccount/NewSettings/EmailForm.test.tsx | 2 +- .../MyAccount/NewSettings/HomeLibraryForm.test.tsx | 2 +- .../MyAccount/NewSettings/NewAccountSettingsTab.tsx | 10 ++++++++-- .../MyAccount/NewSettings/NotificationForm.test.tsx | 2 +- .../MyAccount/NewSettings/PhoneForm.test.tsx | 2 +- .../{PhoneEmailForm.tsx => SettingsInputForm.tsx} | 0 ...raryNotificationForm.tsx => SettingsSelectForm.tsx} | 1 + 7 files changed, 13 insertions(+), 6 deletions(-) rename src/components/MyAccount/NewSettings/{PhoneEmailForm.tsx => SettingsInputForm.tsx} (100%) rename src/components/MyAccount/NewSettings/{HomeLibraryNotificationForm.tsx => SettingsSelectForm.tsx} (99%) diff --git a/src/components/MyAccount/NewSettings/EmailForm.test.tsx b/src/components/MyAccount/NewSettings/EmailForm.test.tsx index fe92ec10b..9e4e99d6a 100644 --- a/src/components/MyAccount/NewSettings/EmailForm.test.tsx +++ b/src/components/MyAccount/NewSettings/EmailForm.test.tsx @@ -2,7 +2,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react" import { filteredPickupLocations } from "../../../../__test__/fixtures/processedMyAccountData" import { PatronDataProvider } from "../../../context/PatronDataContext" import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" -import PhoneEmailForm from "./PhoneEmailForm" +import PhoneEmailForm from "./SettingsInputForm" describe("email form", () => { const mockSetIsSuccess = jest.fn() diff --git a/src/components/MyAccount/NewSettings/HomeLibraryForm.test.tsx b/src/components/MyAccount/NewSettings/HomeLibraryForm.test.tsx index e8e78725d..4228960a2 100644 --- a/src/components/MyAccount/NewSettings/HomeLibraryForm.test.tsx +++ b/src/components/MyAccount/NewSettings/HomeLibraryForm.test.tsx @@ -3,7 +3,7 @@ import { filteredPickupLocations } from "../../../../__test__/fixtures/processed import { PatronDataProvider } from "../../../context/PatronDataContext" import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" import { pickupLocations } from "../../../../__test__/fixtures/rawSierraAccountData" -import HomeLibraryNotificationForm from "./HomeLibraryNotificationForm" +import HomeLibraryNotificationForm from "./SettingsSelectForm" describe("home library form", () => { const mockSetIsSuccess = jest.fn() diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index 342208a17..7cc45e426 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -1,8 +1,9 @@ import { Flex, Banner } from "@nypl/design-system-react-components" import { useContext, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" -import PhoneEmailForm from "./PhoneEmailForm" -import HomeLibraryNotificationForm from "./HomeLibraryNotificationForm" +import PhoneEmailForm from "./SettingsInputForm" +import HomeLibraryNotificationForm from "./SettingsSelectForm" +import PasswordForm from "./PasswordForm" const NewAccountSettingsTab = () => { const { @@ -54,6 +55,11 @@ const NewAccountSettingsTab = () => { setIsFailure={setIsFailure} type="notification" /> + ) diff --git a/src/components/MyAccount/NewSettings/NotificationForm.test.tsx b/src/components/MyAccount/NewSettings/NotificationForm.test.tsx index eda29fc16..39f9f6d9c 100644 --- a/src/components/MyAccount/NewSettings/NotificationForm.test.tsx +++ b/src/components/MyAccount/NewSettings/NotificationForm.test.tsx @@ -3,7 +3,7 @@ import { filteredPickupLocations } from "../../../../__test__/fixtures/processed import { PatronDataProvider } from "../../../context/PatronDataContext" import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" import { pickupLocations } from "../../../../__test__/fixtures/rawSierraAccountData" -import HomeLibraryNotificationForm from "./HomeLibraryNotificationForm" +import HomeLibraryNotificationForm from "./SettingsSelectForm" describe("notification preference form", () => { const mockSetIsSuccess = jest.fn() diff --git a/src/components/MyAccount/NewSettings/PhoneForm.test.tsx b/src/components/MyAccount/NewSettings/PhoneForm.test.tsx index 55fa9c985..12b215e45 100644 --- a/src/components/MyAccount/NewSettings/PhoneForm.test.tsx +++ b/src/components/MyAccount/NewSettings/PhoneForm.test.tsx @@ -2,7 +2,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react" import { filteredPickupLocations } from "../../../../__test__/fixtures/processedMyAccountData" import { PatronDataProvider } from "../../../context/PatronDataContext" import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" -import PhoneEmailForm from "./PhoneEmailForm" +import PhoneEmailForm from "./SettingsInputForm" describe("phone form", () => { const mockSetIsSuccess = jest.fn() diff --git a/src/components/MyAccount/NewSettings/PhoneEmailForm.tsx b/src/components/MyAccount/NewSettings/SettingsInputForm.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/PhoneEmailForm.tsx rename to src/components/MyAccount/NewSettings/SettingsInputForm.tsx diff --git a/src/components/MyAccount/NewSettings/HomeLibraryNotificationForm.tsx b/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx similarity index 99% rename from src/components/MyAccount/NewSettings/HomeLibraryNotificationForm.tsx rename to src/components/MyAccount/NewSettings/SettingsSelectForm.tsx index 101d439cd..921d3d3a0 100644 --- a/src/components/MyAccount/NewSettings/HomeLibraryNotificationForm.tsx +++ b/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx @@ -133,6 +133,7 @@ const HomeLibraryNotificationForm = ({ sx={{ marginTop: "xs", marginLeft: { base: "l", lg: "unset" } }} > + {formUtils.options.map((option, index) => ( + + ))} + +
+ ) : ( + + - - - ) : ( - - + {editingField === "" && ( + { + setIsEditing(true) + setEditingField(type) }} - > - {selection} - - {editingField === "" && ( - { - setIsEditing(true) - setEditingField(type) - }} - /> - )} - - )} - {isEditing && ( - - )} -
- )} + /> + )} +
+ )} + {isEditing && ( + + )} + ) } diff --git a/src/types/myAccountTypes.ts b/src/types/myAccountTypes.ts index cea6aca86..bbac892a8 100644 --- a/src/types/myAccountTypes.ts +++ b/src/types/myAccountTypes.ts @@ -123,7 +123,7 @@ export interface Hold { export interface Patron { username?: string - notificationPreference: "z" | "p" + notificationPreference: "z" | "p" | "-" name: string barcode: string formattedBarcode?: string From 5063b229321dfd7cd0b9df7b29b379f5faea95d4 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:08:59 -0500 Subject: [PATCH 48/86] Sending focus to banner on submission --- .../NewSettings/NewAccountSettingsTab.tsx | 75 +++++++++++-------- .../NewSettings/SettingsInputForm.tsx | 2 + .../NewSettings/SettingsSelectForm.tsx | 2 - 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index 9c82b5c86..14f6ade78 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -1,5 +1,5 @@ import { Flex, Banner, Link, Text } from "@nypl/design-system-react-components" -import { useContext, useState } from "react" +import { useContext, useEffect, useRef, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" import SettingsInputForm from "./SettingsInputForm" import SettingsSelectForm from "./SettingsSelectForm" @@ -11,6 +11,7 @@ const NewAccountSettingsTab = () => { } = useContext(PatronDataContext) const [status, setStatus] = useState<[string, string?]>(["none"]) const [editingField, setEditingField] = useState("") + const bannerRef = useRef(null) const settingsState = { setStatus, @@ -18,44 +19,52 @@ const NewAccountSettingsTab = () => { setEditingField, } + useEffect(() => { + if (status[0] !== "none" && bannerRef.current) { + bannerRef.current.focus() + } + }, [status]) + return ( <> {status[0] !== "none" && ( - - {status[0] === "failure" ? ( - status[1] ? ( - - {status[1]} Please try again or{" "} - - contact us - {" "} - for assistance. - +
+ + {status[0] === "failure" ? ( + status[1] ? ( + + {status[1]} Please try again or{" "} + + contact us + {" "} + for assistance. + + ) : ( + + Your changes were not saved. + + ) ) : ( - Your changes were not saved. + Your changes were saved. - ) - ) : ( - - Your changes were saved. - - )} -
- } - type={status[0] === "failure" ? "negative" : "positive"} - sx={{ marginTop: "m" }} - /> + )} + + } + type={status[0] === "failure" ? "negative" : "positive"} + /> + )} { setIsLoading(true) setIsEditing(false) + setStatus(["none"]) const validInputs = tempInputs.filter((input) => validateInput(input, tempInputs) ) @@ -140,6 +141,7 @@ const PhoneEmailForm = ({ ) if (response.status === 200) { + console.log("hello") await getMostUpdatedSierraAccountData() setStatus(["success"]) setInputs([...validInputs]) diff --git a/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx b/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx index 1e58b1e20..bcba0e309 100644 --- a/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx +++ b/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx @@ -1,9 +1,7 @@ import { useContext, useState } from "react" import { PatronDataContext } from "../../../context/PatronDataContext" import { - Button, Flex, - Icon, Select, SkeletonLoader, Text, From 2f78ef926f06490aaef347a16bb21004e8f4b3cc Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Wed, 13 Nov 2024 16:44:55 -0500 Subject: [PATCH 49/86] Splitting status and statusMessage state --- .../NewSettings/NewAccountSettingsTab.tsx | 29 ++++++++++++------- .../NewSettings/PasswordForm.test.tsx | 17 ++++++----- .../MyAccount/NewSettings/PasswordForm.tsx | 12 ++++---- .../NewSettings/SettingsInputForm.tsx | 9 +++--- .../NewSettings/SettingsSelectForm.tsx | 6 ++-- 5 files changed, 42 insertions(+), 31 deletions(-) diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index 14f6ade78..f50327f92 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -9,8 +9,9 @@ const NewAccountSettingsTab = () => { const { updatedAccountData: { patron, pickupLocations }, } = useContext(PatronDataContext) - const [status, setStatus] = useState<[string, string?]>(["none"]) - const [editingField, setEditingField] = useState("") + const [status, setStatus] = useState("") + const [statusMessage, setStatusMessage] = useState("") + const [editingField, setEditingField] = useState("") const bannerRef = useRef(null) const settingsState = { @@ -19,25 +20,30 @@ const NewAccountSettingsTab = () => { setEditingField, } + const passwordSettingsState = { + ...settingsState, + setStatusMessage, + } + useEffect(() => { - if (status[0] !== "none" && bannerRef.current) { + if (status !== "" && bannerRef.current) { bannerRef.current.focus() } }, [status]) return ( <> - {status[0] !== "none" && ( + {status !== "" && (
- {status[0] === "failure" ? ( - status[1] ? ( + {status === "failure" ? ( + statusMessage !== "" ? ( - {status[1]} Please try again or{" "} + {statusMessage} Please try again or{" "} { )}
} - type={status[0] === "failure" ? "negative" : "positive"} + type={status === "failure" ? "negative" : "positive"} /> )} @@ -86,10 +92,13 @@ const NewAccountSettingsTab = () => { - +
) diff --git a/src/components/MyAccount/NewSettings/PasswordForm.test.tsx b/src/components/MyAccount/NewSettings/PasswordForm.test.tsx index 8deb7e332..1261387e9 100644 --- a/src/components/MyAccount/NewSettings/PasswordForm.test.tsx +++ b/src/components/MyAccount/NewSettings/PasswordForm.test.tsx @@ -9,6 +9,7 @@ import { PatronDataProvider } from "../../../context/PatronDataContext" const mockSettingsState = { setStatus: jest.fn(), + setStatusMessage: jest.fn(), editingField: "", setEditingField: jest.fn(), } @@ -93,10 +94,9 @@ describe("Pin/password form", () => { await waitFor(() => expect(mockSettingsState.setStatus).toHaveBeenCalledTimes(2) ) - expect(mockSettingsState.setStatus).toHaveBeenNthCalledWith(2, [ - "failure", - "Incorrect current pin/password.", - ]) + expect(mockSettingsState.setStatusMessage).toHaveBeenCalledWith( + "Incorrect current pin/password." + ) }) test("sets failure if new password is invalid", async () => { @@ -125,10 +125,11 @@ describe("Pin/password form", () => { await waitFor(() => expect(mockSettingsState.setStatus).toHaveBeenCalledTimes(4) ) - expect(mockSettingsState.setStatus).toHaveBeenNthCalledWith(4, [ - "failure", - "Invalid new pin/password.", - ]) + expect(mockSettingsState.setStatus).toHaveBeenNthCalledWith(4, "failure") + expect(mockSettingsState.setStatusMessage).toHaveBeenNthCalledWith( + 2, + "Invalid new pin/password." + ) }) test("successfully sets patron data if every field is valid", async () => { diff --git a/src/components/MyAccount/NewSettings/PasswordForm.tsx b/src/components/MyAccount/NewSettings/PasswordForm.tsx index 6197fe09c..8d32d4f56 100644 --- a/src/components/MyAccount/NewSettings/PasswordForm.tsx +++ b/src/components/MyAccount/NewSettings/PasswordForm.tsx @@ -67,7 +67,8 @@ const PasswordForm = ({ patronData, settingsState }: PasswordFormProps) => { confirmPassword: "", passwordsMatch: true, }) - const { setStatus, editingField, setEditingField } = settingsState + const { setStatus, setStatusMessage, editingField, setEditingField } = + settingsState const cancelEditing = () => { setIsEditing(false) @@ -108,7 +109,7 @@ const PasswordForm = ({ patronData, settingsState }: PasswordFormProps) => { const submitForm = async () => { setIsLoading(true) setIsEditing(false) - setStatus(["none"]) + setStatus("") try { const response = await fetch( `${BASE_URL}/api/account/update-pin/${patronData.id}`, @@ -128,13 +129,14 @@ const PasswordForm = ({ patronData, settingsState }: PasswordFormProps) => { const errorMessage = await response.json() if (response.status === 200) { await getMostUpdatedSierraAccountData() - setStatus(["success"]) + setStatus("success") } else { + setStatus("failure") if (errorMessage) { errorMessage.startsWith("Invalid parameter") ? // Returning a more user-friendly error message. - setStatus(["failure", "Incorrect current pin/password."]) - : setStatus(["failure", "Invalid new pin/password."]) + setStatusMessage("Incorrect current pin/password.") + : setStatusMessage("Invalid new pin/password.") } } } catch (error) { diff --git a/src/components/MyAccount/NewSettings/SettingsInputForm.tsx b/src/components/MyAccount/NewSettings/SettingsInputForm.tsx index 9b7a81813..659fd6181 100644 --- a/src/components/MyAccount/NewSettings/SettingsInputForm.tsx +++ b/src/components/MyAccount/NewSettings/SettingsInputForm.tsx @@ -116,7 +116,7 @@ const PhoneEmailForm = ({ const submitInputs = async () => { setIsLoading(true) setIsEditing(false) - setStatus(["none"]) + setStatus("none") const validInputs = tempInputs.filter((input) => validateInput(input, tempInputs) ) @@ -141,13 +141,12 @@ const PhoneEmailForm = ({ ) if (response.status === 200) { - console.log("hello") await getMostUpdatedSierraAccountData() - setStatus(["success"]) + setStatus("success") setInputs([...validInputs]) setTempInputs([...validInputs]) } else { - setStatus(["failure"]) + setStatus("failure") setTempInputs([...inputs]) } } catch (error) { @@ -247,7 +246,7 @@ const PhoneEmailForm = ({ ))} - {editingField === "" && ( + {editingField === "" && tempInputs.length > 0 && ( { diff --git a/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx b/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx index bcba0e309..dc86e7cda 100644 --- a/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx +++ b/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx @@ -98,7 +98,7 @@ const HomeLibraryNotificationForm = ({ const submitSelection = async () => { setIsLoading(true) setIsEditing(false) - setStatus(["none"]) + setStatus("") const body = formUtils.body( type === "notification" ? notificationPreferenceMap.find((pref) => pref.name === tempSelection) @@ -118,11 +118,11 @@ const HomeLibraryNotificationForm = ({ if (response.status === 200) { await getMostUpdatedSierraAccountData() - setStatus(["success"]) + setStatus("success") setSelection(tempSelection) setTempSelection(tempSelection) } else { - setStatus(["failure"]) + setStatus("failure") setTempSelection(tempSelection) } } catch (error) { From 3ce348f6840e824e7d2c85892e46bff61a8c9a90 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Wed, 13 Nov 2024 16:45:42 -0500 Subject: [PATCH 50/86] correcting typo --- src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index f50327f92..88f8098e2 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -92,7 +92,7 @@ const NewAccountSettingsTab = () => { Date: Wed, 13 Nov 2024 16:49:20 -0500 Subject: [PATCH 51/86] More typos --- src/components/MyAccount/NewSettings/SettingsInputForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MyAccount/NewSettings/SettingsInputForm.tsx b/src/components/MyAccount/NewSettings/SettingsInputForm.tsx index 659fd6181..cd2657dfa 100644 --- a/src/components/MyAccount/NewSettings/SettingsInputForm.tsx +++ b/src/components/MyAccount/NewSettings/SettingsInputForm.tsx @@ -116,7 +116,7 @@ const PhoneEmailForm = ({ const submitInputs = async () => { setIsLoading(true) setIsEditing(false) - setStatus("none") + setStatus("") const validInputs = tempInputs.filter((input) => validateInput(input, tempInputs) ) From 5f35ebfada6092f215d5916208938f3f73a07960 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Thu, 14 Nov 2024 11:07:49 -0500 Subject: [PATCH 52/86] Edwin updates (rearranging variables, typing state, style fixes) --- .../NewSettings/NewAccountSettingsTab.tsx | 4 +- .../NewSettings/PasswordForm.test.tsx | 34 ++++++---- .../MyAccount/NewSettings/PasswordForm.tsx | 38 +++++------ .../NewSettings/SettingsInputForm.tsx | 11 ++-- .../MyAccount/NewSettings/SettingsLabel.tsx | 4 +- .../NewSettings/SettingsSelectForm.tsx | 64 ++++++++++--------- 6 files changed, 84 insertions(+), 71 deletions(-) diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx index 88f8098e2..3d873c136 100644 --- a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx +++ b/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx @@ -5,11 +5,13 @@ import SettingsInputForm from "./SettingsInputForm" import SettingsSelectForm from "./SettingsSelectForm" import PasswordForm from "./PasswordForm" +type StatusType = "" | "failure" | "success" + const NewAccountSettingsTab = () => { const { updatedAccountData: { patron, pickupLocations }, } = useContext(PatronDataContext) - const [status, setStatus] = useState("") + const [status, setStatus] = useState("") const [statusMessage, setStatusMessage] = useState("") const [editingField, setEditingField] = useState("") const bannerRef = useRef(null) diff --git a/src/components/MyAccount/NewSettings/PasswordForm.test.tsx b/src/components/MyAccount/NewSettings/PasswordForm.test.tsx index 1261387e9..013b1e303 100644 --- a/src/components/MyAccount/NewSettings/PasswordForm.test.tsx +++ b/src/components/MyAccount/NewSettings/PasswordForm.test.tsx @@ -6,6 +6,7 @@ import { processedPatron, } from "../../../../__test__/fixtures/processedMyAccountData" import { PatronDataProvider } from "../../../context/PatronDataContext" +import { passwordFormMessages } from "./PasswordForm" const mockSettingsState = { setStatus: jest.fn(), @@ -30,6 +31,12 @@ const component = ( ) +beforeEach(() => { + mockSettingsState.setStatus.mockClear() + mockSettingsState.setStatusMessage.mockClear() + mockSettingsState.setEditingField.mockClear() +}) + describe("Pin/password form", () => { test("disables submit button if any form field is empty", async () => { const { getByText, getByLabelText } = render(component) @@ -79,11 +86,13 @@ describe("Pin/password form", () => { const button = getByText("Edit") fireEvent.click(button) - const oldPasswordField = getByLabelText("Enter current pin/password") + const currentPasswordField = getByLabelText("Enter current pin/password") const newPasswordField = getByLabelText("Enter new pin/password") const confirmPasswordField = getByLabelText("Re-enter new pin/password") - fireEvent.change(oldPasswordField, { target: { value: "wrongPassword" } }) + fireEvent.change(currentPasswordField, { + target: { value: "wrongPassword" }, + }) fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) fireEvent.change(confirmPasswordField, { target: { value: "newPassword" }, @@ -95,7 +104,7 @@ describe("Pin/password form", () => { expect(mockSettingsState.setStatus).toHaveBeenCalledTimes(2) ) expect(mockSettingsState.setStatusMessage).toHaveBeenCalledWith( - "Incorrect current pin/password." + passwordFormMessages.INCORRECT ) }) @@ -110,11 +119,13 @@ describe("Pin/password form", () => { const button = getByText("Edit") fireEvent.click(button) - const oldPasswordField = getByLabelText("Enter current pin/password") + const currentPasswordField = getByLabelText("Enter current pin/password") const newPasswordField = getByLabelText("Enter new pin/password") const confirmPasswordField = getByLabelText("Re-enter new pin/password") - fireEvent.change(oldPasswordField, { target: { value: "wrongPassword" } }) + fireEvent.change(currentPasswordField, { + target: { value: "wrongPassword" }, + }) fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) fireEvent.change(confirmPasswordField, { target: { value: "newPassword" }, @@ -123,12 +134,11 @@ describe("Pin/password form", () => { const submitButton = getByText("Save changes") fireEvent.click(submitButton) await waitFor(() => - expect(mockSettingsState.setStatus).toHaveBeenCalledTimes(4) + expect(mockSettingsState.setStatus).toHaveBeenCalledTimes(2) ) - expect(mockSettingsState.setStatus).toHaveBeenNthCalledWith(4, "failure") - expect(mockSettingsState.setStatusMessage).toHaveBeenNthCalledWith( - 2, - "Invalid new pin/password." + expect(mockSettingsState.setStatus).toHaveBeenNthCalledWith(2, "failure") + expect(mockSettingsState.setStatusMessage).toHaveBeenCalledWith( + passwordFormMessages.INVALID ) }) @@ -142,11 +152,11 @@ describe("Pin/password form", () => { const button = getByText("Edit") fireEvent.click(button) - const oldPasswordField = getByLabelText("Enter current pin/password") + const currentPasswordField = getByLabelText("Enter current pin/password") const newPasswordField = getByLabelText("Enter new pin/password") const confirmPasswordField = getByLabelText("Re-enter new pin/password") - fireEvent.change(oldPasswordField, { target: { value: "oldPassword" } }) + fireEvent.change(currentPasswordField, { target: { value: "oldPassword" } }) fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) fireEvent.change(confirmPasswordField, { target: { value: "newPassword" }, diff --git a/src/components/MyAccount/NewSettings/PasswordForm.tsx b/src/components/MyAccount/NewSettings/PasswordForm.tsx index 8d32d4f56..e99427c21 100644 --- a/src/components/MyAccount/NewSettings/PasswordForm.tsx +++ b/src/components/MyAccount/NewSettings/PasswordForm.tsx @@ -25,6 +25,11 @@ interface PasswordFormFieldProps { isInvalid?: boolean } +export const passwordFormMessages = { + INCORRECT: "Incorrect current pin/password.", + INVALID: "Invalid new pin/password.", +} + const PasswordFormField = ({ label, handler, @@ -62,7 +67,7 @@ const PasswordForm = ({ patronData, settingsState }: PasswordFormProps) => { const [isLoading, setIsLoading] = useState(false) const [isEditing, setIsEditing] = useState(false) const [formData, setFormData] = useState({ - oldPassword: "", + currentPassword: "", newPassword: "", confirmPassword: "", passwordsMatch: true, @@ -76,7 +81,7 @@ const PasswordForm = ({ patronData, settingsState }: PasswordFormProps) => { } const validateForm = - formData.oldPassword !== "" && + formData.currentPassword !== "" && formData.newPassword !== "" && formData.confirmPassword !== "" && formData.passwordsMatch @@ -85,23 +90,14 @@ const PasswordForm = ({ patronData, settingsState }: PasswordFormProps) => { const { name, value } = e.target let updatedFormData = { ...formData } + updatedFormData = { + ...updatedFormData, + [name]: value, + } if (name === "confirmPassword") { - updatedFormData = { - ...updatedFormData, - confirmPassword: value, - passwordsMatch: updatedFormData.newPassword === value, - } + updatedFormData.passwordsMatch = updatedFormData.newPassword === value } else if (name === "newPassword") { - updatedFormData = { - ...updatedFormData, - newPassword: value, - passwordsMatch: updatedFormData.confirmPassword === value, - } - } else { - updatedFormData = { - ...updatedFormData, - [name]: value, - } + updatedFormData.passwordsMatch = updatedFormData.confirmPassword === value } setFormData(updatedFormData) } @@ -119,7 +115,7 @@ const PasswordForm = ({ patronData, settingsState }: PasswordFormProps) => { "Content-Type": "application/json", }, body: JSON.stringify({ - oldPin: formData.oldPassword, + oldPin: formData.currentPassword, newPin: formData.newPassword, barcode: patronData.barcode, }), @@ -135,8 +131,8 @@ const PasswordForm = ({ patronData, settingsState }: PasswordFormProps) => { if (errorMessage) { errorMessage.startsWith("Invalid parameter") ? // Returning a more user-friendly error message. - setStatusMessage("Incorrect current pin/password.") - : setStatusMessage("Invalid new pin/password.") + setStatusMessage(passwordFormMessages.INCORRECT) + : setStatusMessage(passwordFormMessages.INVALID) } } } catch (error) { @@ -163,7 +159,7 @@ const PasswordForm = ({ patronData, settingsState }: PasswordFormProps) => { > {input}{" "} {index === 0 && inputs.length > 1 && ( - (P) - + )} ))} @@ -257,6 +259,7 @@ const PhoneEmailForm = ({ )} ) : ( + // User has no phone or email. { diff --git a/src/components/MyAccount/NewSettings/SettingsLabel.tsx b/src/components/MyAccount/NewSettings/SettingsLabel.tsx index 629f53fd0..7faafc471 100644 --- a/src/components/MyAccount/NewSettings/SettingsLabel.tsx +++ b/src/components/MyAccount/NewSettings/SettingsLabel.tsx @@ -4,14 +4,14 @@ const SettingsLabel = ({ icon, text }) => { return ( - + { - if (input == "Phone" && patronData.phones.length === 0) { - setError(true) - } - } - const sortedPickupLocations = [ patronData.homeLibrary, ...pickupLocations.filter( @@ -56,35 +50,37 @@ const HomeLibraryNotificationForm = ({ ), ] - const formUtils = { - initialState: - type === "notification" - ? notificationPreferenceMap.find( - (pref) => pref.code === patronData.notificationPreference - )?.name - : patronData.homeLibrary.name, - options: - type === "notification" - ? notificationPreferenceMap - : sortedPickupLocations, - icon: type === "notification" ? "communicationChatBubble" : "actionHome", - label: type === "notification" ? "Notification preference" : "Home library", - selectorId: - type === "notification" - ? "notification-preference-selector" - : "update-home-library-selector", - body: (code) => - type === "notification" - ? { - fixedFields: { "268": { label: "Notice Preference", value: code } }, - } - : { homeLibraryCode: `${code}` }, + const notificationFormUtils = { + initialState: notificationPreferenceMap.find( + (pref) => pref.code === patronData.notificationPreference + )?.name, + options: notificationPreferenceMap, + icon: "communicationChatBubble", + label: "Notification preference", + selectorId: "notification-preference-selector", } + const libraryFormUtils = { + initialState: patronData.homeLibrary.name, + options: sortedPickupLocations, + icon: "actionHome", + label: "Home library", + selectorId: "update-home-library-selector", + } + + const formUtils = + type === "notification" ? notificationFormUtils : libraryFormUtils + const [selection, setSelection] = useState(formUtils.initialState) const [tempSelection, setTempSelection] = useState(selection) + const validateInput = (input) => { + if (input == "Phone" && patronData.phones.length === 0) { + setError(true) + } + } + const handleSelectChange = (event) => { setTempSelection(event.target.value) validateInput(event.target.value) @@ -99,12 +95,18 @@ const HomeLibraryNotificationForm = ({ setIsLoading(true) setIsEditing(false) setStatus("") - const body = formUtils.body( + const code = type === "notification" ? notificationPreferenceMap.find((pref) => pref.name === tempSelection) ?.code : pickupLocations.find((loc) => loc.name === tempSelection)?.code - ) + + const body = + type === "notification" + ? { + fixedFields: { "268": { label: "Notice Preference", value: code } }, + } + : { homeLibraryCode: `${code}` } try { const response = await fetch( From 34ff7dc85a66cf6077c2b2b10634292966b13f4a Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Thu, 14 Nov 2024 11:33:08 -0500 Subject: [PATCH 53/86] More styling and renaming --- .../MyAccount/NewSettings/SettingsInputForm.tsx | 8 ++++---- .../MyAccount/NewSettings/SettingsLabel.tsx | 3 ++- .../NewSettings/SettingsSelectForm.tsx | 17 +++++++++++------ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/components/MyAccount/NewSettings/SettingsInputForm.tsx b/src/components/MyAccount/NewSettings/SettingsInputForm.tsx index 3f932ee01..14cd6acc9 100644 --- a/src/components/MyAccount/NewSettings/SettingsInputForm.tsx +++ b/src/components/MyAccount/NewSettings/SettingsInputForm.tsx @@ -16,17 +16,17 @@ import type { Patron } from "../../../types/myAccountTypes" import EditButton from "./EditButton" import AddButton from "./AddButton" -interface PhoneEmailFormProps { +interface SettingsInputFormProps { patronData: Patron settingsState inputType: "phones" | "emails" } -const PhoneEmailForm = ({ +const SettingsInputForm = ({ patronData, settingsState, inputType, -}: PhoneEmailFormProps) => { +}: SettingsInputFormProps) => { const isEmail = inputType === "emails" const { getMostUpdatedSierraAccountData } = useContext(PatronDataContext) const [inputs, setInputs] = useState( @@ -284,4 +284,4 @@ const PhoneEmailForm = ({ ) } -export default PhoneEmailForm +export default SettingsInputForm diff --git a/src/components/MyAccount/NewSettings/SettingsLabel.tsx b/src/components/MyAccount/NewSettings/SettingsLabel.tsx index 7faafc471..73ca5ffdd 100644 --- a/src/components/MyAccount/NewSettings/SettingsLabel.tsx +++ b/src/components/MyAccount/NewSettings/SettingsLabel.tsx @@ -4,11 +4,12 @@ const SettingsLabel = ({ icon, text }) => { return ( diff --git a/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx b/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx index fb4edf206..fe8390ee0 100644 --- a/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx +++ b/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx @@ -11,19 +11,19 @@ import SaveCancelButtons from "./SaveCancelButtons" import type { Patron, SierraCodeName } from "../../../types/myAccountTypes" import EditButton from "./EditButton" -interface HomeLibraryNotificationFormProps { +interface SettingsSelectFormProps { type: "library" | "notification" patronData: Patron settingsState pickupLocations: SierraCodeName[] } -const HomeLibraryNotificationForm = ({ +const SettingsSelectForm = ({ type, patronData, settingsState, pickupLocations, -}: HomeLibraryNotificationFormProps) => { +}: SettingsSelectFormProps) => { const { getMostUpdatedSierraAccountData } = useContext(PatronDataContext) const [isLoading, setIsLoading] = useState(false) const [isEditing, setIsEditing] = useState(false) @@ -147,10 +147,15 @@ const HomeLibraryNotificationForm = ({ ) : isEditing ? ( - {sortedPickupLocations.map((loc, i) => ( - - ))} - - ) - } - } - break - case "Notification preference": - { - inputField = ( - - ) - } - break - case "Phone": - inputField = ( - - ) - break - case "Email": - inputField = ( - - ) - break - case "Pin/Password": - inputField = ( - - **** - - - ) - } - return { - term: setting.term, - description: ( - {inputField} - ), - icon: setting.icon, - } - }) - .map(buildListElementsWithIcons) - return <>{formInputs} -} diff --git a/src/components/MyAccount/Settings/AccountSettingsTab.test.tsx b/src/components/MyAccount/Settings/AccountSettingsTab.test.tsx deleted file mode 100644 index 789720e84..000000000 --- a/src/components/MyAccount/Settings/AccountSettingsTab.test.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { patron } from "../../../../__test__/fixtures/rawSierraAccountData" -import AccountSettingsTab from "./AccountSettingsTab" -import MyAccount from "../../../models/MyAccount" -import { fireEvent, render, screen } from "../../../utils/testUtils" -import * as helpers from "../../../../pages/api/account/helpers" -import userEvent from "@testing-library/user-event" -import { filteredPickupLocations } from "../../../../__test__/fixtures/processedMyAccountData" -import { PatronDataProvider } from "../../../context/PatronDataContext" -import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" - -jest.spyOn(helpers, "updatePatronSettings") - -describe("AccountSettingsTab", () => { - const renderWithPatronProvider = (data) => { - render( - - - - ) - } - it("can render a complete patron", () => { - const myAccountPatron = MyAccount.prototype.buildPatron(patron) - renderWithPatronProvider(myAccountPatron) - - const emailLabel = screen.getAllByText("Email")[0] - const email = screen.getByText("streganonna@gmail.com") - expect(email).toBeInTheDocument() - expect(emailLabel).toBeInTheDocument() - - const phone = screen.getByText("Phone") - const phoneNumber = screen.getByText("123-456-7890") - expect(phone).toBeInTheDocument() - expect(phoneNumber).toBeInTheDocument() - - const homeLibrary = screen.getByText("Home library") - const snfl = screen.getByText("SNFL (formerly Mid-Manhattan)") - expect(homeLibrary).toBeInTheDocument() - expect(snfl).toBeInTheDocument() - - const pin = screen.getByText("Pin/Password") - const maskedPin = screen.getByText("****") - expect(pin).toBeInTheDocument() - expect(maskedPin).toBeInTheDocument() - }) - it("can render a patron with no email or phone", () => { - const myAccountPatron = MyAccount.prototype.buildPatron({ - ...patron, - emails: [], - phones: [], - }) - renderWithPatronProvider(myAccountPatron) - ;["Notification preference", "Home library", "Pin/Password"].forEach( - (patronInfo) => { - const element = screen.queryByText(patronInfo) - if (patronInfo === "Email" || patronInfo === "Phone") { - expect(element).not.toBeInTheDocument() - } else expect(element).toBeInTheDocument() - } - ) - }) - describe("editing", () => { - global.fetch = jest - .fn() - // post request to send settings - .mockResolvedValueOnce({ - json: async () => { - console.log("updated") - }, - status: 200, - } as Response) - // get request to update state - .mockResolvedValueOnce({ - json: async () => JSON.stringify({ patron: processedPatron }), - status: 200, - } as Response) - // failed post request - .mockResolvedValueOnce({ - json: async () => console.log("not updated"), - status: 500, - } as Response) - it("clicking edit focuses on first input and cancel focuses on edit", async () => { - const myAccountPatron = MyAccount.prototype.buildPatron({ - ...patron, - }) - renderWithPatronProvider(myAccountPatron) - - await userEvent.click(screen.getByText("Edit account settings")) - const inputs = screen.getAllByRole("textbox") - expect(inputs[0]).toHaveFocus() - await userEvent.click(screen.getByText("Cancel")) - expect(screen.getByText("Edit account settings")).toHaveFocus() - }) - it("clicking the edit button opens the form, \nclicking submit opens modal on success,\n closing modal toggles display", async () => { - const myAccountPatron = MyAccount.prototype.buildPatron({ - ...patron, - }) - renderWithPatronProvider(myAccountPatron) - - // open account settings - await userEvent.click(screen.getByText("Edit account settings")) - // verify inputs are present - const textInputs = screen.getAllByRole("textbox") - expect(textInputs).toHaveLength(2) - const dropdowns = screen.getAllByRole("combobox") - expect(dropdowns).toHaveLength(2) - // save changes - await userEvent.click(screen.getByText("Save Changes")) - expect( - screen.queryByText("Your account settings were successfully updated.", { - exact: false, - }) - ).toBeInTheDocument() - await userEvent.click(screen.getAllByText("OK")[0]) - textInputs.forEach((input) => expect(input).not.toBeInTheDocument()) - }) - - // this test only passes when it it run by itself - xit("clicking the edit button opens the form, \nclicking submit triggers error message on error response,\n closing modal toggles display", async () => { - const myAccountPatron = MyAccount.prototype.buildPatron({ - ...patron, - }) - renderWithPatronProvider(myAccountPatron) - - await userEvent.click(screen.getByText("Edit account settings")) - await userEvent.click(screen.getByText("Save Changes")) - - expect( - screen.queryByText("We were unable to update your account settings.", { - exact: false, - }) - ).toBeInTheDocument() - await userEvent.click(screen.getAllByText("OK")[0]) - expect(screen.queryByText("Save changes")).not.toBeInTheDocument() - }) - - it("prevents users from submitting empty fields according to notification preference", async () => { - const myAccountPatron = MyAccount.prototype.buildPatron({ - ...patron, - }) - renderWithPatronProvider(myAccountPatron) - - // open account settings - await userEvent.click(screen.getByText("Edit account settings")) - const saveButton = screen - .getByText("Save Changes", { exact: false }) - .closest("button") - expect(saveButton).not.toBeDisabled() - // confirm patron has email ("z") selected - expect( - screen.getByLabelText("Update notification preference") - ).toHaveValue("z") - const emailField = screen.getByLabelText("Update email") - // update email to empty string - fireEvent.change(emailField, { target: { value: "" } }) - - expect(saveButton).toBeDisabled() - fireEvent.change(emailField, { target: { value: "email@email" } }) - expect(saveButton).not.toBeDisabled() - await userEvent.click(screen.getByText("Save Changes")) - await userEvent.click(screen.getAllByText("OK")[0]) - }) - it("prevents users from submitting empty fields after changing notification preference", async () => { - const myAccountPatron = MyAccount.prototype.buildPatron({ - ...patron, - }) - renderWithPatronProvider(myAccountPatron) - - // open account settings - await userEvent.click(screen.getByText("Edit account settings")) - const saveButton = screen - .getByText("Save Changes", { exact: false }) - .closest("button") - expect(saveButton).not.toBeDisabled() - const notificationPreferenceSelector = screen.getByLabelText( - "Update notification preference" - ) - expect( - screen.getByLabelText("Update notification preference") - ).toHaveValue("z") - // update phone number to empty - const phoneField = screen.getByLabelText("Update phone number") - fireEvent.change(phoneField, { target: { value: "" } }) - // save button should be enabled because email is still selected as - // notification preference - expect(saveButton).not.toBeDisabled() - // make phone the prefered notifier - fireEvent.change(notificationPreferenceSelector, { - target: { value: "p" }, - }) - // now that phone is notification preference, but phone input is empty, - // user should not be able to save preferences. - expect(saveButton).toBeDisabled() - }) - }) -}) diff --git a/src/components/MyAccount/Settings/AccountSettingsTab.tsx b/src/components/MyAccount/Settings/AccountSettingsTab.tsx deleted file mode 100644 index fff07890a..000000000 --- a/src/components/MyAccount/Settings/AccountSettingsTab.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { - Form, - List, - Spacer, - useModal, - SkeletonLoader, - type TextInputRefType, -} from "@nypl/design-system-react-components" -import { useContext, useEffect, useRef, useState } from "react" -import styles from "../../../../styles/components/MyAccount.module.scss" -import AccountSettingsButtons from "./AccountSettingsButtons" -import { - AccountSettingsForm, - AccountSettingsDisplay, -} from "./AccountSettingsDisplayOptions" -import { parseAccountSettingsPayload } from "./AccountSettingsUtils" -import { - successModalProps, - failureModalProps, -} from "./SuccessAndFailureModalProps" -import { PatronDataContext } from "../../../context/PatronDataContext" - -const AccountSettingsTab = () => { - const { - patronDataLoading, - getMostUpdatedSierraAccountData, - updatedAccountData: { patron, pickupLocations }, - } = useContext(PatronDataContext) - const [currentlyEditing, setCurrentlyEditing] = useState(false) - const [modalProps, setModalProps] = useState(null) - const [isLoading, setIsLoading] = useState(false) - - const { onOpen: openModal, onClose: closeModal, Modal } = useModal() - - const [isFormValid, setIsFormValid] = useState(true) - - const editButtonRef = useRef() - const firstInputRef = useRef() - - const listElements = currentlyEditing ? ( - - ) : ( - - ) - const [focusOnAccountSettingsButton, setFocusOnAccountSettingButton] = - useState(false) - useEffect(() => { - if (currentlyEditing) { - firstInputRef.current?.focus() - } else if (!patronDataLoading && focusOnAccountSettingsButton) { - editButtonRef.current?.focus() - } - }, [currentlyEditing, focusOnAccountSettingsButton, patronDataLoading]) - - const submitAccountSettings = async (e) => { - e.preventDefault() - setIsLoading(true) - const payload = parseAccountSettingsPayload(e.target, patron) - const response = await fetch( - `/research/research-catalog/api/account/settings/${patron.id}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - } - ) - if (response.status === 200) { - await getMostUpdatedSierraAccountData() - setCurrentlyEditing(false) - setModalProps(successModalProps) - openModal() - } else { - setModalProps(failureModalProps) - openModal() - } - setIsLoading(false) - } - return isLoading ? ( - - ) : ( - <> - {modalProps && ( - { - closeModal() - setFocusOnAccountSettingButton(true) - }, - }} - /> - )} -
submitAccountSettings(e)} - > - - {listElements} - - - - - - ) -} - -export default AccountSettingsTab diff --git a/src/components/MyAccount/Settings/AccountSettingsUtils.test.ts b/src/components/MyAccount/Settings/AccountSettingsUtils.test.ts deleted file mode 100644 index 317f8773d..000000000 --- a/src/components/MyAccount/Settings/AccountSettingsUtils.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { - parseAccountSettingsPayload, - updatePhoneOrEmailArrayWithNewPrimary, - formatPhoneNumber, -} from "./AccountSettingsUtils" -import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" -import { formatDate, formatPatronName } from "../../../utils/myAccountUtils" - -describe("Account settings utils", () => { - describe("formatDate", () => { - it("can parse a date", () => { - const date = "2025-03-28" - expect(formatDate(date)).toEqual("March 28, 2025") - }) - }) - describe("formatPatronName", () => { - it("correctly formats the patron name when in all caps and comma-separated", () => { - expect(formatPatronName("LAST,FIRST")).toEqual("First Last") - }) - it("falls back to the input name when not comma-separated", () => { - expect(formatPatronName("QA Tester ILS")).toEqual("QA Tester ILS") - }) - it("can handle an initial", () => { - expect(formatPatronName("JOHNSON, LYNDON B")).toEqual("Lyndon B Johnson") - }) - }) - describe("formatPhoneNumber", () => { - it("formats a 10 digit number", () => { - const phones = [{ number: "1234567890", type: "t" }] - expect(formatPhoneNumber(phones)).toEqual("123-456-7890") - }) - it("formats an 11 digit number", () => { - const phones = [{ number: "01234567890", type: "t" }] - expect(formatPhoneNumber(phones)).toEqual("0-123-456-7890") - }) - it("returns any other number", () => { - const phones = [{ number: "1234567", type: "t" }] - expect(formatPhoneNumber(phones)).toEqual("1234567") - }) - }) - describe("parseAccountSettingsPayload", () => { - it("does not submit empty form inputs", () => { - const eventTarget = { - emails: { value: "" }, - phones: { value: "" }, - } - expect( - parseAccountSettingsPayload(eventTarget, processedPatron) - ).toStrictEqual({}) - }) - it("submits inputs with values", () => { - const eventTarget = { - emails: { value: "fusili@gmail.com" }, - phones: { value: "666" }, - homeLibrary: { value: "xx @spaghetti" }, - notificationPreference: { value: "z" }, - } - expect( - parseAccountSettingsPayload(eventTarget, processedPatron).emails - ).toStrictEqual([ - "fusili@gmail.com", - "streganonna@gmail.com", - "spaghettigrandma@gmail.com", - ]) - expect( - parseAccountSettingsPayload(eventTarget, processedPatron).phones - ).toStrictEqual([ - { - number: "666", - type: "t", - }, - { - number: "123-456-7890", - type: "t", - }, - ]) - expect( - parseAccountSettingsPayload(eventTarget, processedPatron) - .homeLibraryCode - ).toBe("xx ") - expect( - parseAccountSettingsPayload(eventTarget, processedPatron).fixedFields - ).toStrictEqual({ - 268: { - label: "Notice Preference", - value: "z", - }, - }) - }) - }) - describe("updatePhoneOrEmailArrayWithNewPrimary", () => { - it("appends new primary to the front of the array", () => { - expect( - updatePhoneOrEmailArrayWithNewPrimary("a", ["b", "c"]) - ).toStrictEqual(["a", "b", "c"]) - }) - it("does not return duplicate new primaries", () => { - expect( - updatePhoneOrEmailArrayWithNewPrimary("a", ["b", "c", "a"]) - ).toStrictEqual(["a", "b", "c"]) - }) - it("does not return duplicate new primary phone types", () => { - expect( - updatePhoneOrEmailArrayWithNewPrimary({ number: "789", type: "t" }, [ - { number: "123", type: "t" }, - { number: "456", type: "t" }, - { number: "789", type: "t" }, - ]) - ).toStrictEqual([ - { number: "789", type: "t" }, - { number: "123", type: "t" }, - { number: "456", type: "t" }, - ]) - }) - it("works for phone types", () => { - expect( - updatePhoneOrEmailArrayWithNewPrimary({ number: "123", type: "t" }, [ - { number: "456", type: "t" }, - ]) - ).toStrictEqual([ - { number: "123", type: "t" }, - { number: "456", type: "t" }, - ]) - }) - }) -}) diff --git a/src/components/MyAccount/Settings/AccountSettingsUtils.ts b/src/components/MyAccount/Settings/AccountSettingsUtils.ts deleted file mode 100644 index 8e2d33996..000000000 --- a/src/components/MyAccount/Settings/AccountSettingsUtils.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { IconNames } from "@nypl/design-system-react-components" -import type { Patron, PatronUpdateBody } from "../../../types/myAccountTypes" - -import { notificationPreferenceTuples } from "../../../utils/myAccountUtils" - -type Phone = { number: string; type: string } -type PhoneOrEmail = string | Phone - -export const isFormValid = (updatedForm: { - emails: string - phones: string - notificationPreference: string -}) => { - const phoneRegex = /^(?:\D*\d){10,11}\D*$/ - if (updatedForm.notificationPreference === "p") { - return updatedForm.phones !== "" && phoneRegex.test(updatedForm.phones) - } else if (updatedForm.notificationPreference === "z") { - return updatedForm.emails !== "" - } else return true -} - -export const formatPhoneNumber = (value: Phone[]) => { - const number = value[0]?.number - if (!number) return - if (number.length === 11) { - return `${number[0]}-${number.substring(1, 4)}-${number.substring( - 4, - 7 - )}-${number.substring(7)}` - } else if (number.length === 10) { - return `${number.substring(0, 3)}-${number.substring( - 3, - 6 - )}-${number.substring(6)}` - } else return number -} - -export const accountSettings = [ - { - field: "phones", - icon: "communicationCall", - term: "Phone", - description: formatPhoneNumber, - }, - { - field: "emails", - icon: "communicationEmail", - term: "Email", - description: (value: string[]) => value?.[0], - }, - { - field: "notificationPreference", - icon: "communicationChatBubble", - term: "Notification preference", - description: (pref): [code: string, label: string] => - notificationPreferenceTuples.find(([code]) => pref === code)?.[1], - }, - { - field: "homeLibrary", - icon: "actionHome", - term: "Home library", - description: (location) => location?.name, - }, - { - field: "pin", - icon: "actionLockClosed", - term: "Pin/Password", - description: () => "****", - }, -] as { - field: string - icon: IconNames - term: string - description?: (any) => string -}[] - -export const updatePhoneOrEmailArrayWithNewPrimary = ( - newPrimary: PhoneOrEmail, - currentValues: PhoneOrEmail[] -) => { - const removedNewPrimaryIfPresent = currentValues.filter((val) => { - if (val["type"]) return val["number"] !== newPrimary["number"] - return val !== newPrimary - }) - return [newPrimary, ...removedNewPrimaryIfPresent] -} - -/** Parses the account settings form submission event target and turns it into - * the payload for the patron settings update request. - */ -export const parseAccountSettingsPayload = ( - formSubmissionBody, - settingsData: Patron -) => { - return accountSettings.reduce((putRequestPayload, setting) => { - const field = setting.field - const fieldValue = formSubmissionBody[field]?.value - if (!fieldValue) { - return putRequestPayload - } - switch (field) { - case "pin": - // pin is handled in a separate dialog - break - case "emails": - putRequestPayload["emails"] = updatePhoneOrEmailArrayWithNewPrimary( - fieldValue, - settingsData.emails - ) as string[] - break - // Accepting one phone number as primary, since NYPL currently doesn't differentiate between mobile - // and home phones. - case "phones": - putRequestPayload["phones"] = updatePhoneOrEmailArrayWithNewPrimary( - { number: fieldValue.match(/\d+/g).join(""), type: "t" }, - settingsData.phones - ) as Phone[] - break - case "notificationPreference": - putRequestPayload["fixedFields"] = { - "268": { - label: "Notice Preference", - value: fieldValue, - }, - } - break - case "homeLibrary": - // Sierra API holds PUT endpoint only takes homeLibraryCode, which is a - // different type than the homeLibrary object used everywhere else in - // the app. - putRequestPayload.homeLibraryCode = fieldValue.split("@")[0] - } - return putRequestPayload - }, {} as PatronUpdateBody) -} diff --git a/src/components/MyAccount/NewSettings/AddButton.tsx b/src/components/MyAccount/Settings/AddButton.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/AddButton.tsx rename to src/components/MyAccount/Settings/AddButton.tsx diff --git a/src/components/MyAccount/NewSettings/EditButton.tsx b/src/components/MyAccount/Settings/EditButton.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/EditButton.tsx rename to src/components/MyAccount/Settings/EditButton.tsx diff --git a/src/components/MyAccount/NewSettings/EmailForm.test.tsx b/src/components/MyAccount/Settings/EmailForm.test.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/EmailForm.test.tsx rename to src/components/MyAccount/Settings/EmailForm.test.tsx diff --git a/src/components/MyAccount/NewSettings/HomeLibraryForm.test.tsx b/src/components/MyAccount/Settings/HomeLibraryForm.test.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/HomeLibraryForm.test.tsx rename to src/components/MyAccount/Settings/HomeLibraryForm.test.tsx diff --git a/src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx b/src/components/MyAccount/Settings/NewAccountSettingsTab.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/NewAccountSettingsTab.tsx rename to src/components/MyAccount/Settings/NewAccountSettingsTab.tsx diff --git a/src/components/MyAccount/NewSettings/NotificationForm.test.tsx b/src/components/MyAccount/Settings/NotificationForm.test.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/NotificationForm.test.tsx rename to src/components/MyAccount/Settings/NotificationForm.test.tsx diff --git a/src/components/MyAccount/Settings/PasswordChangeForm.tsx b/src/components/MyAccount/Settings/PasswordChangeForm.tsx deleted file mode 100644 index 719925b55..000000000 --- a/src/components/MyAccount/Settings/PasswordChangeForm.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { type Dispatch, useState } from "react" -import { - Form, - FormField, - TextInput, - Button, -} from "@nypl/design-system-react-components" -import styles from "../../../../styles/components/MyAccount.module.scss" -import { BASE_URL } from "../../../config/constants" -import type { Patron } from "../../../types/myAccountTypes" - -const PasswordChangeForm = ({ - patron, - updateModal, - onModalSubmit, -}: { - patron: Patron - updateModal: (errorMessage?: string) => void - onModalSubmit: () => void -}) => { - const [formData, setFormData] = useState({ - oldPassword: "", - newPassword: "", - confirmPassword: "", - passwordsMatch: true, - }) - - const validateForm = - formData.oldPassword !== "" && - formData.newPassword !== "" && - formData.confirmPassword !== "" && - formData.passwordsMatch - - const handleInputChange = (e) => { - const { name, value } = e.target - let updatedFormData = { ...formData } - - if (name === "confirmPassword") { - updatedFormData = { - ...updatedFormData, - confirmPassword: value, - passwordsMatch: updatedFormData.newPassword === value, - } - } else if (name === "newPassword") { - updatedFormData = { - ...updatedFormData, - newPassword: value, - passwordsMatch: updatedFormData.confirmPassword === value, - } - } else { - updatedFormData = { - ...updatedFormData, - [name]: value, - } - } - - setFormData(updatedFormData) - } - - const handleSubmit = async () => { - onModalSubmit() - const res = await fetch(`${BASE_URL}/api/account/update-pin/${patron.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - oldPin: formData.oldPassword, - newPin: formData.newPassword, - barcode: patron.barcode, - }), - }) - const errorMessage = await res.json() - res.status === 200 ? updateModal() : updateModal(errorMessage) - } - - return ( -
- - - - - - - - - - - - -
- ) -} - -export default PasswordChangeForm diff --git a/src/components/MyAccount/NewSettings/PasswordForm.test.tsx b/src/components/MyAccount/Settings/PasswordForm.test.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/PasswordForm.test.tsx rename to src/components/MyAccount/Settings/PasswordForm.test.tsx diff --git a/src/components/MyAccount/NewSettings/PasswordForm.tsx b/src/components/MyAccount/Settings/PasswordForm.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/PasswordForm.tsx rename to src/components/MyAccount/Settings/PasswordForm.tsx diff --git a/src/components/MyAccount/Settings/PasswordModal.test.tsx b/src/components/MyAccount/Settings/PasswordModal.test.tsx deleted file mode 100644 index 4f5a19d09..000000000 --- a/src/components/MyAccount/Settings/PasswordModal.test.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import React from "react" - -import { render, fireEvent, waitFor } from "../../../utils/testUtils" -import PasswordModal from "./PasswordModal" -import { processedPatron } from "../../../../__test__/fixtures/processedMyAccountData" - -describe("PasswordModal", () => { - test("renders", () => { - const { getByText } = render() - expect(getByText("Change pin/password")).toBeInTheDocument() - }) - - test("opens modal on button click", () => { - const { getByText, getByRole } = render( - - ) - const button = getByText("Change pin/password") - fireEvent.click(button) - const modal = getByRole("dialog") - expect(modal).toBeInTheDocument() - }) - - test("closes modal when clicking cancel button", () => { - const { getByText, queryByRole } = render( - - ) - const button = getByText("Change pin/password") - fireEvent.click(button) - const cancelButton = getByText("Cancel") - fireEvent.click(cancelButton) - expect(queryByRole("dialog")).toBeNull() - }) - - test("displays success modal when form is valid and submitted", async () => { - // Successful response - global.fetch = jest.fn().mockResolvedValue({ - status: 200, - json: async () => "Updated", - } as Response) - - const { getByText, getAllByText, getByLabelText, queryByText } = render( - - ) - const button = getByText("Change pin/password") - fireEvent.click(button) - - const oldPasswordField = getByLabelText("Enter current PIN/PASSWORD") - const newPasswordField = getByLabelText("Enter new PIN/PASSWORD") - const confirmPasswordField = getByLabelText("Re-enter new PIN/PASSWORD") - - fireEvent.change(oldPasswordField, { target: { value: "oldPassword" } }) - fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) - fireEvent.change(confirmPasswordField, { - target: { value: "newPassword" }, - }) - - const submitButton = getAllByText("Change PIN/PASSWORD")[1] - fireEvent.click(submitButton) - - await waitFor(() => { - expect( - queryByText("Your PIN/PASSWORD has been changed.") - ).toBeInTheDocument() - }) - - fireEvent.click(getByText("OK")) - - await waitFor(() => { - expect(queryByText("Your PIN/PASSWORD has been changed.")).toBeNull() - }) - }) - - test("disables submit button if any form field is empty", async () => { - const { getByText, getAllByText, getByLabelText } = render( - - ) - const button = getByText("Change pin/password") - fireEvent.click(button) - - const submitButton = getAllByText("Change PIN/PASSWORD")[1] - - const newPasswordField = getByLabelText("Enter new PIN/PASSWORD") - const confirmPasswordField = getByLabelText("Re-enter new PIN/PASSWORD") - - fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) - fireEvent.change(confirmPasswordField, { - target: { value: "wrongPassword" }, - }) - - expect(submitButton).toBeDisabled() - }) - - test("disables submit button if passwords don't match", async () => { - const { getByText, getAllByText, getByLabelText } = render( - - ) - const button = getByText("Change pin/password") - fireEvent.click(button) - - const oldPasswordField = getByLabelText("Enter current PIN/PASSWORD") - const newPasswordField = getByLabelText("Enter new PIN/PASSWORD") - const confirmPasswordField = getByLabelText("Re-enter new PIN/PASSWORD") - - fireEvent.change(oldPasswordField, { target: { value: "oldPassword" } }) - fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) - fireEvent.change(confirmPasswordField, { - target: { value: "wrongPassword" }, - }) - - const submitButton = getAllByText("Change PIN/PASSWORD")[1] - expect(submitButton).toBeDisabled() - }) - - test("displays failure modal if current password is wrong", async () => { - // Failure response - global.fetch = jest.fn().mockResolvedValue({ - status: 400, - json: async () => "Invalid parameter: Invalid PIN or barcode", - } as Response) - - const { getByText, getAllByText, getByLabelText, queryByText } = render( - - ) - const button = getByText("Change pin/password") - fireEvent.click(button) - - const oldPasswordField = getByLabelText("Enter current PIN/PASSWORD") - const newPasswordField = getByLabelText("Enter new PIN/PASSWORD") - const confirmPasswordField = getByLabelText("Re-enter new PIN/PASSWORD") - - fireEvent.change(oldPasswordField, { target: { value: "wrongPassword" } }) - fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) - fireEvent.change(confirmPasswordField, { - target: { value: "newPassword" }, - }) - - const submitButton = getAllByText("Change PIN/PASSWORD")[1] - fireEvent.click(submitButton) - - await waitFor(() => { - expect( - queryByText( - "We were unable to change your PIN/PASSWORD: Current PIN/PASSWORD is incorrect." - ) - ).toBeInTheDocument() - }) - }) - - test("displays failure modal if new password is invalid", async () => { - // Failure response - global.fetch = jest.fn().mockResolvedValue({ - status: 400, - json: async () => "PIN is not valid : PIN is trivial", - } as Response) - - const { getByText, getAllByText, getByLabelText, queryByText } = render( - - ) - const button = getByText("Change pin/password") - fireEvent.click(button) - - const oldPasswordField = getByLabelText("Enter current PIN/PASSWORD") - const newPasswordField = getByLabelText("Enter new PIN/PASSWORD") - const confirmPasswordField = getByLabelText("Re-enter new PIN/PASSWORD") - - fireEvent.change(oldPasswordField, { target: { value: "wrongPassword" } }) - fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) - fireEvent.change(confirmPasswordField, { - target: { value: "newPassword" }, - }) - - const submitButton = getAllByText("Change PIN/PASSWORD")[1] - fireEvent.click(submitButton) - - await waitFor(() => { - expect( - queryByText( - "We were unable to change your PIN/PASSWORD: New PIN/PASSWORD is invalid." - ) - ).toBeInTheDocument() - }) - }) -}) diff --git a/src/components/MyAccount/Settings/PasswordModal.tsx b/src/components/MyAccount/Settings/PasswordModal.tsx deleted file mode 100644 index 7fb37edae..000000000 --- a/src/components/MyAccount/Settings/PasswordModal.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { - useModal, - Box, - Icon, - Text, - List, - Button, - SkeletonLoader, -} from "@nypl/design-system-react-components" -import { useState } from "react" - -import styles from "../../../../styles/components/MyAccount.module.scss" -import PasswordChangeForm from "./PasswordChangeForm" -import type { Patron } from "../../../types/myAccountTypes" - -const PasswordModal = ({ patron }: { patron: Patron }) => { - const { onOpen: openModal, onClose: closeModal, Modal } = useModal() - - const entryModalProps = { - type: "default", - bodyContent: ( - - - Use a strong PIN/PASSWORD to protect your security and identity. - - -
  • - You have the option of creating a standard PIN (4 characters in - length) or the more secure option of creating a PASSWORD up to 32 - characters long. -
  • -
  • - You can create a PIN/PASSWORD that includes upper or lower case - characters (a-z, A-Z), numbers (0-9), and/or special characters - limited to the following: ~ ! ? @ # $ % ^ & * ( ) -
  • -
  • - PINs or PASSWORDS must not contain common patterns, for example: a - character that is repeated 3 or more times (0001, aaaa, aaaatf54, - x7gp3333), or four characters repeated two or more times (1212, - abab, abcabc, ababx7gp, x7gp3434). -
  • -
  • PINs and PASSWORDS must NOT contain a period.
  • -
    - setModalProps(loadingProps)} - /> -
    - ), - closeButtonLabel: "Cancel", - headingText:
    Change PIN/PASSWORD
    , - onClose: () => { - closeModal() - }, - } - - const loadingProps = { - ...entryModalProps, - bodyContent: , - onClose: () => null, - closeButtonLabel: "Loading", - } - - const [modalProps, setModalProps] = useState(entryModalProps) - - function updateModal(errorMessage?: string) { - if (errorMessage) { - errorMessage.startsWith("Invalid parameter") - ? // Returning a more user-friendly error message. - setModalProps(failureModalProps("Current PIN/PASSWORD is incorrect.")) - : setModalProps(failureModalProps("New PIN/PASSWORD is invalid.")) - } else { - setModalProps(successModalProps) - } - } - - const successModalProps = { - type: "default", - bodyContent: ( - - Your PIN/PASSWORD has been changed. - - ), - closeButtonLabel: "OK", - headingText: ( -
    - <> - - PIN/PASSWORD change was successful - -
    - ), - onClose: async () => { - closeModal() - setModalProps(entryModalProps) - }, - } - - const failureModalProps = (errorMessage) => ({ - type: "default", - bodyContent: ( - - We were unable to change your PIN/PASSWORD: {errorMessage} - Please try again. - - ), - closeButtonLabel: "OK", - headingText: ( -
    - <> - - PIN/PASSWORD change failed - -
    - ), - onClose: async () => { - closeModal() - setModalProps(entryModalProps) - }, - }) - - return ( - <> - - - - ) -} - -export default PasswordModal diff --git a/src/components/MyAccount/NewSettings/PhoneForm.test.tsx b/src/components/MyAccount/Settings/PhoneForm.test.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/PhoneForm.test.tsx rename to src/components/MyAccount/Settings/PhoneForm.test.tsx diff --git a/src/components/MyAccount/NewSettings/SaveCancelButtons.tsx b/src/components/MyAccount/Settings/SaveCancelButtons.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/SaveCancelButtons.tsx rename to src/components/MyAccount/Settings/SaveCancelButtons.tsx diff --git a/src/components/MyAccount/NewSettings/SettingsInputForm.tsx b/src/components/MyAccount/Settings/SettingsInputForm.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/SettingsInputForm.tsx rename to src/components/MyAccount/Settings/SettingsInputForm.tsx diff --git a/src/components/MyAccount/NewSettings/SettingsLabel.tsx b/src/components/MyAccount/Settings/SettingsLabel.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/SettingsLabel.tsx rename to src/components/MyAccount/Settings/SettingsLabel.tsx diff --git a/src/components/MyAccount/NewSettings/SettingsSelectForm.tsx b/src/components/MyAccount/Settings/SettingsSelectForm.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/SettingsSelectForm.tsx rename to src/components/MyAccount/Settings/SettingsSelectForm.tsx diff --git a/src/components/MyAccount/NewSettings/StatusBanner.tsx b/src/components/MyAccount/Settings/StatusBanner.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/StatusBanner.tsx rename to src/components/MyAccount/Settings/StatusBanner.tsx diff --git a/src/components/MyAccount/Settings/SuccessAndFailureModalProps.tsx b/src/components/MyAccount/Settings/SuccessAndFailureModalProps.tsx deleted file mode 100644 index 48c24c364..000000000 --- a/src/components/MyAccount/Settings/SuccessAndFailureModalProps.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Box, Icon, Link, Text } from "@nypl/design-system-react-components" -import styles from "../../../../styles/components/MyAccount.module.scss" - -export const successModalProps = { - type: "default", - bodyContent: ( - - - Your account settings were successfully updated. - - - ), - closeButtonLabel: "OK", - headingText: ( -
    - <> - - Update successful - -
    - ), -} -export const failureModalProps = { - type: "default", - bodyContent: ( - - - We were unable to update your account settings. Please try again or{" "} - contact us{" "} - for assistance. - - - ), - closeButtonLabel: "OK", - headingText: ( -
    - <> - - Update failed - -
    - ), -} diff --git a/src/components/MyAccount/NewSettings/UsernameForm.test.tsx b/src/components/MyAccount/Settings/UsernameForm.test.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/UsernameForm.test.tsx rename to src/components/MyAccount/Settings/UsernameForm.test.tsx diff --git a/src/components/MyAccount/NewSettings/UsernameForm.tsx b/src/components/MyAccount/Settings/UsernameForm.tsx similarity index 100% rename from src/components/MyAccount/NewSettings/UsernameForm.tsx rename to src/components/MyAccount/Settings/UsernameForm.tsx From e8d4eec3901065e2c92c716c526d30e0516b888d Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 26 Nov 2024 11:18:37 -0500 Subject: [PATCH 84/86] Correcting changelog --- CHANGELOG | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 389332992..e9054fdae 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,7 +5,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -### Unreleased +## Unreleased + +### Updated - Updated phone, email, notification preference and home library to be individually editable in Account Settings (SCC-4337, SCC-4254, SCC-4253) - Updated username to be editable in My Account header (SCC-4236) From b846751bbcd614e27aabc6c285a63a3ca8a1e3c7 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:56:45 -0500 Subject: [PATCH 85/86] Vera fixes --- src/components/MyAccount/Settings/EmailForm.test.tsx | 2 -- .../MyAccount/Settings/HomeLibraryForm.test.tsx | 2 -- .../MyAccount/Settings/PasswordForm.test.tsx | 10 +++++----- src/components/MyAccount/Settings/PhoneForm.test.tsx | 2 -- src/server/nyplApiClient/index.ts | 4 ++-- 5 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/components/MyAccount/Settings/EmailForm.test.tsx b/src/components/MyAccount/Settings/EmailForm.test.tsx index ed8f1d500..9636b63db 100644 --- a/src/components/MyAccount/Settings/EmailForm.test.tsx +++ b/src/components/MyAccount/Settings/EmailForm.test.tsx @@ -10,7 +10,6 @@ describe("email form", () => { editingField: "", setEditingField: jest.fn(), } - const accountFetchSpy = jest.fn() beforeEach(() => { jest.clearAllMocks() @@ -24,7 +23,6 @@ describe("email form", () => { const component = ( { editingField: "", setEditingField: jest.fn(), } - const accountFetchSpy = jest.fn() const component = ( { }) describe("Pin/password form", () => { - test("disables submit button if any form field is empty", async () => { + it("disables submit button if any form field is empty", async () => { const { getByText, getByLabelText } = render(component) const button = getByText("Edit") fireEvent.click(button) @@ -56,7 +56,7 @@ describe("Pin/password form", () => { expect(submitButton).toBeDisabled() }) - test("disables submit button if passwords don't match", async () => { + it("disables submit button if passwords don't match", async () => { const { getByText, getByLabelText } = render(component) const button = getByText("Edit") fireEvent.click(button) @@ -75,7 +75,7 @@ describe("Pin/password form", () => { expect(submitButton).toBeDisabled() }) - test("sets failure if current password is wrong", async () => { + it("sets failure if current password is wrong", async () => { // Failure response global.fetch = jest.fn().mockResolvedValue({ status: 400, @@ -108,7 +108,7 @@ describe("Pin/password form", () => { ) }) - test("sets failure if new password is invalid", async () => { + it("sets failure if new password is invalid", async () => { // Failure response global.fetch = jest.fn().mockResolvedValue({ status: 400, @@ -142,7 +142,7 @@ describe("Pin/password form", () => { ) }) - test("successfully sets patron data if every field is valid", async () => { + it("successfully sets patron data if every field is valid", async () => { global.fetch = jest.fn().mockResolvedValue({ status: 200, json: async () => "Updated", diff --git a/src/components/MyAccount/Settings/PhoneForm.test.tsx b/src/components/MyAccount/Settings/PhoneForm.test.tsx index 5dac0801d..685d5d043 100644 --- a/src/components/MyAccount/Settings/PhoneForm.test.tsx +++ b/src/components/MyAccount/Settings/PhoneForm.test.tsx @@ -10,7 +10,6 @@ describe("phone form", () => { editingField: "", setEditingField: jest.fn(), } - const accountFetchSpy = jest.fn() beforeEach(() => { jest.clearAllMocks() @@ -24,7 +23,6 @@ describe("phone form", () => { const component = ( { - if (CACHE.clients[apiName]) { - return CACHE.clients[apiName] + if (CACHE.clients[`${apiName}${version}`]) { + return CACHE.clients[`${apiName}${version}`] } const baseUrl = From 245d6e32a133abedce04cf5c07faa2e74b829074 Mon Sep 17 00:00:00 2001 From: Emma Mansell <73774046+7emansell@users.noreply.github.com> Date: Wed, 27 Nov 2024 07:59:09 -0500 Subject: [PATCH 86/86] Adding tests --- .../MyAccount/Settings/UsernameForm.test.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/MyAccount/Settings/UsernameForm.test.tsx b/src/components/MyAccount/Settings/UsernameForm.test.tsx index fb0b924c6..479b37210 100644 --- a/src/components/MyAccount/Settings/UsernameForm.test.tsx +++ b/src/components/MyAccount/Settings/UsernameForm.test.tsx @@ -80,7 +80,20 @@ describe("username form", () => { expect(screen.queryByText(/edit/)).not.toBeInTheDocument() }) - it("validates username input correctly", () => { + it("allows editing when Add button is clicked from no username", () => { + render(noUsernameComponent) + fireEvent.click(screen.getByRole("button", { name: /add/i })) + + expect(screen.getByLabelText("Username")).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument() + expect( + screen.getByRole("button", { name: /save changes/i }) + ).toBeInTheDocument() + expect(screen.queryByText(/edit/)).not.toBeInTheDocument() + }) + + it("validates invalid username input correctly", () => { render(component) fireEvent.click(screen.getByRole("button", { name: /edit/i })) @@ -91,7 +104,7 @@ describe("username form", () => { expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled() }) - it("validates empty username correctly", () => { + it("validates empty username input correctly", () => { render(component) fireEvent.click(screen.getByRole("button", { name: /edit/i })) @@ -112,6 +125,8 @@ describe("username form", () => { expect( screen.queryByDisplayValue(processedPatron.username) ).not.toBeInTheDocument() + + expect(screen.getByText("+ Add username")).toBeInTheDocument() }) it("calls submitInput with valid data", async () => { @@ -142,11 +157,11 @@ describe("username form", () => { fireEvent.click(screen.getByRole("button", { name: /edit/i })) const input = screen.getByLabelText("Username") - fireEvent.change(input, { target: { value: "modification" } }) + fireEvent.change(input, { target: { value: "newUsername" } }) fireEvent.click(screen.getByRole("button", { name: /cancel/i })) expect(screen.getByText(processedPatron.username)).toBeInTheDocument() - expect(screen.queryByDisplayValue("modification")).not.toBeInTheDocument() + expect(screen.queryByDisplayValue("newUsername")).not.toBeInTheDocument() }) })