diff --git a/CHANGELOG b/CHANGELOG index 7d5ef575c..047c16618 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -5,9 +5,20 @@ 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). -## Updated +## Prerelease + +### Updated - Pull in Owning institution from items to populate bib details ([SCC-4334](https://newyorkpubliclibrary.atlassian.net/browse/SCC-4334)) +- Refactor bib details model so most non-url properties return value, not object with value and label +- add call number and standard numbers to advanced search ([SCC-4326](https://newyorkpubliclibrary.atlassian.net/browse/SCC-4326)) + +## 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) ## [1.3.6] 2024-11-6 diff --git a/__test__/pages/account/account.test.tsx b/__test__/pages/account/account.test.tsx index 1510dfef6..df6239d76 100644 --- a/__test__/pages/account/account.test.tsx +++ b/__test__/pages/account/account.test.tsx @@ -231,22 +231,6 @@ describe("MyAccount page", () => { const result = await getServerSideProps({ req: req, res: mockRes }) expect(result.props.tabsPath).toBe("overdues") }) - it("can handle no username", () => { - render( - - ) - const username = screen.queryByText("Username") - expect(username).toBeNull() - }) it("renders notification banner if user has fines", () => { render( jest.requireActual("next-router-mock")) describe("Advanced Search Form", () => { + beforeEach(async () => { + render() + }) const submit = () => { fireEvent( screen.getByTestId("submit-advanced-search-button"), @@ -22,8 +26,6 @@ describe("Advanced Search Form", () => { await userEvent.click(screen.getByText("Clear fields")) }) it("displays alert when no fields are submitted", () => { - render() - submit() screen.getByText(defaultEmptySearchErrorMessage) }) @@ -32,32 +34,36 @@ describe("Advanced Search Form", () => { // final input in output string in test. the broken test is // commented out below. it.todo("can set keyword, contributor, title, subject") - // async () => { - // render() + // , async () => { + // // const [keywordInput, contributorInput, titleInput, subjectInput] = [ - // "Keywords", + // "Keyword", // "Title", // "Author", // "Subject", + // "Call number", + // "Unique identifier", // ].map((field) => screen.getByLabelText(field)) - // await act(async () => { - // await userEvent.type(subjectInput, "italian food") - // await userEvent.type(keywordInput, "spaghetti") - // await userEvent.type(contributorInput, "strega nonna") - // await userEvent.type(titleInput, "il amore di pasta") - // // this set stimeout is to ad - // // eslint-disable-next-line @typescript-eslint/no-empty-function - // setTimeout(() => {}, 300) - // submit() + // fireEvent.change(subjectInput, { target: { value: "italian food" } }) + // fireEvent.change(keywordInput, { target: { value: "spaghetti" } }) + // fireEvent.change(contributorInput, { target: { value: "strega nonna" } }) + // fireEvent.change(titleInput, { target: { value: "il amore di pasta" } }) + // submit() + // await waitFor(() => // expect(mockRouter.asPath).toBe( // "/search?q=spaghetti&contributor=il+amore+di+pasta&title=strega+nonna&subject=italian+food" // ) - // }) + // ) // }) - it("can select languages", async () => { - render() + it("renders inputs for all text input fields", () => { + textInputFields.map(({ label }) => { + const input = screen.getByLabelText(label) + expect(input).toBeInTheDocument() + }) + }) + it("can select languages", async () => { const languageSelect = screen.getByLabelText("Language") await userEvent.selectOptions(languageSelect, "Azerbaijani") submit() @@ -67,7 +73,6 @@ describe("Advanced Search Form", () => { ) }) it("can check material checkboxes", async () => { - render() await userEvent.click(screen.getByLabelText("Notated music")) await userEvent.click(screen.getByLabelText("Cartographic")) submit() @@ -78,7 +83,6 @@ describe("Advanced Search Form", () => { ) }) it("can check location checkboxes", async () => { - render() const location = searchAggregations.buildingLocation[0] await userEvent.click(screen.getByLabelText(location.label as string)) submit() @@ -88,7 +92,6 @@ describe("Advanced Search Form", () => { }) it("can clear the form", async () => { - render() const notatedMusic = screen.getByLabelText("Notated music") await userEvent.click(notatedMusic) const cartographic = screen.getByLabelText("Cartographic") diff --git a/accountREADME.md b/accountREADME.md index c88db4eb2..29fa23a5d 100644 --- a/accountREADME.md +++ b/accountREADME.md @@ -89,7 +89,8 @@ Route parameter is the patron ID. Request body can include any fields on the pat exampleBody: { emails: ['new@email.com'], - phones: [6466600432] + phones: [12345678], + homeLibraryCode: 'sn' }, ``` diff --git a/pages/account/[[...index]].tsx b/pages/account/[[...index]].tsx index 094a92085..e2825ef5e 100644 --- a/pages/account/[[...index]].tsx +++ b/pages/account/[[...index]].tsx @@ -50,6 +50,7 @@ export default function MyAccount({ assistance. ) + useEffect(() => { resetCountdown() // to avoid a reference error on document in the modal, wait to render it @@ -111,16 +112,17 @@ export async function getServerSideProps({ req, res }) { }, } } - // Parsing path from url to pass to ProfileTabs. + + // Parsing path from URL const tabsPathRegex = /\/account\/(.+)/ const match = req.url.match(tabsPathRegex) 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. */ + // Redirecting invalid paths and cleaning extra parts off valid paths. if (tabsPath) { const allowedPaths = ["items", "requests", "overdues", "settings"] if ( @@ -147,6 +149,7 @@ export async function getServerSideProps({ req, res }) { } } } + return { props: { accountData: { checkouts, holds, patron, fines, pickupLocations }, diff --git a/pages/api/account/helpers.ts b/pages/api/account/helpers.ts index db9e8654b..db4dd30e9 100644 --- a/pages/api/account/helpers.ts +++ b/pages/api/account/helpers.ts @@ -1,5 +1,6 @@ import sierraClient from "../../../src/server/sierraClient" import type { HTTPResponse } from "../../../src/types/appTypes" +import nyplApiClient from "../../../src/server/nyplApiClient" /** * PUT request to Sierra to update patron PIN, first validating with previous PIN. @@ -27,6 +28,52 @@ export async function updatePin( } } +/** + * PUT request to Sierra to update patron username, first validating that it's available. + * Returns status and message about request. + */ +export async function updateUsername( + patronId: string, + newUsername: string +): Promise { + try { + // If the new username is an empty string, skips validation and directly updates in Sierra. + const client = await sierraClient() + if (newUsername === "") { + const client = await sierraClient() + await client.put(`patrons/${patronId}`, { + varFields: [{ fieldTag: "u", content: newUsername }], + }) + return { status: 200, message: "Username removed successfully" } + } else { + const platformClient = await nyplApiClient({ version: "v0.3" }) + const response = await platformClient.post("/validations/username", { + username: newUsername, + }) + + if (response?.type === "available-username") { + await client.put(`patrons/${patronId}`, { + varFields: [{ fieldTag: "u", content: newUsername }], + }) + return { status: 200, message: `Username updated to ${newUsername}` } + } else if (response?.type === "unavailable-username") { + // Username taken but not an error, returns a message. + return { status: 200, message: "Username taken" } + } else { + throw new Error("Username update failed") + } + } + } catch (error) { + return { + status: error?.status || 500, + message: + error?.message || + error.response?.data?.description || + "An error occurred", + } + } +} + /** * PUT request to Sierra to update patron settings. Returns status and message about request. */ diff --git a/pages/api/account/settings/[id].ts b/pages/api/account/settings/[id].ts index f6c4af046..4dba5e7b2 100644 --- a/pages/api/account/settings/[id].ts +++ b/pages/api/account/settings/[id].ts @@ -22,6 +22,7 @@ export default async function handler( if (req.method == "GET") { responseMessage = "Please make a PUT request to this endpoint." } + if (req.method == "PUT") { /** We get the patron id from the request: */ const patronId = req.query.id as string diff --git a/pages/api/account/username/[id].ts b/pages/api/account/username/[id].ts new file mode 100644 index 000000000..87d96a345 --- /dev/null +++ b/pages/api/account/username/[id].ts @@ -0,0 +1,40 @@ +import type { NextApiResponse, NextApiRequest } from "next" +import initializePatronTokenAuth from "../../../../src/server/auth" +import { updateUsername } from "../helpers" + +/** + * API route handler for /api/account/username/{patronId} + */ +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + let responseMessage = "Request error" + let responseStatus = 400 + const patronTokenResponse = await initializePatronTokenAuth(req.cookies) + const cookiePatronId = patronTokenResponse.decodedPatron?.sub + if (!cookiePatronId) { + responseStatus = 403 + responseMessage = "No authenticated patron" + return res.status(responseStatus).json(responseMessage) + } + if (req.method == "GET") { + responseMessage = "Please make a PUT request to this endpoint." + } + if (req.method == "PUT") { + /** We get the patron id from the request: */ + const patronId = req.query.id as string + const { username } = req.body + /** We check that the patron cookie matches the patron id in the request, + * i.e.,the logged in user is updating their own username. */ + if (patronId == cookiePatronId) { + const response = await updateUsername(patronId, username) + responseStatus = response.status + responseMessage = response.message + } else { + responseStatus = 403 + responseMessage = "Authenticated patron does not match request" + } + } + res.status(responseStatus).json(responseMessage) +} diff --git a/pages/search/advanced.tsx b/pages/search/advanced.tsx index d1d4130aa..242084280 100644 --- a/pages/search/advanced.tsx +++ b/pages/search/advanced.tsx @@ -85,7 +85,6 @@ export default function AdvancedSearch({ e.preventDefault() alert && setAlert(false) const target = e.target as HTMLInputElement - dispatch({ type: type, field: target.name, @@ -106,7 +105,6 @@ export default function AdvancedSearch({ e.preventDefault() if (!validateDateRange()) return const queryString = getSearchQuery(searchFormState as SearchParams) - if (!queryString.length) { setErrorMessage(defaultEmptySearchErrorMessage) setAlert(true) @@ -164,7 +162,7 @@ export default function AdvancedSearch({ onSubmit={handleSubmit} > - + {textInputFields.map(({ name, label }) => { return ( @@ -201,7 +199,7 @@ export default function AdvancedSearch({ {} - + { const { isLargerThanMobile } = useNYPLBreakpoints() + const [usernameStatus, setUsernameStatus] = useState("") + const [usernameStatusMessage, setUsernameStatusMessage] = useState("") + const usernameBannerRef = useRef(null) + + useEffect(() => { + if (usernameStatus !== "" && usernameBannerRef.current) { + usernameBannerRef.current.focus() + } + }, [usernameStatus]) + + const usernameState = { + setUsernameStatus, + setUsernameStatusMessage, + } const profileData = ( [ @@ -20,7 +39,9 @@ const ProfileHeader = ({ patron }: { patron: Patron }) => { { icon: "actionIdentity", term: "Username", - description: patron.username, + description: ( + + ), }, { icon: "actionPayment", @@ -53,19 +74,32 @@ const ProfileHeader = ({ patron }: { patron: Patron }) => { .map(buildListElementsWithIcons) return ( - - {profileData} - + <> + {usernameStatus !== "" && ( +
+ +
+ )} + + {profileData} + + ) } diff --git a/src/components/MyAccount/ProfileTabs.tsx b/src/components/MyAccount/ProfileTabs.tsx index b223d48c4..7148f3fdf 100644 --- a/src/components/MyAccount/ProfileTabs.tsx +++ b/src/components/MyAccount/ProfileTabs.tsx @@ -1,12 +1,12 @@ 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" import { PatronDataContext } from "../../context/PatronDataContext" import { useContext } from "react" +import NewAccountSettingsTab from "./Settings/NewAccountSettingsTab" interface ProfileTabsPropsType { activePath: string @@ -49,7 +49,7 @@ const ProfileTabs = ({ activePath }: ProfileTabsPropsType) => { : []), { label: "Account settings", - content: , + content: , urlPath: "settings", }, ] diff --git a/src/components/MyAccount/Settings/AccountSettingsButtons.tsx b/src/components/MyAccount/Settings/AccountSettingsButtons.tsx deleted file mode 100644 index cb101ab88..000000000 --- a/src/components/MyAccount/Settings/AccountSettingsButtons.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Icon, Button } from "@nypl/design-system-react-components" -import type { Dispatch, MutableRefObject } from "react" -import styles from "../../../../styles/components/MyAccount.module.scss" -import CancelSubmitButtonGroup from "../../RefineSearch/CancelSubmitButtonGroup" - -interface AccountSettingsButtonsPropsType { - currentlyEditing: boolean - formValid: boolean - setCurrentlyEditing: Dispatch> - editButtonRef: MutableRefObject - setFocusOnAccountSettingsButton: Dispatch> -} - -const AccountSettingsButtons = ({ - currentlyEditing, - formValid, - setCurrentlyEditing, - editButtonRef, - setFocusOnAccountSettingsButton, -}: AccountSettingsButtonsPropsType) => { - const toggleCurrentlyEditing = (doWeWantToEdit: boolean) => { - setCurrentlyEditing(doWeWantToEdit) - setFocusOnAccountSettingsButton(!doWeWantToEdit) - } - const editButton = ( - - ) - - const cancelAndSaveButtons = ( - toggleCurrentlyEditing(false)} - formName="account-settings" - submitLabel="Save changes" - cancelLabel="Cancel" - disableSubmit={!formValid} - /> - ) - - return currentlyEditing ? cancelAndSaveButtons : editButton -} - -export default AccountSettingsButtons diff --git a/src/components/MyAccount/Settings/AccountSettingsDisplayOptions.test.tsx b/src/components/MyAccount/Settings/AccountSettingsDisplayOptions.test.tsx deleted file mode 100644 index 8cc6b4ab9..000000000 --- a/src/components/MyAccount/Settings/AccountSettingsDisplayOptions.test.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { - AccountSettingsForm, - AccountSettingsDisplay, -} from "./AccountSettingsDisplayOptions" -import { - emptyPatron, - filteredPickupLocations, - processedPatron, -} from "../../../../__test__/fixtures/processedMyAccountData" -import { render, screen, within } from "../../../utils/testUtils" -import { useRef } from "react" - -const FormWithRef = ({ patron }) => { - const ref = useRef() - return ( - { - return true - }} - /> - ) -} - -describe("AccountSettingsDisplayOptions", () => { - describe("Display normal patron", () => { - beforeEach(() => { - render( - - ) - }) - it("displays patron's home library", () => { - const homeLibrary = screen.getByText("SNFL (formerly Mid-Manhattan)") - expect(homeLibrary).toBeInTheDocument() - }) - it("displays a selector with patron's notification selected", () => { - const pref = screen.getByTestId("Notification preference") - const notificationPreference = within(pref).getByText("Phone") - expect(notificationPreference).toBeInTheDocument() - }) - it("displays a text input with patron's primary email displayed", () => { - const primaryEmail = screen.getByText("streganonna@gmail.com") - expect(primaryEmail).toBeInTheDocument() - }) - it("displays a text input with patron's primary phone displayed", () => { - const phone = screen.getByText("123-456-7890") - expect(phone).toBeInTheDocument() - }) - it("displays password mask", () => { - const pin = screen.getByText("****") - expect(pin).toBeInTheDocument() - }) - }) - describe("empty patron", () => { - it("doesn't display email, phone, or notification preference if not specified", () => { - render() - const missingFields = ["Email", "Phone", "Notification preference"].map( - (name) => { - return screen.queryByLabelText(name) - } - ) - missingFields.forEach((field) => expect(field).not.toBeInTheDocument()) - }) - it("displays empty email, phone, or notification preference in edit mode if not specified", () => { - render() - const missingFields = [ - "Update email", - "Update phone number", - "Update notification preference", - ].map((name) => { - return screen.queryByLabelText(name) - }) - missingFields.forEach((field) => expect(field).toBeInTheDocument()) - }) - }) - describe("Update", () => { - beforeEach(() => { - render() - }) - it("displays a selector with patron's home library selected", () => { - const homeLibraryCode = screen.getByDisplayValue( - "SNFL (formerly Mid-Manhattan)" - ) - expect(homeLibraryCode).toBeInTheDocument() - }) - it("displays a selector with patron's notification selected", () => { - const notificationPreference = screen.getByLabelText("Update email") - expect(notificationPreference).toBeInTheDocument() - }) - it("displays a text input with patron's primary email displayed", () => { - const primaryEmail = screen.getByDisplayValue("streganonna@gmail.com") - expect(primaryEmail).toBeInTheDocument() - }) - it("displays a text input with patron's primary phone displayed", () => { - const phone = screen.getByDisplayValue("123-456-7890") - expect(phone).toBeInTheDocument() - }) - it("displays password mask", () => { - const pin = screen.getByText("****") - expect(pin).toBeInTheDocument() - }) - }) -}) diff --git a/src/components/MyAccount/Settings/AccountSettingsDisplayOptions.tsx b/src/components/MyAccount/Settings/AccountSettingsDisplayOptions.tsx deleted file mode 100644 index c742d44cb..000000000 --- a/src/components/MyAccount/Settings/AccountSettingsDisplayOptions.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { - Text, - FormField, - Select, - TextInput, - Box, - type TextInputRefType, -} from "@nypl/design-system-react-components" -import { notificationPreferenceTuples } from "../../../utils/myAccountUtils" -import type { Patron, SierraCodeName } from "../../../types/myAccountTypes" -import { accountSettings, isFormValid } from "./AccountSettingsUtils" -import { buildListElementsWithIcons } from "../IconListElement" -import type { Dispatch, JSX, MutableRefObject, ReactNode } from "react" -import { useState } from "react" -import PasswordModal from "./PasswordModal" - -export const AccountSettingsDisplay = ({ patron }: { patron: Patron }) => { - const terms = accountSettings - .map((setting) => { - const description = setting.description - ? setting.description(patron[setting.field]) - : patron[setting.field] - return { - icon: setting.icon, - term: setting.term, - description, - } - }) - .filter((listData) => !!listData.description) - return <>{terms.map(buildListElementsWithIcons)} -} - -export const AccountSettingsForm = ({ - pickupLocations, - patron, - setIsFormValid, - firstInputRef, -}: { - pickupLocations: SierraCodeName[] - firstInputRef: MutableRefObject - patron: Patron - setIsFormValid: Dispatch> -}) => { - const [formData, setFormData] = useState({ - phones: patron.phones[0]?.number, - emails: patron.emails[0], - notificationPreference: patron.notificationPreference[0], - }) - - const handleInputChange = (e) => { - const { value, name } = e.target - const updatedFormData = { ...formData, [name]: value } - setIsFormValid(isFormValid(updatedFormData)) - setFormData(updatedFormData) - } - - const formInputs = accountSettings - .map((setting) => { - let inputField: - | string - | number - | boolean - | JSX.Element - | Iterable - switch (setting.term) { - case "Home library": - { - if (pickupLocations) { - const sortedPickupLocations = [ - patron.homeLibrary, - ...pickupLocations.filter( - (loc) => loc.code.trim() !== patron.homeLibrary.code.trim() - ), - ] - inputField = ( - - ) - } - } - 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 6838973a7..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/Settings/AddButton.tsx b/src/components/MyAccount/Settings/AddButton.tsx new file mode 100644 index 000000000..33f1da869 --- /dev/null +++ b/src/components/MyAccount/Settings/AddButton.tsx @@ -0,0 +1,30 @@ +import { Button } from "@nypl/design-system-react-components" + +type AddButtonProps = { + inputType?: string + label: string + onClick: () => void +} + +const AddButton = ({ inputType, label, onClick }: AddButtonProps) => { + return ( + + ) +} + +export default AddButton diff --git a/src/components/MyAccount/Settings/EditButton.tsx b/src/components/MyAccount/Settings/EditButton.tsx new file mode 100644 index 000000000..edff21264 --- /dev/null +++ b/src/components/MyAccount/Settings/EditButton.tsx @@ -0,0 +1,26 @@ +import { Button, Icon } from "@nypl/design-system-react-components" + +type EditButtonProps = { + buttonId: string + onClick: () => void +} + +const EditButton = ({ buttonId, onClick }: EditButtonProps) => { + return ( + + ) +} + +export default EditButton diff --git a/src/components/MyAccount/Settings/EmailForm.test.tsx b/src/components/MyAccount/Settings/EmailForm.test.tsx new file mode 100644 index 000000000..9636b63db --- /dev/null +++ b/src/components/MyAccount/Settings/EmailForm.test.tsx @@ -0,0 +1,141 @@ +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 SettingsInputForm from "./SettingsInputForm" + +describe("email form", () => { + const mockSettingsState = { + setStatus: jest.fn(), + editingField: "", + setEditingField: 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 emails")[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() + expect(screen.queryByText(/edit/)).not.toBeInTheDocument() + }) + + it("validates email input correctly", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getAllByLabelText("Update emails")[0] + fireEvent.change(input, { target: { value: "invalid-email" } }) + + expect( + screen.getByText("Please enter a valid and unique 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 emails").length).toBe( + processedPatron.emails.length + 1 + ) + }) + + 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 emails")[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 emails")[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() + }) +}) diff --git a/src/components/MyAccount/Settings/HomeLibraryForm.test.tsx b/src/components/MyAccount/Settings/HomeLibraryForm.test.tsx new file mode 100644 index 000000000..6375ef7d4 --- /dev/null +++ b/src/components/MyAccount/Settings/HomeLibraryForm.test.tsx @@ -0,0 +1,108 @@ +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 "./SettingsSelectForm" + +describe("home library form", () => { + const mockSettingsState = { + setStatus: jest.fn(), + editingField: "", + setEditingField: jest.fn(), + } + + const component = ( + + + + ) + + beforeEach(() => { + jest.clearAllMocks() + global.fetch = jest.fn().mockResolvedValue({ + json: async () => { + console.log("Updated") + }, + status: 200, + } as Response) + }) + + 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/Settings/NewAccountSettingsTab.tsx b/src/components/MyAccount/Settings/NewAccountSettingsTab.tsx new file mode 100644 index 000000000..7a4ae694f --- /dev/null +++ b/src/components/MyAccount/Settings/NewAccountSettingsTab.tsx @@ -0,0 +1,76 @@ +import { Flex } from "@nypl/design-system-react-components" +import { useContext, useEffect, useRef, useState } from "react" +import { PatronDataContext } from "../../../context/PatronDataContext" +import SettingsInputForm from "./SettingsInputForm" +import SettingsSelectForm from "./SettingsSelectForm" +import PasswordForm from "./PasswordForm" +import { StatusBanner } from "./StatusBanner" + +type StatusType = "" | "failure" | "success" + +const NewAccountSettingsTab = () => { + const { + updatedAccountData: { patron, pickupLocations }, + } = useContext(PatronDataContext) + const [status, setStatus] = useState("") + const [statusMessage, setStatusMessage] = useState("") + const [editingField, setEditingField] = useState("") + const bannerRef = useRef(null) + + const settingsState = { + setStatus, + editingField, + setEditingField, + } + + const passwordSettingsState = { + ...settingsState, + setStatusMessage, + } + + useEffect(() => { + if (status !== "" && bannerRef.current) { + bannerRef.current.focus() + } + }, [status]) + + return ( + <> + {status !== "" && ( +
+ +
+ )} + + + + + + + + + ) +} + +export default NewAccountSettingsTab diff --git a/src/components/MyAccount/Settings/NotificationForm.test.tsx b/src/components/MyAccount/Settings/NotificationForm.test.tsx new file mode 100644 index 000000000..a603255e8 --- /dev/null +++ b/src/components/MyAccount/Settings/NotificationForm.test.tsx @@ -0,0 +1,161 @@ +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 SettingsSelectForm from "./SettingsSelectForm" +import type { Patron } from "../../../types/myAccountTypes" + +describe("notification preference form", () => { + const mockSettingsState = { + setStatus: jest.fn(), + editingField: "", + setEditingField: jest.fn(), + } + const accountFetchSpy = 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.queryByDisplayValue("None")).not.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: "Phone" }, + }) + + 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":"p"}}}', + headers: { "Content-Type": "application/json" }, + method: "PUT", + } + ) + expect(accountFetchSpy).toHaveBeenCalled() + }) + + it("displays none as an option if that's already user's selection", async () => { + const processedPatronWithNone = { + notificationPreference: "-", + username: "pastadisciple", + name: "Strega Nonna", + barcode: "23333121538324", + formattedBarcode: "2 3333 12153 8324", + expirationDate: "March 28, 2025", + emails: ["streganonna@gmail.com", "spaghettigrandma@gmail.com"], + phones: [ + { + number: "123-456-7890", + type: "t", + }, + ], + homeLibrary: { code: "sn ", name: "SNFL (formerly Mid-Manhattan)" }, + id: 6742743, + } as Patron + const component = ( + + + + ) + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + expect(screen.getByText("None")).toBeInTheDocument() + }) + + 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: "Phone" }, + }) + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })) + + expect(screen.getByText(processedPatronPref)).toBeInTheDocument() + expect(screen.queryByDisplayValue("Phone")).not.toBeInTheDocument() + }) +}) 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/Settings/PasswordForm.test.tsx b/src/components/MyAccount/Settings/PasswordForm.test.tsx new file mode 100644 index 000000000..5c5dce990 --- /dev/null +++ b/src/components/MyAccount/Settings/PasswordForm.test.tsx @@ -0,0 +1,169 @@ +import React from "react" +import { render, fireEvent, waitFor } from "../../../utils/testUtils" +import PasswordForm from "./PasswordForm" +import { + filteredPickupLocations, + processedPatron, +} from "../../../../__test__/fixtures/processedMyAccountData" +import { PatronDataProvider } from "../../../context/PatronDataContext" +import { passwordFormMessages } from "./PasswordForm" + +const mockSettingsState = { + setStatus: jest.fn(), + setStatusMessage: jest.fn(), + editingField: "", + setEditingField: jest.fn(), +} +const accountFetchSpy = jest.fn() + +const component = ( + + + +) + +beforeEach(() => { + mockSettingsState.setStatus.mockClear() + mockSettingsState.setStatusMessage.mockClear() + mockSettingsState.setEditingField.mockClear() +}) + +describe("Pin/password form", () => { + it("disables submit button if any form field is empty", async () => { + const { getByText, getByLabelText } = render(component) + const button = getByText("Edit") + fireEvent.click(button) + + const submitButton = getByText("Save changes") + + 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() + }) + + it("disables submit button if passwords don't match", async () => { + const { getByText, getByLabelText } = render(component) + const button = getByText("Edit") + 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 = getByText("Save changes") + expect(submitButton).toBeDisabled() + }) + + it("sets failure 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, getByLabelText } = render(component) + const button = getByText("Edit") + fireEvent.click(button) + + const currentPasswordField = getByLabelText("Enter current pin/password") + const newPasswordField = getByLabelText("Enter new pin/password") + const confirmPasswordField = getByLabelText("Re-enter new pin/password") + + fireEvent.change(currentPasswordField, { + target: { value: "wrongPassword" }, + }) + fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) + fireEvent.change(confirmPasswordField, { + target: { value: "newPassword" }, + }) + + const submitButton = getByText("Save changes") + fireEvent.click(submitButton) + await waitFor(() => + expect(mockSettingsState.setStatus).toHaveBeenCalledTimes(2) + ) + expect(mockSettingsState.setStatusMessage).toHaveBeenCalledWith( + passwordFormMessages.INCORRECT + ) + }) + + it("sets failure 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, getByLabelText } = render(component) + const button = getByText("Edit") + fireEvent.click(button) + + const currentPasswordField = getByLabelText("Enter current pin/password") + const newPasswordField = getByLabelText("Enter new pin/password") + const confirmPasswordField = getByLabelText("Re-enter new pin/password") + + fireEvent.change(currentPasswordField, { + target: { value: "wrongPassword" }, + }) + fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) + fireEvent.change(confirmPasswordField, { + target: { value: "newPassword" }, + }) + + const submitButton = getByText("Save changes") + fireEvent.click(submitButton) + await waitFor(() => + expect(mockSettingsState.setStatus).toHaveBeenCalledTimes(2) + ) + expect(mockSettingsState.setStatus).toHaveBeenNthCalledWith(2, "failure") + expect(mockSettingsState.setStatusMessage).toHaveBeenCalledWith( + passwordFormMessages.INVALID + ) + }) + + it("successfully sets patron data if every field is valid", async () => { + global.fetch = jest.fn().mockResolvedValue({ + status: 200, + json: async () => "Updated", + } as Response) + + const { getByText, getByLabelText } = render(component) + const button = getByText("Edit") + fireEvent.click(button) + + const currentPasswordField = getByLabelText("Enter current pin/password") + const newPasswordField = getByLabelText("Enter new pin/password") + const confirmPasswordField = getByLabelText("Re-enter new pin/password") + + fireEvent.change(currentPasswordField, { target: { value: "oldPassword" } }) + fireEvent.change(newPasswordField, { target: { value: "newPassword" } }) + fireEvent.change(confirmPasswordField, { + target: { value: "newPassword" }, + }) + + const submitButton = getByText("Save changes") + fireEvent.click(submitButton) + await waitFor(() => expect(accountFetchSpy).toHaveBeenCalled()) + }) +}) diff --git a/src/components/MyAccount/Settings/PasswordForm.tsx b/src/components/MyAccount/Settings/PasswordForm.tsx new file mode 100644 index 000000000..e99427c21 --- /dev/null +++ b/src/components/MyAccount/Settings/PasswordForm.tsx @@ -0,0 +1,248 @@ +import { useContext, useState } from "react" +import { PatronDataContext } from "../../../context/PatronDataContext" +import { + Banner, + Flex, + SkeletonLoader, + Text, + TextInput, +} from "@nypl/design-system-react-components" +import SettingsLabel from "./SettingsLabel" +import SaveCancelButtons from "./SaveCancelButtons" +import type { Patron } from "../../../types/myAccountTypes" +import { BASE_URL } from "../../../config/constants" +import EditButton from "./EditButton" + +interface PasswordFormProps { + patronData: Patron + settingsState +} + +interface PasswordFormFieldProps { + label: string + handler: (e) => void + name: string + isInvalid?: boolean +} + +export const passwordFormMessages = { + INCORRECT: "Incorrect current pin/password.", + INVALID: "Invalid new pin/password.", +} + +const PasswordFormField = ({ + label, + handler, + name, + isInvalid, +}: PasswordFormFieldProps) => { + return ( + + + + + ) +} + +const PasswordForm = ({ patronData, settingsState }: PasswordFormProps) => { + const { getMostUpdatedSierraAccountData } = useContext(PatronDataContext) + const [isLoading, setIsLoading] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [formData, setFormData] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + passwordsMatch: true, + }) + const { setStatus, setStatusMessage, editingField, setEditingField } = + settingsState + + const cancelEditing = () => { + setIsEditing(false) + setEditingField("") + } + + const validateForm = + formData.currentPassword !== "" && + formData.newPassword !== "" && + formData.confirmPassword !== "" && + formData.passwordsMatch + + const handleInputChange = (e) => { + const { name, value } = e.target + let updatedFormData = { ...formData } + + updatedFormData = { + ...updatedFormData, + [name]: value, + } + if (name === "confirmPassword") { + updatedFormData.passwordsMatch = updatedFormData.newPassword === value + } else if (name === "newPassword") { + updatedFormData.passwordsMatch = updatedFormData.confirmPassword === value + } + setFormData(updatedFormData) + } + + const submitForm = async () => { + setIsLoading(true) + setIsEditing(false) + setStatus("") + try { + const response = await fetch( + `${BASE_URL}/api/account/update-pin/${patronData.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + oldPin: formData.currentPassword, + newPin: formData.newPassword, + barcode: patronData.barcode, + }), + } + ) + + const errorMessage = await response.json() + if (response.status === 200) { + await getMostUpdatedSierraAccountData() + setStatus("success") + } else { + setStatus("failure") + if (errorMessage) { + errorMessage.startsWith("Invalid parameter") + ? // Returning a more user-friendly error message. + setStatusMessage(passwordFormMessages.INCORRECT) + : setStatusMessage(passwordFormMessages.INVALID) + } + } + } catch (error) { + console.error("Error submitting", error) + } finally { + setIsLoading(false) + setEditingField("") + } + } + + return ( + <> + {isLoading ? ( + + ) : isEditing ? ( + <> + + + + + + + + + + + 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. +
+ + } + /> + + ) : ( + + + + + **** + + {editingField === "" && ( + { + setIsEditing(true) + setEditingField("password") + }} + /> + )} + + + )} + + ) +} + +export default PasswordForm 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/Settings/PhoneForm.test.tsx b/src/components/MyAccount/Settings/PhoneForm.test.tsx new file mode 100644 index 000000000..685d5d043 --- /dev/null +++ b/src/components/MyAccount/Settings/PhoneForm.test.tsx @@ -0,0 +1,153 @@ +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 SettingsInputForm from "./SettingsInputForm" + +describe("phone form", () => { + const mockSettingsState = { + setStatus: jest.fn(), + editingField: "", + setEditingField: 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", async () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + fireEvent.click( + screen.getByRole("button", { name: /\+ add a phone number/i }) + ) + + const input = screen.getAllByLabelText("Update phones")[1] + + fireEvent.change(input, { target: { value: "5106974153" } }) + + expect(screen.getAllByRole("textbox").length).toBe( + processedPatron.phones.length + 1 + ) + + fireEvent.click(screen.getByLabelText("Remove phone")) + + expect(screen.getAllByLabelText("Update phones").length).toBe( + processedPatron.phones.length + ) + }) + + it("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", + { + 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() + }) +}) diff --git a/src/components/MyAccount/Settings/SaveCancelButtons.tsx b/src/components/MyAccount/Settings/SaveCancelButtons.tsx new file mode 100644 index 000000000..e6bd00711 --- /dev/null +++ b/src/components/MyAccount/Settings/SaveCancelButtons.tsx @@ -0,0 +1,45 @@ +import { ButtonGroup, Button } from "@nypl/design-system-react-components" + +type SaveCancelButtonProps = { + isDisabled?: boolean + onCancel: () => void + onSave: () => void + inputType?: "phones" | "emails" +} + +const SaveCancelButtons = ({ + isDisabled, + onCancel, + onSave, + inputType, +}: SaveCancelButtonProps) => { + return ( + + + + + ) +} + +export default SaveCancelButtons diff --git a/src/components/MyAccount/Settings/SettingsInputForm.tsx b/src/components/MyAccount/Settings/SettingsInputForm.tsx new file mode 100644 index 000000000..14cd6acc9 --- /dev/null +++ b/src/components/MyAccount/Settings/SettingsInputForm.tsx @@ -0,0 +1,287 @@ +import { + Icon, + TextInput, + Text, + Flex, + Button, + SkeletonLoader, + Form, + Box, +} from "@nypl/design-system-react-components" +import { useContext, useState } from "react" +import { PatronDataContext } from "../../../context/PatronDataContext" +import SaveCancelButtons from "./SaveCancelButtons" +import SettingsLabel from "./SettingsLabel" +import type { Patron } from "../../../types/myAccountTypes" +import EditButton from "./EditButton" +import AddButton from "./AddButton" + +interface SettingsInputFormProps { + patronData: Patron + settingsState + inputType: "phones" | "emails" +} + +const SettingsInputForm = ({ + patronData, + settingsState, + inputType, +}: SettingsInputFormProps) => { + const isEmail = inputType === "emails" + const { getMostUpdatedSierraAccountData } = useContext(PatronDataContext) + const [inputs, setInputs] = useState( + isEmail + ? patronData[inputType] + : patronData[inputType]?.map((phone) => phone.number) || [] + ) + const [isLoading, setIsLoading] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [error, setError] = useState(false) + + const { setStatus, editingField, setEditingField } = settingsState + + 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" + }.`, + formId: `${isEmail ? "email" : "phone"}-form`, + icon: `communication${isEmail ? "Email" : "Call"}`, + inputLabel: isEmail ? "Email" : "Phone", + } + + const validateInput = (currentInput, inputs) => { + const isInputUnique = + inputs.filter((input) => input === currentInput).length === 1 + return formUtils.regex.test(currentInput) && isInputUnique + } + + const handleInputChange = (e, index) => { + const { value } = e.target + const updatedInputs = [...tempInputs] + updatedInputs[index] = value + setTempInputs(updatedInputs) + + const firstInputEmpty = index === 0 + + if (firstInputEmpty && (!value || !validateInput(value, updatedInputs))) { + setError(true) + } else { + const hasInvalidInput = updatedInputs.some( + (input) => !validateInput(input, updatedInputs) + ) + setError(hasInvalidInput) + } + } + + const handleRemove = (index) => { + const updatedInputs = tempInputs.filter((_, i) => i !== index) + setTempInputs(updatedInputs) + + // Immediately revalidate remaining inputs. + const hasInvalidInput = updatedInputs.some( + (input) => !validateInput(input, updatedInputs) + ) + setError(hasInvalidInput) + } + + const handleAdd = () => { + const updatedInputs = [...tempInputs, ""] + setTempInputs(updatedInputs) + + // Immediately revalidate remaining inputs. + const hasInvalidInput = updatedInputs.some( + (input) => !validateInput(input, updatedInputs) + ) + setError(hasInvalidInput) + } + + const handleClearableCallback = (index) => { + const updatedInputs = [...tempInputs] + updatedInputs[index] = "" + setTempInputs(updatedInputs) + setError(true) + } + + const cancelEditing = () => { + setTempInputs([...inputs]) + setIsEditing(false) + setEditingField("") + setError(false) + } + + const submitInputs = async () => { + setIsLoading(true) + setIsEditing(false) + setStatus("") + const validInputs = tempInputs.filter((input) => + validateInput(input, tempInputs) + ) + const body = isEmail + ? JSON.stringify({ [inputType]: validInputs }) + : JSON.stringify({ + [inputType]: validInputs.map((input) => ({ + number: input, + type: "t", + })), + }) + try { + const response = await fetch( + `/research/research-catalog/api/account/settings/${patronData.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: body, + } + ) + + if (response.status === 200) { + await getMostUpdatedSierraAccountData() + setStatus("success") + setInputs([...validInputs]) + setTempInputs([...validInputs]) + } else { + setStatus("failure") + setTempInputs([...inputs]) + } + } catch (error) { + console.error("Error submitting", inputType, error) + } finally { + setIsLoading(false) + setEditingField("") + } + } + + return ( + <> + + + + {isLoading ? ( + + ) : isEditing ? ( + + {tempInputs.map((input, index) => ( +
    + + 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) + + )} + + ))} + + {editingField === "" && tempInputs.length > 0 && ( + { + setIsEditing(true) + setEditingField(inputType) + }} + /> + )} + + ) : ( + // User has no phone or email. + { + setIsEditing(true) + setEditingField(inputType) + handleAdd() + }} + label={formUtils.addButtonLabel} + /> + )} + + {isEditing && ( + + )} +
    + + ) +} + +export default SettingsInputForm diff --git a/src/components/MyAccount/Settings/SettingsLabel.tsx b/src/components/MyAccount/Settings/SettingsLabel.tsx new file mode 100644 index 000000000..73ca5ffdd --- /dev/null +++ b/src/components/MyAccount/Settings/SettingsLabel.tsx @@ -0,0 +1,29 @@ +import { Flex, Icon, Text } from "@nypl/design-system-react-components" + +const SettingsLabel = ({ icon, text }) => { + return ( + + + + {text} + + + ) +} + +export default SettingsLabel diff --git a/src/components/MyAccount/Settings/SettingsSelectForm.tsx b/src/components/MyAccount/Settings/SettingsSelectForm.tsx new file mode 100644 index 000000000..fe8390ee0 --- /dev/null +++ b/src/components/MyAccount/Settings/SettingsSelectForm.tsx @@ -0,0 +1,208 @@ +import { useContext, useState } from "react" +import { PatronDataContext } from "../../../context/PatronDataContext" +import { + Flex, + Select, + SkeletonLoader, + Text, +} from "@nypl/design-system-react-components" +import SettingsLabel from "./SettingsLabel" +import SaveCancelButtons from "./SaveCancelButtons" +import type { Patron, SierraCodeName } from "../../../types/myAccountTypes" +import EditButton from "./EditButton" + +interface SettingsSelectFormProps { + type: "library" | "notification" + patronData: Patron + settingsState + pickupLocations: SierraCodeName[] +} + +const SettingsSelectForm = ({ + type, + patronData, + settingsState, + pickupLocations, +}: SettingsSelectFormProps) => { + const { getMostUpdatedSierraAccountData } = useContext(PatronDataContext) + const [isLoading, setIsLoading] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [error, setError] = useState(false) + + const { setStatus, editingField, setEditingField } = settingsState + + const notificationPreferenceMap = + patronData.notificationPreference === "-" + ? [ + { code: "z", name: "Email" }, + { code: "p", name: "Phone" }, + { code: "-", name: "None" }, + ] + : [ + { code: "z", name: "Email" }, + { code: "p", name: "Phone" }, + ] + + const sortedPickupLocations = [ + patronData.homeLibrary, + ...pickupLocations.filter( + (loc) => loc.code.trim() !== patronData.homeLibrary.code.trim() + ), + ] + + 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) + } + + const cancelEditing = () => { + setIsEditing(false) + setEditingField("") + } + + const submitSelection = async () => { + setIsLoading(true) + setIsEditing(false) + setStatus("") + 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( + `/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() + setStatus("success") + setSelection(tempSelection) + setTempSelection(tempSelection) + } else { + setStatus("failure") + setTempSelection(tempSelection) + } + } catch (error) { + console.error("Error submitting", error) + } finally { + setIsLoading(false) + setEditingField("") + } + } + + return ( + <> + + + {isLoading ? ( + + ) : isEditing ? ( + + + + ) : ( + + + {selection} + + {editingField === "" && ( + { + setIsEditing(true) + setEditingField(type) + }} + /> + )} + + )} + {isEditing && ( + + )} + + + ) +} + +export default SettingsSelectForm diff --git a/src/components/MyAccount/Settings/StatusBanner.tsx b/src/components/MyAccount/Settings/StatusBanner.tsx new file mode 100644 index 000000000..b40622288 --- /dev/null +++ b/src/components/MyAccount/Settings/StatusBanner.tsx @@ -0,0 +1,56 @@ +import { Banner, Text, Link } from "@nypl/design-system-react-components" + +export type StatusType = "" | "failure" | "usernameFailure" | "success" + +type StatusBannerProps = { + status: StatusType + statusMessage: string +} + +const successContent = ( + + Your changes were saved. + +) + +const generalFailureContent = ( + + Your changes were not saved. + +) + +const specificFailureContent = (statusMessage: string) => { + return ( + + {statusMessage} Please try again or{" "} + contact us{" "} + for assistance. + + ) +} + +const statusContent = (status, statusMessage) => { + if (status === "success") { + return successContent + } + if (status === "failure" && statusMessage !== "") { + return specificFailureContent(statusMessage) + } else { + return generalFailureContent + } +} + +export const StatusBanner = ({ status, statusMessage }: StatusBannerProps) => { + return ( + + {statusContent(status, statusMessage)} + + } + type={status === "failure" ? "negative" : "positive"} + /> + ) +} 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/Settings/UsernameForm.test.tsx b/src/components/MyAccount/Settings/UsernameForm.test.tsx new file mode 100644 index 000000000..479b37210 --- /dev/null +++ b/src/components/MyAccount/Settings/UsernameForm.test.tsx @@ -0,0 +1,167 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { PatronDataProvider } from "../../../context/PatronDataContext" +import { + emptyPatron, + filteredPickupLocations, + processedPatron, +} from "../../../../__test__/fixtures/processedMyAccountData" +import UsernameForm from "./UsernameForm" + +describe("username form", () => { + const mockUsernameState = { + setUsernameStatus: jest.fn(), + setUsernameStatusMessage: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + global.fetch = jest.fn().mockResolvedValue({ + json: async () => { + console.log("Updated") + }, + status: 200, + } as Response) + }) + + const component = ( + + + + ) + + const noUsernameComponent = ( + + + + ) + + it("renders correctly with initial username", () => { + render(component) + + expect(screen.getByText(processedPatron.username)).toBeInTheDocument() + + expect(screen.getByRole("button", { name: /edit/i })).toBeInTheDocument() + }) + + it("renders correctly with when user has no username", () => { + render(noUsernameComponent) + + expect(screen.getByText("+ Add username")).toBeInTheDocument() + + expect(screen.queryByRole("button", { name: /edit/i })).toBeNull() + }) + + it("allows editing when edit button is clicked", () => { + render(component) + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + expect(screen.getByLabelText("Username")).toBeInTheDocument() + expect( + screen.getByDisplayValue(processedPatron.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("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 })) + + const input = screen.getByLabelText("Username") + fireEvent.change(input, { target: { value: "!!!!!" } }) + + expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled() + }) + + it("validates empty username input correctly", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getByLabelText("Username") + fireEvent.change(input, { target: { value: "" } }) + + expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled() + }) + + it("removes username when delete icon is clicked", () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + fireEvent.click(screen.getByLabelText("Remove username")) + + expect( + screen.queryByDisplayValue(processedPatron.username) + ).not.toBeInTheDocument() + + expect(screen.getByText("+ Add username")).toBeInTheDocument() + }) + + it("calls submitInput with valid data", async () => { + render(component) + + fireEvent.click(screen.getByRole("button", { name: /edit/i })) + + const input = screen.getByLabelText("Username") + fireEvent.change(input, { target: { value: "newUsername" } }) + + fireEvent.click(screen.getByRole("button", { name: /save changes/i })) + + await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2)) + + expect(fetch).toHaveBeenCalledWith( + `/research/research-catalog/api/account/username/${processedPatron.id}`, + expect.objectContaining({ + body: '{"username":"newUsername"}', + 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("Username") + fireEvent.change(input, { target: { value: "newUsername" } }) + + fireEvent.click(screen.getByRole("button", { name: /cancel/i })) + + expect(screen.getByText(processedPatron.username)).toBeInTheDocument() + expect(screen.queryByDisplayValue("newUsername")).not.toBeInTheDocument() + }) +}) diff --git a/src/components/MyAccount/Settings/UsernameForm.tsx b/src/components/MyAccount/Settings/UsernameForm.tsx new file mode 100644 index 000000000..2d5b2d60a --- /dev/null +++ b/src/components/MyAccount/Settings/UsernameForm.tsx @@ -0,0 +1,228 @@ +import { + Icon, + TextInput, + Text, + Flex, + Button, + SkeletonLoader, + Banner, +} from "@nypl/design-system-react-components" +import { useContext, useState } from "react" +import { PatronDataContext } from "../../../context/PatronDataContext" +import SaveCancelButtons from "./SaveCancelButtons" +import type { Patron } from "../../../types/myAccountTypes" +import EditButton from "./EditButton" +import AddButton from "./AddButton" +import { BASE_URL } from "../../../config/constants" + +export const usernameStatusMessages = { + USERNAME_FAILURE: + "This username already exists. Please try a different username or contact us for assistance.", + FAILURE: + "Your changes could not be saved. Please try again or contact us for assistance. ", + SUCCESS: "Your changes were saved.", +} + +interface UsernameFormProps { + patron: Patron + usernameState +} + +const UsernameForm = ({ patron, usernameState }: UsernameFormProps) => { + const { getMostUpdatedSierraAccountData } = useContext(PatronDataContext) + const [isLoading, setIsLoading] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [error, setError] = useState(false) + /** + * In Sierra, the user NOT having a username is represented by the empty string: username = "". + * Within this form, the user NOT having a username is represented by: username = null, so the + * empty string is an invalid username. + */ + const [usernameInSierra, setusernameInSierra] = useState( + patron.username === "" ? null : patron.username + ) + const [tempUsername, setTempUsername] = useState(usernameInSierra) + const currentUsernameNotDeleted = tempUsername !== null + + const { setUsernameStatus, setUsernameStatusMessage } = usernameState + + const validateUsername = (username: string) => { + const usernameRegex = /^[a-zA-Z0-9]{5,15}$/ + return usernameRegex.test(username) + } + + const cancelEditing = () => { + setTempUsername(usernameInSierra) + setIsEditing(false) + setError(false) + } + + const handleInputChange = (e) => { + const { value } = e.target + setTempUsername(value) + if (!validateUsername(value)) { + setError(true) + } else { + setError(false) + } + } + + const submitInput = async () => { + setIsLoading(true) + setIsEditing(false) + setUsernameStatus("") + const submissionInput = tempUsername === null ? "" : tempUsername + try { + const response = await fetch( + `${BASE_URL}/api/account/username/${patron.id}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username: submissionInput }), + } + ) + const responseMessage = await response.json() + if (responseMessage !== "Username taken" && response.status === 200) { + await getMostUpdatedSierraAccountData() + setUsernameStatus("success") + setusernameInSierra(submissionInput) + setTempUsername(submissionInput) + } else { + setUsernameStatus("failure") + setUsernameStatusMessage( + responseMessage === "Username taken" + ? usernameStatusMessages.USERNAME_FAILURE + : usernameStatusMessages.FAILURE + ) + setTempUsername(usernameInSierra) + } + } catch (error) { + setUsernameStatus("failure") + setUsernameStatusMessage(usernameStatusMessages.FAILURE) + console.error("Error submitting username:", error) + } finally { + setIsLoading(false) + } + } + + const editUsernameField = ( + <> + + setError(true)} + /> + + + + + ) + + const notEditingView = ( + + {usernameInSierra ? ( + <> + + {usernameInSierra} + + setIsEditing(true)} + /> + + ) : ( + { + setIsEditing(true) + setTempUsername("") + setError(true) + }} + /> + )} + + ) + + const editingView = ( + + {currentUsernameNotDeleted ? ( + editUsernameField + ) : ( + { + setTempUsername("") + setError(true) + }} + /> + )} + + ) + + let content + if (isLoading) { + content = ( + + ) + } else if (isEditing) { + content = editingView + } else { + content = notEditingView + } + + return ( + + {content} + {isEditing && ( + + )} + + ) +} +export default UsernameForm diff --git a/src/config/config.ts b/src/config/config.ts index f32dbb269..fe46b743e 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -6,17 +6,9 @@ export const appConfig: AppConfig = { (process.env.NEXT_PUBLIC_APP_ENV as Environment) || "development", apiEndpoints: { platform: { - development: "https://qa-platform.nypl.org/api/v0.1", - qa: "https://qa-platform.nypl.org/api/v0.1", - production: "https://platform.nypl.org/api/v0.1", - }, - // The 'discovery' base URL should use DISCOVERY_API_BASE_URL if set, - // falling back on PLATFORM_API_BASE_URL if set, - // and finally falling back on a sensible default. - discovery: { - development: "https://qa-platform.nypl.org/api/v0.1", - qa: "https://qa-platform.nypl.org/api/v0.1", - production: "https://platform.nypl.org/api/v0.1", + development: "https://qa-platform.nypl.org/api", + qa: "https://qa-platform.nypl.org/api", + production: "https://platform.nypl.org/api", }, domain: { development: "local.nypl.org:8080", diff --git a/src/config/constants.ts b/src/config/constants.ts index 9d8c68e3e..c6f2ad2e9 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -24,7 +24,6 @@ export const PATHS = { } // API Names -export const DISCOVERY_API_NAME = "discovery" export const DRB_API_NAME = "drb" // API Routes diff --git a/src/server/api/bib.ts b/src/server/api/bib.ts index 0a0e67fa0..9092e6f80 100644 --- a/src/server/api/bib.ts +++ b/src/server/api/bib.ts @@ -7,7 +7,6 @@ import { } from "../../utils/bibUtils" import nyplApiClient from "../nyplApiClient" import { - DISCOVERY_API_NAME, DISCOVERY_API_SEARCH_ROUTE, ITEM_VIEW_ALL_BATCH_SIZE, SHEP_HTTP_TIMEOUT, @@ -29,7 +28,7 @@ export async function fetchBib( } } - const client = await nyplApiClient({ apiName: DISCOVERY_API_NAME }) + const client = await nyplApiClient() const [bibResponse, annotatedMarcResponse] = await Promise.allSettled([ await client.get( `${DISCOVERY_API_SEARCH_ROUTE}/${standardizedId}${getBibQueryString( @@ -148,7 +147,7 @@ async function fetchAllBibItemsWithQuery( batchSize: number ): Promise { const items: DiscoveryItemResult[] = [] - const client = await nyplApiClient({ apiName: DISCOVERY_API_NAME }) + const client = await nyplApiClient() const totalBatchNum = Math.ceil(numItems / batchSize) for (let batchNum = 1; batchNum <= totalBatchNum; batchNum++) { diff --git a/src/server/api/locations.ts b/src/server/api/locations.ts index 5b45164ff..842dc377c 100644 --- a/src/server/api/locations.ts +++ b/src/server/api/locations.ts @@ -1,12 +1,11 @@ import nyplApiClient from "../nyplApiClient" -import { DISCOVERY_API_NAME } from "../../config/constants" import { encode as encodeQueryString } from "querystring" /** * Fetch locations by query {object} */ export async function fetchLocations(query) { - const client = await nyplApiClient({ apiName: DISCOVERY_API_NAME }) + const client = await nyplApiClient() const path = `/locations?${encodeQueryString(query)}` return client.get(path) } diff --git a/src/server/api/search.ts b/src/server/api/search.ts index 16a3d7bca..eb03595c3 100644 --- a/src/server/api/search.ts +++ b/src/server/api/search.ts @@ -5,7 +5,6 @@ import type { import { standardizeBibId } from "../../utils/bibUtils" import { getSearchQuery } from "../../utils/searchUtils" import { - DISCOVERY_API_NAME, DISCOVERY_API_SEARCH_ROUTE, DRB_API_NAME, RESULTS_PER_PAGE, @@ -38,7 +37,6 @@ export async function fetchResults( ...journalParams, q: keywordsOrBibId, } - let queryString = getSearchQuery(modifiedSearchParams) // Fall back to a single "?" in the case of an empty query @@ -53,9 +51,8 @@ export async function fetchResults( // - search results // - aggregations // - drb results - const client = await nyplApiClient({ apiName: DISCOVERY_API_NAME }) + const client = await nyplApiClient() const drbClient = await nyplApiClient({ apiName: DRB_API_NAME }) - const [resultsResponse, aggregationsResponse, drbResultsResponse] = await Promise.allSettled([ client.get(`${DISCOVERY_API_SEARCH_ROUTE}${resultsQuery}`), diff --git a/src/server/nyplApiClient/index.ts b/src/server/nyplApiClient/index.ts index c172b371e..6ed1f5b72 100644 --- a/src/server/nyplApiClient/index.ts +++ b/src/server/nyplApiClient/index.ts @@ -22,13 +22,16 @@ export class NyplApiClientError extends Error { } } -const nyplApiClient = async (options = { apiName: "platform" }) => { - const { apiName } = options - if (CACHE.clients[apiName]) { - return CACHE.clients[apiName] +const nyplApiClient = async ({ + apiName = "platform", + version = "v0.1", +} = {}) => { + if (CACHE.clients[`${apiName}${version}`]) { + return CACHE.clients[`${apiName}${version}`] } - const baseUrl = appConfig.apiEndpoints[apiName][appEnvironment] + const baseUrl = + appConfig.apiEndpoints[apiName][appEnvironment] + "/" + version let decryptedId: string let decryptedSecret: string diff --git a/src/server/tests/helpers.test.ts b/src/server/tests/helpers.test.ts index bf0fd136d..92a63fb49 100644 --- a/src/server/tests/helpers.test.ts +++ b/src/server/tests/helpers.test.ts @@ -1,13 +1,17 @@ import sierraClient from "../../../src/server/sierraClient" +import nyplApiClient from "../nyplApiClient" import { updatePin, updatePatronSettings, updateHold, renewCheckout, cancelHold, + updateUsername, } from "../../../pages/api/account/helpers" jest.mock("../../../src/server/sierraClient") +jest.mock("../../../src/server/nyplApiClient") + const mockCheckoutResponse = { id: "1234567", callNumber: "123 TEST", @@ -272,6 +276,109 @@ describe("updatePin", () => { }) }) +describe("updateUsername", () => { + it("should return a success message if username is updated", async () => { + const newUsername = "newUsername" + const patronId = "678910" + + const platformMethodMock = jest.fn().mockResolvedValueOnce({ + status: 200, + type: "available-username", + }) + ;(nyplApiClient as jest.Mock).mockResolvedValueOnce({ + post: platformMethodMock, + }) + + const sierraMethodMock = jest.fn().mockResolvedValueOnce({ + status: 200, + message: `Username updated to ${newUsername}`, + }) + ;(sierraClient as jest.Mock).mockResolvedValueOnce({ + put: sierraMethodMock, + }) + + const response = await updateUsername(patronId, newUsername) + + expect(nyplApiClient).toHaveBeenCalled + expect(platformMethodMock).toHaveBeenNthCalledWith( + 1, + "/validations/username", + { + username: newUsername, + } + ) + + expect(sierraClient).toHaveBeenCalled + expect(sierraMethodMock).toHaveBeenNthCalledWith(1, `patrons/${patronId}`, { + varFields: [{ fieldTag: "u", content: newUsername }], + }) + + expect(response.status).toBe(200) + expect(response.message).toBe(`Username updated to ${newUsername}`) + }) + + it("should return a message if username is already taken or invalid", async () => { + const alreadyTakenUsername = "alreadyTakenUsername" + const patronId = "678910" + + const platformMethodMock = jest.fn().mockResolvedValueOnce({ + status: 400, + type: "unavailable-username", + title: "Bad Username", + detail: + "Usernames should be 5-25 characters, letters or numbers only. Please revise your username.", + }) + ;(nyplApiClient as jest.Mock).mockResolvedValueOnce({ + post: platformMethodMock, + }) + + const response = await updateUsername(patronId, alreadyTakenUsername) + + expect(nyplApiClient).toHaveBeenCalled + expect(platformMethodMock).toHaveBeenNthCalledWith( + 1, + "/validations/username", + { + username: alreadyTakenUsername, + } + ) + + expect(sierraClient).not.toHaveBeenCalled + + expect(response.status).toBe(200) + expect(response.message).toBe("Username taken") + }) + + it("should return an error if there's a server error", async () => { + const newUsername = "newUsername" + const patronId = "678910" + + const platformMethodMock = jest.fn().mockResolvedValueOnce({ + status: 502, + type: "ils-integration-error", + }) + ;(nyplApiClient as jest.Mock).mockResolvedValueOnce({ + post: platformMethodMock, + }) + + const response = await updateUsername(patronId, newUsername) + + expect(nyplApiClient).toHaveBeenCalled + expect(platformMethodMock).toHaveBeenNthCalledWith( + 1, + "/validations/username", + { + username: newUsername, + } + ) + + expect(sierraClient).not.toHaveBeenCalled + + expect(response.status).toBe(500) + expect(response.message).toBe("Username update failed") + }) +}) + describe("renewCheckout", () => { it("should return a success message, and the checkout, if renewal is successful", async () => { const checkoutId = "123" 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 diff --git a/src/types/searchTypes.ts b/src/types/searchTypes.ts index 2cf22674e..482c9d1ea 100644 --- a/src/types/searchTypes.ts +++ b/src/types/searchTypes.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import type { DiscoveryBibResult } from "./bibTypes" import type { DRBResults } from "./drbTypes" import type { Aggregation } from "./filterTypes" @@ -29,17 +30,13 @@ export interface Identifiers { lccn?: string } -export interface SearchParams { +export interface SearchParams extends AdvancedSearchQueryParams { q?: string field?: string sortBy?: SortKey order?: SortOrder filters?: SearchFilters - contributor?: string - title?: string journalTitle?: string - standardNumber?: string - subject?: string page?: number identifiers?: Identifiers } @@ -47,8 +44,6 @@ export interface SearchParams { export type SortKey = "relevance" | "title" | "date" export type SortOrder = "asc" | "desc" -type SearchFormField = { value: string } - export interface SearchResultsResponse { results?: SearchResults aggregations?: AggregationResults @@ -86,27 +81,20 @@ export interface SearchFormAction { payload: SearchParams | SearchFilters | string | string[] } -/* eslint-disable @typescript-eslint/naming-convention */ - -export interface SearchQueryParams extends Identifiers { - q?: string +export interface AdvancedSearchQueryParams { + callnumber?: string + standard_number?: string contributor?: string title?: string subject?: string +} + +export interface SearchQueryParams + extends Identifiers, + AdvancedSearchQueryParams { + q?: string sort?: SortKey sort_direction?: SortOrder search_scope?: string page?: string } - -export interface SearchFormEvent { - q?: SearchFormField - search_scope?: SearchFormField - title?: SearchFormField - contributor?: SearchFormField - subject?: SearchFormField - language?: SearchFormField - dateBefore?: SearchFormField - dateAfter?: SearchFormField - materialType?: SearchFormField -} diff --git a/src/utils/advancedSearchUtils.ts b/src/utils/advancedSearchUtils.ts index 0ab01474e..43eb57ff4 100644 --- a/src/utils/advancedSearchUtils.ts +++ b/src/utils/advancedSearchUtils.ts @@ -7,6 +7,8 @@ export const textInputFields: SearchFormInputField[] = [ { name: "title", label: "Title" }, { name: "contributor", label: "Author" }, { name: "subject", label: "Subject" }, + { name: "callnumber", label: "Call number" }, + { name: "standard_number", label: "Unique identifier" }, ] export const initialSearchFormState: SearchParams = { @@ -14,6 +16,8 @@ export const initialSearchFormState: SearchParams = { title: "", contributor: "", subject: "", + callnumber: "", + standard_number: "", filters: { language: "", dateBefore: "", diff --git a/src/utils/searchUtils.ts b/src/utils/searchUtils.ts index 1f2605b98..841484e6e 100644 --- a/src/utils/searchUtils.ts +++ b/src/utils/searchUtils.ts @@ -1,6 +1,9 @@ import { isArray, isEmpty, mapObject, forEach } from "underscore" -import { textInputFields as advSearchFields } from "./advancedSearchUtils" +import { + textInputFields as advSearchFields, + textInputFields, +} from "./advancedSearchUtils" import type { SearchParams, SearchQueryParams, @@ -190,18 +193,16 @@ function getFilterQuery(filters: SearchFilters) { * getSearchQuery * Builds a query string from a SearchParams object */ -export function getSearchQuery({ - sortBy = "relevance", - field = "all", - order, - filters = {}, - identifiers = {}, - q, - contributor, - title, - subject, - page = 1, -}: SearchParams): string { +export function getSearchQuery(params: SearchParams): string { + const { + sortBy = "relevance", + field = "all", + order, + filters = {}, + identifiers = {}, + q, + page = 1, + } = params const searchKeywordsQuery = encodeURIComponent(q) const sortQuery = getSortQuery(sortBy, order) @@ -210,21 +211,24 @@ export function getSearchQuery({ const identifierQuery = getIdentifierQuery(identifiers) const pageQuery = page !== 1 ? `&page=${page}` : "" - // advanced search query - const contributorQuery = contributor ? `&contributor=${contributor}` : "" - const titleQuery = title ? `&title=${title}` : "" - const subjectQuery = subject ? `&subject=${subject}` : "" + const advancedSearchQueryParams = textInputFields + .map(({ name: advancedSearchParam }) => { + if (advancedSearchParam === "q") return + return params[advancedSearchParam] + ? `&${advancedSearchParam}=${params[advancedSearchParam]}` + : "" + }) + .join("") // if a search_scope is "all", we want to clear the advanced search query params // and default to the q param const isAdvancedSearchOrAllFields = field.length && field === "all" const advancedQuery = isAdvancedSearchOrAllFields - ? `${contributorQuery}${titleQuery}${subjectQuery}` + ? advancedSearchQueryParams : "" const completeQuery = `${searchKeywordsQuery}${advancedQuery}${filterQuery}${sortQuery}${fieldQuery}${pageQuery}${identifierQuery}` - return completeQuery?.length ? `?q=${completeQuery}` : "" } @@ -303,6 +307,8 @@ export function mapQueryToSearchParams({ search_scope, sort_direction, page, + callnumber, + standard_number, contributor, title, subject, @@ -315,11 +321,13 @@ export function mapQueryToSearchParams({ }: SearchQueryParams): SearchParams { const hasIdentifiers = issn || isbn || oclc || lccn const filters = collapseMultiValueQueryParams(queryFilters) - + //TODO: can we merge the SearchQueryParams and SearchParams types by renaming some params? e.g. search_scope, sort, sort_direction. Also maybe passing in identifiers so it maches this pattern. return { q, field: search_scope, page: page ? parseInt(page) : 1, + callnumber, + standard_number, contributor, title, subject,