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 (
-
+ <>
+ {usernameStatus !== "" && (
+
+
+
+ )}
+
+ >
)
}
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 = (
- toggleCurrentlyEditing(true)}
- >
-
- Edit account settings
-
- )
-
- 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 = (
-
- {sortedPickupLocations.map((loc, i) => (
-
- {loc.name}
-
- ))}
-
- )
- }
- }
- break
- case "Notification preference":
- {
- inputField = (
-
- {notificationPreferenceTuples.map((pref) => (
-
- {pref[1]}
-
- ))}
-
- )
- }
- 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)
- },
- }}
- />
- )}
-
- >
- )
-}
-
-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 (
+
+ {label}
+
+ )
+}
+
+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 (
+
+
+ Edit
+
+ )
+}
+
+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 (
- <>
-
-
- Change pin/password
-
-
- >
- )
-}
-
-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 (
+
+
+ Cancel
+
+
+ Save changes
+
+
+ )
+}
+
+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) => (
+
+ ))}
+
+
+ ) : 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 ? (
+
+
+ {formUtils.options.map((option, index) => (
+
+ {option.name}
+
+ ))}
+
+
+ ) : (
+
+
+ {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)}
+ />
+ {
+ setTempUsername(null)
+ setError(false)
+ }}
+ >
+ {" "}
+
+
+
+
+ >
+ )
+
+ 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,