Skip to content

Commit b4203e1

Browse files
authored
Merge pull request #730 from NYPL/SCC-5307/fe-nyql-mvp
SCC-5307: NYQL dropdown and banner
2 parents 48d92d0 + 4b542bc commit b4203e1

15 files changed

Lines changed: 391 additions & 3288 deletions

File tree

CHANGELOG

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Prerelease
99

10+
### Added
11+
12+
- Added CQL search scope, error page, and query highlight banner [SCC-5307](https://newyorkpubliclibrary.atlassian.net/browse/SCC-5307) \*\*EMMA: DO NOT RELEASE WITHOUT QUERY GUIDE LINK
13+
1014
## [1.9.2] 2026-05-27
1115

1216
### Updated

package-lock.json

Lines changed: 35 additions & 3235 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pages/search/index.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import type {
1313
import { SITE_NAME } from "../../src/config/constants"
1414
import initializePatronTokenAuth from "../../src/server/auth"
1515
import { useFocusContext, idConstants } from "../../src/context/FocusContext"
16-
import type { HTTPStatusCode } from "../../src/types/appTypes"
16+
import type {
17+
APIError,
18+
APIErrorName,
19+
HTTPStatusCode,
20+
} from "../../src/types/appTypes"
1721
import Search from "../../src/components/Search/Search"
1822
import { appConfig } from "../../src/config/appConfig"
1923

@@ -22,6 +26,7 @@ interface SearchPageProps {
2226
results: SearchResultsResponse
2327
isAuthenticated: boolean
2428
errorStatus?: HTTPStatusCode | null
29+
errorName?: APIErrorName | null
2530
}
2631

2732
/**
@@ -32,6 +37,7 @@ export default function SearchPage({
3237
results,
3338
isAuthenticated,
3439
errorStatus = null,
40+
errorName = null,
3541
}: SearchPageProps) {
3642
const { push, query } = useRouter()
3743
// TODO: Move this to global context
@@ -66,6 +72,7 @@ export default function SearchPage({
6672
return (
6773
<Search
6874
errorStatus={errorStatus}
75+
errorName={errorName}
6976
results={results}
7077
metadataTitle={`Search | ${SITE_NAME}`}
7178
activePage="search"
@@ -85,7 +92,12 @@ export async function getServerSideProps({ req, query }) {
8592

8693
// Direct to error display according to status
8794
if (results.status !== 200) {
88-
return { props: { errorStatus: results.status } }
95+
return {
96+
props: {
97+
errorStatus: (results as APIError).status,
98+
errorName: (results as APIError).name,
99+
},
100+
}
89101
}
90102

91103
// Check for `redirectOnMatch` trigger:
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from "react"
2+
import { render, screen, fireEvent } from "../../utils/testUtils"
3+
import { QueryBanner } from "./QueryBanner"
4+
5+
describe("QueryBanner", () => {
6+
beforeEach(() => {
7+
// clear the cookie before each test
8+
document.cookie = "seenQueryBanner=; expires=Thu, 01 Jan 1970 00:00:00 UTC;"
9+
})
10+
11+
it("renders the banner when the cookie is absent", () => {
12+
render(<QueryBanner />)
13+
14+
expect(screen.getByText("Got it")).toBeInTheDocument()
15+
expect(screen.getByText(/Search using queries/i)).toBeInTheDocument()
16+
})
17+
18+
it("does not render the banner when the cookie is present", () => {
19+
document.cookie = "seenQueryBanner=true"
20+
render(<QueryBanner />)
21+
22+
expect(screen.queryByText("Got it")).not.toBeInTheDocument()
23+
})
24+
25+
it("dismisses the banner and sets the cookie when 'Got it' is clicked", () => {
26+
render(<QueryBanner />)
27+
28+
const dismissLink = screen.getByText("Got it")
29+
fireEvent.click(dismissLink)
30+
31+
expect(screen.queryByText("Got it")).not.toBeInTheDocument()
32+
33+
// cookie should now be set in the mock document
34+
expect(document.cookie).toContain("seenQueryBanner=true")
35+
})
36+
})
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Text, Box, Link, Flex } from "@nypl/design-system-react-components"
2+
import { useState, useEffect } from "react"
3+
4+
export const QueryBanner = () => {
5+
const [isVisible, setIsVisible] = useState(false)
6+
7+
useEffect(() => {
8+
if (!document.cookie.includes("seenQueryBanner=true")) {
9+
setIsVisible(true)
10+
}
11+
}, [])
12+
13+
if (!isVisible) return null
14+
15+
return (
16+
<Box
17+
sx={{
18+
background: "ui.gray.xx-dark",
19+
borderRadius: "md",
20+
zIndex: "100",
21+
padding: "s",
22+
marginTop: "xs",
23+
marginBottom: "xs",
24+
width: "300px",
25+
boxShadow: "2px 4px 4px 0px rgba(0, 0, 0, 0.2)",
26+
position: "relative",
27+
_before: {
28+
content: '""',
29+
position: "absolute",
30+
top: "-8px",
31+
left: "140px",
32+
marginTop: "2px",
33+
borderLeft: "8px solid transparent",
34+
borderRight: "8px solid transparent",
35+
borderBottom: "8px solid",
36+
borderBottomColor: "ui.gray.xx-dark",
37+
},
38+
}}
39+
>
40+
<Text
41+
marginBottom="xxs"
42+
size="body2"
43+
fontWeight="bold"
44+
color="ui.typography.inverse.heading"
45+
>
46+
<Text
47+
as="span"
48+
marginBottom="xxs"
49+
size="body2"
50+
fontWeight="bold"
51+
color="dark.ui.success.secondary"
52+
>
53+
New!
54+
</Text>{" "}
55+
Search using queries
56+
</Text>
57+
<Text
58+
marginBottom="0"
59+
size="body2"
60+
fontWeight="medium"
61+
color="ui.typography.inverse.heading"
62+
>
63+
You can now search with more precision and control using boolean
64+
operators against a wide set of fields. Check it out in this menu.
65+
</Text>
66+
<Flex flexDir="row" justifyContent="flex-end">
67+
<Link
68+
href="#"
69+
color="ui.typography.inverse.heading"
70+
fontSize="14px"
71+
fontWeight="bold"
72+
onClick={(e) => {
73+
e.preventDefault()
74+
setIsVisible(false)
75+
const expirationDate = new Date(
76+
new Date().setFullYear(new Date().getFullYear() + 1)
77+
).toUTCString()
78+
document.cookie = `seenQueryBanner=true; expires=${expirationDate}; `
79+
}}
80+
sx={{
81+
textDecoration: "underline solid 1px",
82+
textUnderlineOffset: "2px",
83+
}}
84+
>
85+
Got it
86+
</Link>
87+
</Flex>
88+
</Box>
89+
)
90+
}

src/components/Error/ResultsError.tsx

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Heading, Flex, Text } from "@nypl/design-system-react-components"
2-
import type { HTTPStatusCode } from "../../types/appTypes"
2+
import type { APIErrorName, HTTPStatusCode } from "../../types/appTypes"
33
import { appConfig } from "../../config/appConfig"
44
import { SITE_NAME } from "../../config/constants"
55
import RCHead from "../Head/RCHead"
@@ -14,10 +14,15 @@ import Link from "../Link/Link"
1414
type ResultsErrorProps = {
1515
page: RCPage
1616
errorStatus: HTTPStatusCode
17+
errorName?: APIErrorName
1718
}
1819

1920
/* Display error state that replaces browse/search results. */
20-
export default function ResultsError({ errorStatus, page }: ResultsErrorProps) {
21+
export default function ResultsError({
22+
errorStatus,
23+
errorName,
24+
page,
25+
}: ResultsErrorProps) {
2126
const { openFeedbackFormWithError } = useContext(FeedbackContext)
2227
let metadataTitle = "Error"
2328
let errorContent
@@ -28,6 +33,13 @@ export default function ResultsError({ errorStatus, page }: ResultsErrorProps) {
2833
metadataTitle = "Results not found"
2934
errorContent = (
3035
<>
36+
<Image
37+
src={errorImage}
38+
alt="Error image"
39+
width={96}
40+
height={64}
41+
style={{ marginBottom: "48px" }}
42+
/>
3143
<Heading level="h3" tabIndex={-1} id={headingID} mb="s">
3244
No results found
3345
</Heading>
@@ -56,6 +68,13 @@ export default function ResultsError({ errorStatus, page }: ResultsErrorProps) {
5668
case 500:
5769
errorContent = (
5870
<>
71+
<Image
72+
src={errorImage}
73+
alt="Error image"
74+
width={96}
75+
height={64}
76+
style={{ marginBottom: "48px" }}
77+
/>
5978
<Heading level="h3" tabIndex={-1} id={headingID} mb="s">
6079
Something went wrong on our end
6180
</Heading>
@@ -75,11 +94,58 @@ export default function ResultsError({ errorStatus, page }: ResultsErrorProps) {
7594
</>
7695
)
7796
break
78-
97+
case 422:
98+
if (errorName === "InvalidQuerySyntaxError") {
99+
errorContent = (
100+
<>
101+
<Image
102+
src={errorImage}
103+
alt="Error image"
104+
width={96}
105+
height={64}
106+
style={{ marginBottom: "48px" }}
107+
/>
108+
<Heading level="h3" tabIndex={-1} id={headingID} mb="s">
109+
Invalid query
110+
</Heading>
111+
<Text marginBottom="0">
112+
Your query contained an invalid search scope or syntax error.
113+
Change your query and try again.
114+
</Text>
115+
<Text>
116+
{" "}
117+
Read our{" "}
118+
<Link
119+
isExternal
120+
href="https://libguides.nypl.org/researchcatalog/query"
121+
>
122+
Query Guide
123+
</Link>{" "}
124+
to learn how to construct queries or{" "}
125+
<Link
126+
onClick={() => openFeedbackFormWithError(errorStatus)}
127+
id="feedback-link"
128+
>
129+
contact us
130+
</Link>{" "}
131+
for assistance.
132+
</Text>
133+
</>
134+
)
135+
break
136+
}
79137
// 4xx
138+
// fallthrough
80139
default:
81140
errorContent = (
82141
<>
142+
<Image
143+
src={errorImage}
144+
alt="Error image"
145+
width={96}
146+
height={64}
147+
style={{ marginBottom: "48px" }}
148+
/>
83149
<Heading level="h3" tabIndex={-1} id={headingID} mb="s">
84150
There was an unexpected error
85151
</Heading>
@@ -115,13 +181,6 @@ export default function ResultsError({ errorStatus, page }: ResultsErrorProps) {
115181
justifyContent="center"
116182
textAlign="center"
117183
>
118-
<Image
119-
src={errorImage}
120-
alt="Error image"
121-
width={96}
122-
height={64}
123-
style={{ marginBottom: "48px" }}
124-
/>
125184
{errorContent}
126185
</Flex>
127186
</Layout>

src/components/Search/Search.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { useRef, useEffect } from "react"
2626
import type { Aggregation } from "../../types/filterTypes"
2727
import ResultsError from "../Error/ResultsError"
2828
import useLoading from "../../hooks/useLoading"
29-
import type { HTTPStatusCode } from "../../types/appTypes"
29+
import type { APIErrorName, HTTPStatusCode } from "../../types/appTypes"
3030
import type {
3131
SearchParams,
3232
SearchResultsResponse,
@@ -37,6 +37,7 @@ import { useRouter } from "next/router"
3737

3838
interface SearchProps {
3939
errorStatus?: HTTPStatusCode | null
40+
errorName?: APIErrorName | null
4041
results: SearchResultsResponse
4142
metadataTitle: string
4243
activePage: RCPage
@@ -51,6 +52,7 @@ interface SearchProps {
5152

5253
const Search = ({
5354
errorStatus,
55+
errorName,
5456
results,
5557
metadataTitle,
5658
activePage,
@@ -79,11 +81,18 @@ const Search = ({
7981
}, [isLoading])
8082

8183
if (errorStatus) {
82-
return <ResultsError errorStatus={errorStatus} page={activePage} />
84+
return (
85+
<ResultsError
86+
errorStatus={errorStatus}
87+
errorName={errorName}
88+
page="search"
89+
/>
90+
)
8391
}
8492

8593
const { itemListElement: searchResultsElements, totalResults } =
8694
results.results
95+
const parsedQuery = results?.results?.debug?.parsed
8796

8897
const aggs = results?.aggregations?.itemListElement
8998
// if there are no results, then applied filters correspond to aggregations
@@ -159,13 +168,15 @@ const Search = ({
159168
{getSearchResultsHeading(
160169
searchParams,
161170
totalResults,
171+
162172
slug
163173
? {
164174
slug,
165175
browseType: resultsType,
166176
role,
167177
}
168-
: undefined
178+
: undefined,
179+
parsedQuery
169180
)}
170181
</Heading>
171182
<ResultsSort

0 commit comments

Comments
 (0)