Skip to content

Commit

Permalink
Merge pull request #392 from nulib/deploy/staging
Browse files Browse the repository at this point in the history
Add IIIF search and collection sharing, address AI local storage bug.
mathewjordan authored Dec 6, 2024
2 parents 60439e3 + fe253fd commit 8bc8a87
Showing 47 changed files with 802 additions and 151 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
name: Playwright Tests
on:
# push:
# branches: []
# pull_request:
# branches: [main, deploy/staging]
push:
branches: []
pull_request:
branches: [main, deploy/staging]
workflow_dispatch:
jobs:
test:
10 changes: 10 additions & 0 deletions components/Clover/ViewerWrapper.styled.ts
Original file line number Diff line number Diff line change
@@ -10,6 +10,16 @@ const ViewerWrapperStyled = styled("section", {
background: "#f0f0f0",
},

".clover-viewer-header": {
display: "none",
},

".clover-viewer-media-wrapper": {
"div[role='radiogroup']": {
paddingBottom: "0",
},
},

"& label[for='information-toggle']": {
boxShadow: "none",
},
39 changes: 21 additions & 18 deletions components/Clover/ViewerWrapper.tsx
Original file line number Diff line number Diff line change
@@ -8,6 +8,7 @@ import type {
} from "@samvera/clover-iiif";

import Announcement from "@/components/Shared/Announcement";
import Container from "../Shared/Container";
import { IconInfo } from "@/components/Shared/SVG/Icons";
import React from "react";
import { UserContext } from "@/context/user-context";
@@ -76,24 +77,26 @@ const WorkViewerWrapper: React.FC<WrapperProps> = ({
};

return (
<ViewerWrapperStyled data-testid="work-viewer-wrapper">
{manifestId && (
<CloverViewer
// @ts-ignore
customTheme={customTheme}
iiifContent={manifestId}
options={options}
/>
)}
{isWorkRestricted && userAuth?.user?.isReadingRoom && (
<Announcement>
<AnnouncementContent>
<IconInfo />
<p>You have access to Work because you are in the reading room</p>
</AnnouncementContent>
</Announcement>
)}
</ViewerWrapperStyled>
<Container containerType="wide">
<ViewerWrapperStyled data-testid="work-viewer-wrapper">
{manifestId && (
<CloverViewer
// @ts-ignore
customTheme={customTheme}
iiifContent={manifestId}
options={options}
/>
)}
{isWorkRestricted && userAuth?.user?.isReadingRoom && (
<Announcement>
<AnnouncementContent>
<IconInfo />
<p>You have access to Work because you are in the reading room</p>
</AnnouncementContent>
</Announcement>
)}
</ViewerWrapperStyled>
</Container>
);
};

28 changes: 24 additions & 4 deletions components/Collection/Collection.styled.ts
Original file line number Diff line number Diff line change
@@ -2,12 +2,32 @@ import { styled } from "@/stitches.config";

/* eslint sort-keys: 0 */

const CollectionHeader = styled("div", {
display: "flex",
justifyContent: "space-between",
marginTop: "1.5em",
gap: "$gr4",

"div:last-child": {
flexShrink: 0,
flexGrow: 0,
marginBottom: "$gr2",
},

"@sm": {
flexDirection: "column",
gap: "0",
},
});

const Description = styled("p", {
fontSize: "$gr5",
fontFamily: "$northwesternSansLight",
lineHeight: "1.55em",
margin: "$gr2 0 $gr5",
margin: "$gr2 0 $gr6",
color: "$black50",

"@sm": {
fontSize: "$gr3",
},
});

const HeroWrapper = styled("div", {
@@ -22,4 +42,4 @@ const Interstitial = styled("div", {
padding: "$gr3 0",
});

export { Description, Interstitial, HeroWrapper };
export { CollectionHeader, Description, Interstitial, HeroWrapper };
2 changes: 1 addition & 1 deletion components/Collection/NavTabs.styled.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as TabsPrimitive from "@radix-ui/react-tabs";

import { styled } from "@/stitches.config";

/* eslint sort-keys: 0 */
@@ -63,7 +64,6 @@ const StyledContent = styled(TabsPrimitive.Content, {
borderBottomLeftRadius: 6,
borderBottomRightRadius: 6,
outline: "none",
"&:focus": { boxShadow: `0 0 0 2px #f0f0f0` },

"& a": {
color: "$purple",
1 change: 1 addition & 0 deletions components/Figure/Figure.styled.ts
Original file line number Diff line number Diff line change
@@ -93,6 +93,7 @@ const FigureTitle = styled("span", {
color: "$purple",
display: "flex",
alignItems: "flex-start",
lineHeight: "1.25em",
});

const FigureText = styled("div", {
14 changes: 12 additions & 2 deletions components/Header/Super.tsx
Original file line number Diff line number Diff line change
@@ -15,7 +15,9 @@ import { NavResponsiveOnly } from "@/components/Nav/Nav.styled";
import { NorthwesternWordmark } from "@/components/Shared/SVG/Northwestern";
import React from "react";
import { UserContext } from "@/context/user-context";
import { defaultAIState } from "@/hooks/useGenerativeAISearchToggle";
import useLocalStorage from "@/hooks/useLocalStorage";
import { useRouter } from "next/router";

const nav = [
{
@@ -33,9 +35,12 @@ const nav = [
];

export default function HeaderSuper() {
const router = useRouter();
const { query } = router;

const [isLoaded, setIsLoaded] = React.useState(false);
const [isExpanded, setIsExpanded] = React.useState(false);
const [ai, setAI] = useLocalStorage("ai", "false");
const [ai, setAI] = useLocalStorage("ai", defaultAIState);

React.useEffect(() => {
setIsLoaded(true);
@@ -45,7 +50,12 @@ export default function HeaderSuper() {
const handleMenu = () => setIsExpanded(!isExpanded);

const handleLogout = () => {
if (ai === "true") setAI("false");
// reset AI state and remove query param
setAI(defaultAIState);
delete query?.ai;
router.push(router.pathname, { query });

// logout
window.location.href = `${DCAPI_ENDPOINT}/auth/logout`;
};

3 changes: 2 additions & 1 deletion components/Heading/Heading.styled.ts
Original file line number Diff line number Diff line change
@@ -35,9 +35,10 @@ const StyledHeading = styled("h2", {
},

"&[data-level=h2]": {
color: "$purple",
fontFamily: "$northwesternDisplayBold",
color: "$black80",
fontSize: "$gr7",
letterSpacing: "-0.015em",
fontWeight: "400",
marginBottom: "$gr5",
},
9 changes: 6 additions & 3 deletions components/Hero/Hero.styled.ts
Original file line number Diff line number Diff line change
@@ -37,6 +37,7 @@ const HeroStyled = styled("div", {
width: "100%",
height: "100%",
position: "relative",
zIndex: "1",

".swiper-wrapper": {
"&::before": {
@@ -115,7 +116,7 @@ const HeroStyled = styled("div", {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
textShadow: "2px 2px 2px #000",
textShadow: "3px 3px 8px #0006",
maxWidth: "$gr11",
textAlign: "left",

@@ -135,18 +136,20 @@ const HeroStyled = styled("div", {

".slide-label": {
fontFamily: "$northwesternDisplayBold",
fontSize: "$gr8",
fontWeight: "400",
fontSize: "$gr7",
display: "block",
margin: "0 0 $gr2",
lineHeight: "1em",
letterSpacing: "-0.015em",

"@sm": {
fontSize: "$gr7",
},
},

".slide-summary": {
fontFamily: "$northwesternSansLightItalic",
fontFamily: "$northwesternSansRegular",
fontSize: "$gr4",
display: "block",
color: "$black20",
2 changes: 1 addition & 1 deletion components/Hero/Hero.tsx
Original file line number Diff line number Diff line change
@@ -30,7 +30,7 @@ const Hero: React.FC<HeroProps> = ({ collection }) => {
clickable: true,
}}
slidesPerView={1}
speed={1000}
speed={300}
>
{collection.items.map((item) => (
<SwiperSlide key={item.id}>
3 changes: 0 additions & 3 deletions components/Homepage/Collections.styled.ts
Original file line number Diff line number Diff line change
@@ -4,9 +4,6 @@ import { FigureTitle } from "@/components/Figure/Figure.styled";
import { styled } from "@/stitches.config";

const HomepageCollectionsStyled = styled("section", {
backgroundColor: "$purple10",
padding: "$gr5 0",

[`${FigureTitle}`]: {
lineHeight: "1.25em",
fontSize: "$gr4",
17 changes: 9 additions & 8 deletions components/Homepage/Overview.styled.ts
Original file line number Diff line number Diff line change
@@ -13,18 +13,20 @@ const Content = styled("div", {

h2: {
fontFamily: "$northwesternDisplayBold",
margin: "0 0 $gr2",
margin: "0 0 $gr3",
fontSize: "$gr6",
fontWeight: "400",
lineHeight: "1.15",
letterSpacing: "-0.015em",
},

p: {
fontFamily: "$northwesternSansLight",
fontSize: "$4",
fontFamily: "$northwesternSansRegular",
fontSize: "$gr4",
lineHeight: "1.55em",
margin: "0 0 $gr4",
padding: "0",
color: "$black50",
},
});

@@ -34,11 +36,9 @@ const Images = styled("div", {
gridTemplateColumns: "1fr 2fr 3fr 3fr 3fr 1fr",
gridTemplateRows: "repeat(6,auto)",
gridGap: "$gr2",
marginLeft: "$gr5",

"@sm": {
width: "100%",
marginLeft: "0",
width: "300px",
},

"img, video": {
@@ -82,13 +82,14 @@ const Images = styled("div", {

const Inner = styled("div", {
display: "flex",
padding: "$gr6",
padding: "$gr5",
width: "auto",
alignItems: "center",
gap: "$gr5",

"@sm": {
flexDirection: "column-reverse",
padding: "$gr4 $gr3",
gap: "$gr3",
},
});

18 changes: 14 additions & 4 deletions components/Search/GenerativeAIToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -57,7 +57,11 @@ describe("GenerativeAIToggle", () => {

await user.click(checkbox);
expect(checkbox).toHaveAttribute("data-state", "checked");
expect(localStorage.getItem("ai")).toEqual(JSON.stringify("true"));

const ai = JSON.parse(String(localStorage.getItem("ai")));
expect(ai?.enabled).toEqual("true");
expect(typeof ai?.expires).toEqual("number");
expect(ai?.expires).toBeGreaterThan(Date.now());
});

it("renders the generative AI tooltip", () => {
@@ -99,7 +103,10 @@ describe("GenerativeAIToggle", () => {
...defaultSearchState,
};

localStorage.setItem("ai", JSON.stringify("true"));
localStorage.setItem(
"ai",
JSON.stringify({ enabled: "true", expires: 9733324925021 }),
);

mockRouter.setCurrentUrl("/search");
render(
@@ -117,7 +124,7 @@ describe("GenerativeAIToggle", () => {

mockRouter.setCurrentUrl("/");

localStorage.setItem("ai", JSON.stringify("false"));
localStorage.setItem("ai", JSON.stringify({ enabled: "false" }));

render(
withUserProvider(
@@ -127,6 +134,9 @@ describe("GenerativeAIToggle", () => {

await user.click(screen.getByRole("checkbox"));

expect(localStorage.getItem("ai")).toEqual(JSON.stringify("true"));
const ai = JSON.parse(String(localStorage.getItem("ai")));
expect(ai?.enabled).toEqual("true");
expect(typeof ai?.expires).toEqual("number");
expect(ai?.expires).toBeGreaterThan(Date.now());
});
});
3 changes: 2 additions & 1 deletion components/Search/GenerativeAIToggle.tsx
Original file line number Diff line number Diff line change
@@ -63,7 +63,8 @@ export default function GenerativeAIToggle() {
<SharedAlertDialog
isOpen={dialog.isOpen}
cancel={{ label: "Cancel", onClick: closeDialog }}
action={{ label: "Login", onClick: handleLogin }}
action={{ label: "Sign in", onClick: handleLogin }}
title="Sign in to Digital Collections"
>
{AI_LOGIN_ALERT}
</SharedAlertDialog>
16 changes: 13 additions & 3 deletions components/Search/Results.tsx
Original file line number Diff line number Diff line change
@@ -2,21 +2,28 @@ import {
NoResultsMessage,
ResultsMessage,
ResultsWrapper,
ResultsWrapperHeader,
} from "@/components/Search/Search.styled";

import Grid from "@/components/Grid/Grid";
import IIIFShare from "../Shared/IIIF/Share";
import PaginationAltCounts from "@/components/Search/PaginationAltCounts";
import { SEARCH_RESULTS_PER_PAGE } from "@/lib/constants/common";
import { SearchResultsState } from "@/types/components/search";
import { iiifSearchUri } from "@/lib/dc-api";
import { pluralize } from "@/lib/utils/count-helpers";
import useGenerativeAISearchToggle from "@/hooks/useGenerativeAISearchToggle";
import { useRouter } from "next/router";

const SearchResults: React.FC<SearchResultsState> = ({
data,
error,
loading,
}) => {
const { isChecked: isAI } = useGenerativeAISearchToggle();
const router = useRouter();

const iiifCollection = iiifSearchUri(router.query, SEARCH_RESULTS_PER_PAGE);
const totalResults = data?.pagination?.total_hits;

return (
@@ -27,9 +34,12 @@ const SearchResults: React.FC<SearchResultsState> = ({
<>
{!isAI &&
(totalResults ? (
<ResultsMessage data-testid="results-count">
{pluralize("result", totalResults)}
</ResultsMessage>
<ResultsWrapperHeader>
<ResultsMessage data-testid="results-count">
{pluralize("result", totalResults)}
</ResultsMessage>
<IIIFShare uri={iiifCollection} />
</ResultsWrapperHeader>
) : (
<NoResultsMessage>
<strong>Your search did not match any results.</strong> Please
10 changes: 9 additions & 1 deletion components/Search/Search.styled.ts
Original file line number Diff line number Diff line change
@@ -87,7 +87,6 @@ const Button = styled("button", {

const ResultsMessage = styled("span", {
color: "$black50",
padding: "0 $gr4 $gr4",
fontSize: "$gr3",

"@lg": {
@@ -126,6 +125,14 @@ const ResultsWrapper = styled("div", {
minHeight: "80vh",
});

const ResultsWrapperHeader = styled("header", {
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: "0 $gr4 $gr4",
});

const StyledResponseWrapper = styled("div", {
padding: "0 0 $gr6",
});
@@ -135,6 +142,7 @@ export {
NoResultsMessage,
ResultsMessage,
ResultsWrapper,
ResultsWrapperHeader,
SearchStyled,
StyledResponseWrapper,
};
5 changes: 4 additions & 1 deletion components/Search/Search.test.tsx
Original file line number Diff line number Diff line change
@@ -106,7 +106,10 @@ describe("Search component", () => {
});

it("renders generative AI placeholder text when AI search is active", () => {
localStorage.setItem("ai", JSON.stringify("true"));
localStorage.setItem(
"ai",
JSON.stringify({ enabled: "true", expires: 9733324925021 }),
);

render(withUserProvider(<Search isSearchActive={mockIsSearchActive} />));

11 changes: 8 additions & 3 deletions components/Shared/AlertDialog.styled.ts
Original file line number Diff line number Diff line change
@@ -19,7 +19,7 @@ const AlertDialogOverlay = styled(AlertDialog.Overlay, {

const AlertDialogContent = styled(AlertDialog.Content, {
backgroundColor: "white",
borderRadius: 6,
borderRadius: "6px",
boxShadow:
"hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px",
position: "fixed",
@@ -29,8 +29,9 @@ const AlertDialogContent = styled(AlertDialog.Content, {
width: "90vw",
maxWidth: "500px",
maxHeight: "85vh",
padding: 25,
padding: "$gr4",
zIndex: "2",
fontSize: "$gr3",

"&:focus": { outline: "none" },
});
@@ -46,7 +47,11 @@ const AlertDialogTitle = styled(AlertDialog.Title, {

const AlertDialogButtonRow = styled("div", {
display: "flex",
justifyContent: "flex-end",
justifyContent: "space-between",

"> button": {
margin: 0,
},

"& > *:not(:last-child)": {
marginRight: "$gr3",
4 changes: 2 additions & 2 deletions components/Shared/AlertDialog.tsx
Original file line number Diff line number Diff line change
@@ -43,13 +43,13 @@ export default function SharedAlertDialog({
<AlertDialog.Description>{children}</AlertDialog.Description>
<AlertDialogButtonRow>
{cancel && (
<Button isText onClick={cancel?.onClick}>
<Button onClick={cancel?.onClick} isLowercase>
{cancelLabel}
</Button>
)}

<AlertDialog.Action asChild>
<Button isPrimary onClick={action.onClick}>
<Button isPrimary onClick={action.onClick} isLowercase>
{action.label}
</Button>
</AlertDialog.Action>
17 changes: 9 additions & 8 deletions components/Shared/CopyText.styled.ts
Original file line number Diff line number Diff line change
@@ -6,13 +6,12 @@ const StyledStatus = styled("span", {
display: "flex",
alignContent: "center",
alignItems: "center",
padding: "0 $gr1",
marginLeft: "$gr1",
backgroundColor: "$darkBlueA",
color: "$white",
color: "$darkBlueA",
borderRadius: "3px",
fontSize: "$gr1",
textTransform: "uppercase",
position: "absolute",
right: "-1.25em",
});

const StyledCopyText = styled("button", {
@@ -26,15 +25,17 @@ const StyledCopyText = styled("button", {
fontFamily: "$northwesternSans",
fontSize: "$gr3",
whiteSpace: "nowrap",
textDecoration: "underline",
textDecorationThickness: "min(2px,max(1px,.05em))",
textUnderlineOffset: "calc(.05em + 2px)",
textDecorationColor: "$purple10",
position: "relative",
zIndex: "0",

svg: {
height: "calc($gr3 - 3px)",
marginRight: "$gr1",
},

"&:hover": {
textDecoration: "underline",
},
});

export { StyledCopyText, StyledStatus };
6 changes: 3 additions & 3 deletions components/Shared/DefinitionList.styled.ts
Original file line number Diff line number Diff line change
@@ -4,15 +4,15 @@ import { styled } from "@/stitches.config";

const DefinitionListWrapper = styled("div", {
lineHeight: "1.47em",
fontSize: "$gr3",

"& dt": {
fontSize: "$gr3",
color: "$black",
fontFamily: "$northwesternDisplayBold",
fontFamily: "$northwesternSansBold",
},
"& dd": {
marginInlineStart: "0",
paddingBottom: "$gr2",
paddingBottom: "$gr3",
},
});

6 changes: 3 additions & 3 deletions components/Shared/Expand/Expand.styled.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { VariantProps, styled } from "@/stitches.config";

import { Button } from "@nulib/design-system";

/* eslint sort-keys: 0 */

const ExpandButton = styled(Button, {
margin: "0 auto",
opacity: "1",
transition: "$dcAll",
});
@@ -14,9 +14,9 @@ const ExpandEdge = styled("div", {
width: "100%",
bottom: "0",
display: "flex",
justifyContent: "center",
padding: "$4 0 0",
backgroundColor: "$white",
justifyContent: "flex-start",
paddingTop: "$gr7",
background: "linear-gradient(to bottom, #fff0 0%, #fff 61.8%)",
transition: "$dcAll",
overflow: "hidden",
7 changes: 6 additions & 1 deletion components/Shared/Expand/Expand.tsx
Original file line number Diff line number Diff line change
@@ -38,7 +38,12 @@ const Expand: React.FC<ExpandProps & ExpandVariants> = ({
>
<ExpandContent ref={contentRef}>{children}</ExpandContent>
<ExpandEdge>
<ExpandButton onClick={handleExpand} isLowercase disabled={isExpanded}>
<ExpandButton
onClick={handleExpand}
isLowercase
isPrimary
disabled={isExpanded}
>
{buttonText}
</ExpandButton>
</ExpandEdge>
56 changes: 56 additions & 0 deletions components/Shared/IIIF/Share.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { render, screen } from "@/test-utils";

import IIIFShare from "@/components/Shared/IIIF/Share";
import React from "react";

describe("IIIFShare", () => {
const uri =
"https://iiif.io/api/cookbook/recipe/0001-mvm-image/manifest.json";

it("renders a dropdown with IIIF viewers", async () => {
render(<IIIFShare uri={uri} />);

// Verify that initial elements are present
expect(screen.getByTestId("iiif-share")).toBeInTheDocument();

const trigger = screen.getByTestId("iiif-share-trigger");
expect(trigger).toHaveTextContent("View as IIIF");
});

it("renders dropdown content with expected items", async () => {
render(<IIIFShare uri={uri} dropdownMenuProps={{ open: true }} />);

const content = screen.getByTestId("iiif-share-content");
expect(screen.getByText("View in...")).toBeInTheDocument();

const links = Array.from(content.querySelectorAll("a"));
const expectedLinks = {
"Clover IIIF":
"https://samvera-labs.github.io/clover-iiif/docs/viewer/demo?iiif-content=https%3A%2F%2Fiiif.io%2Fapi%2Fcookbook%2Frecipe%2F0001-mvm-image%2Fmanifest.json",
Mirador:
"https://projectmirador.org/embed?iiif-content=https%3A%2F%2Fiiif.io%2Fapi%2Fcookbook%2Frecipe%2F0001-mvm-image%2Fmanifest.json",
Theseus:
"https://theseusviewer.org/?iiif-content=https%3A%2F%2Fiiif.io%2Fapi%2Fcookbook%2Frecipe%2F0001-mvm-image%2Fmanifest.json",
"View Raw JSON": uri,
"What is IIIF?": "https://iiif.io/get-started/why-iiif/",
};

// verify that the links have the expected hrefs
expect(links.length).toEqual(Object.keys(expectedLinks).length);
links.forEach((link) => {
expect(link).toBeInTheDocument();
expect(link).toBeInstanceOf(HTMLAnchorElement);
expect(link).toHaveAttribute("target", "_blank");
const key = link.textContent as keyof typeof expectedLinks;
const expectedHref = expectedLinks[key];
if (expectedHref) {
expect(link).toHaveAttribute("href", expectedHref);
}
});

// verify that the copy button is present
const copyText = screen.getByText("Copy IIIF JSON");
expect(copyText).toBeInTheDocument();
expect(copyText).toBeInstanceOf(HTMLButtonElement);
});
});
204 changes: 204 additions & 0 deletions components/Shared/IIIF/Share.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import * as Dropdown from "@radix-ui/react-dropdown-menu";

import { IconChevronDown, IconExternalLink } from "../SVG/Icons";

import CopyText from "../CopyText";
import IIIFLogo from "../SVG/IIIF";
import IIIFViewerLink from "./ViewerLink";
import Icon from "../Icon";
import Link from "next/link";
import { styled } from "@/stitches.config";

const IIIFShare = ({
uri,
dropdownMenuProps,
}: {
uri: string;
dropdownMenuProps?: Dropdown.DropdownMenuProps;
}) => {
return (
<StyledIIIFShare data-iiif-uri={uri} data-testid="iiif-share">
<Dropdown.Root {...dropdownMenuProps}>
<Dropdown.Trigger data-testid="iiif-share-trigger">
<Icon>
<IIIFLogo />
</Icon>
<em>View as IIIF</em>
<Icon>
<IconChevronDown />
</Icon>
</Dropdown.Trigger>
<StyledIIIFShareContent
data-testid="iiif-share-content"
side="bottom"
sideOffset={3}
collisionPadding={19}
>
<StyledDropdownLabel>View in...</StyledDropdownLabel>
<Dropdown.Item>
<IIIFViewerLink
viewer={{
label: "Clover IIIF",
href: "https://samvera-labs.github.io/clover-iiif/docs/viewer/demo",
}}
uri={uri}
/>
</Dropdown.Item>
<Dropdown.Item>
<IIIFViewerLink
viewer={{
label: "Mirador",
href: "https://projectmirador.org/embed",
}}
uri={uri}
/>
</Dropdown.Item>
<Dropdown.Item>
<IIIFViewerLink
viewer={{
label: "Theseus",
href: "https://theseusviewer.org",
}}
uri={uri}
/>
</Dropdown.Item>
<StyledDropdownSeparator />
<Dropdown.Item>
<Link href={uri} target="_blank" rel="noreferrer">
View Raw JSON
</Link>
</Dropdown.Item>
<Dropdown.Item>
<CopyText textPrompt="Copy IIIF JSON" textToCopy={uri} />
</Dropdown.Item>
<StyledDropdownSeparator />
<Dropdown.Item>
<Link
href="https://iiif.io/get-started/why-iiif/"
target="_blank"
rel="noreferrer"
data-id="what-is-iiif"
>
What is IIIF?
<Icon
style={{
display: "inline-flex",
width: "12px",
height: "12px",
color: "$black50",
fill: "$black50",
marginLeft: "0.25em",
}}
hasSVGPadding={false}
>
<IconExternalLink />
</Icon>
</Link>
</Dropdown.Item>
</StyledIIIFShareContent>
</Dropdown.Root>
</StyledIIIFShare>
);
};

const StyledIIIFShare = styled("div", {
position: "relative",
zIndex: 1,

"> button": {
backgroundColor: "transparent",
color: "$black50",
fontFamily: "$northwesternSansRegular",
fontSize: "$gr3",
borderRadius: "38px",
border: "none",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
gap: "$gr1",
padding: "0 $gr1",
margin: "0",

"> span": {
svg: {
padding: "7px",
path: {
fill: "$purple !important",
},
},

"&:last-child": {
display: "inline-flex",
alignItems: "center",
gap: "$gr1",

"svg path": {
stroke: "$black50 !important",
fill: "none !important",
},
},
},

em: {
fontStyle: "normal",
display: "inline-flex",
marginBottom: "-3px",
},

"&:hover, &:active ": {
color: "$purple",
fill: "$black",
backgroundColor: "$purple10",

"> span:last-child svg path": {
stroke: "$purple !important",
},
},
},
});

const StyledIIIFShareContent = styled(Dropdown.Content, {
zIndex: 1,
backgroundColor: "$white",
padding: "$gr3",
borderRadius: "3px",
boxShadow: "5px 5px 19px 0 #0002",
display: "flex",
flexDirection: "column",
fontSize: "$gr2 ",
minWidth: "160px",
gap: "$gr2",

a: {
color: "$purple",
display: "flex",

svg: {
color: "$purple",
fill: "$purple",
},
},

button: {
fontSize: "$gr2",
margin: "0 !important",
padding: "0 !important",
fontWeight: "400",
lineHeight: "inherit !important",
textDecoration: "none",
color: "$purple",
},
});

const StyledDropdownLabel = styled(Dropdown.Separator, {
fontSize: "$gr2 ",
color: "$black50",
});

const StyledDropdownSeparator = styled(Dropdown.Separator, {
height: "1px",
backgroundColor: "$gray6",
});

export default IIIFShare;
36 changes: 36 additions & 0 deletions components/Shared/IIIF/ViewerLink.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";

import React from "react";
import ViewerLink from "./ViewerLink";

describe("ViewerLink", () => {
const uri =
"https://iiif.io/api/cookbook/recipe/0001-mvm-image/manifest.json";
const viewer = {
label: "IIIF Viewer",
href: "https://example.org/iiif-viewer",
};

it("renders an `<a>` element to IIIF Viewer", () => {
render(<ViewerLink viewer={viewer} uri={uri} />);
expect(screen.getByText(viewer.label)).toBeInTheDocument();
expect(screen.getByText(viewer.label).closest("a")).toHaveAttribute(
"href",
`${viewer.href}?iiif-content=${encodeURIComponent(uri)}`,
);
});

it("renders an `<a>` element to IIIF Viewer with custom iiif-content param", () => {
render(
<ViewerLink
viewer={{ ...viewer, iiifContentParam: "manifest" }}
uri={uri}
/>,
);
expect(screen.getByText(viewer.label)).toBeInTheDocument();
expect(screen.getByText(viewer.label).closest("a")).toHaveAttribute(
"href",
`${viewer.href}?manifest=${encodeURIComponent(uri)}`,
);
});
});
26 changes: 26 additions & 0 deletions components/Shared/IIIF/ViewerLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Link from "next/link";

interface IIIFViewerLinkProps {
viewer: {
label: string;
href: string;
iiifContentParam?: string;
};
uri: string;
}

const IIIFViewerLink: React.FC<IIIFViewerLinkProps> = ({ viewer, uri }) => {
const iiifContent = new URL(viewer.href);
iiifContent.searchParams.set(
viewer.iiifContentParam ? viewer.iiifContentParam : "iiif-content",
uri,
);

return (
<Link href={iiifContent.toString()} target="_blank" rel="noreferrer">
{viewer.label}
</Link>
);
};

export default IIIFViewerLink;
31 changes: 28 additions & 3 deletions components/Shared/Icon.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { CSS } from "@stitches/react";
import { ReactNode } from "react";
import { styled } from "@/stitches.config";

interface IconProps {
children: ReactNode;
style?: CSS;
hasSVGPadding?: boolean;
}

const Icon: React.FC<IconProps> = ({ children }) => {
return <IconStyled>{children}</IconStyled>;
const Icon: React.FC<IconProps> = ({
children,
style,
hasSVGPadding = true,
}) => {
return (
<IconStyled css={{ ...style }} hasSVGPadding={hasSVGPadding}>
{children}
</IconStyled>
);
};

/* eslint sort-keys: 0 */
@@ -32,7 +43,21 @@ export const IconStyled = styled("span", {
color: "inherit",
fill: "inherit",
stroke: "inherit",
padding: "8px",
},

variants: {
hasSVGPadding: {
true: {
svg: {
padding: "0.5em",
},
},
false: {
svg: {
padding: "0",
},
},
},
},
});

8 changes: 8 additions & 0 deletions components/Shared/SVG/Icons.tsx
Original file line number Diff line number Diff line change
@@ -79,6 +79,13 @@ const IconClear: React.FC = () => (
</svg>
);

const IconExternalLink: React.FC = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M224 304a16 16 0 01-11.31-27.31l157.94-157.94A55.7 55.7 0 00344 112H104a56.06 56.06 0 00-56 56v240a56.06 56.06 0 0056 56h240a56.06 56.06 0 0056-56V168a55.7 55.7 0 00-6.75-26.63L235.31 299.31A15.92 15.92 0 01224 304z" />
<path d="M448 48H336a16 16 0 000 32h73.37l-38.74 38.75a56.35 56.35 0 0122.62 22.62L432 102.63V176a16 16 0 0032 0V64a16 16 0 00-16-16z" />
</svg>
);

const IconFilter: React.FC = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<title>Filter</title>
@@ -228,6 +235,7 @@ export {
IconCheck,
IconChevronDown,
IconClear,
IconExternalLink,
IconFilter,
IconImage,
IconInfo,
1 change: 0 additions & 1 deletion components/Shared/WorkCount/WorkCount.styled.ts
Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@ import { styled } from "@/stitches.config";

const WorkCountStyled = styled("div", {
display: "inline-flex",
backgroundColor: "$purple10",
color: "$white",
fontSize: "$gr1",
borderRadius: "1rem",
23 changes: 12 additions & 11 deletions components/Work/ActionsDialog/DownloadAndShare/IIIFManifest.tsx
Original file line number Diff line number Diff line change
@@ -48,18 +48,19 @@ const IIIFManifest: React.FC<IIIFManifestProps> = ({ manifest, work }) => {
manifestId={manifest.id}
/>
</ShareURLActions>
{(isWorkInstitution || isWorkPrivate) && (
<Announcement
css={{
marginTop: "1rem",
}}
data-testid="mirador-announcement"
>
Opening in external tools like Mirador is not supported for works
that require authentication.
</Announcement>
)}
</ShareURL>

{(isWorkInstitution || isWorkPrivate) && (
<Announcement
css={{
marginTop: "1rem",
}}
data-testid="mirador-announcement"
>
Opening in external tools like Mirador is not supported for works that
require authentication.
</Announcement>
)}
</>
);
};
50 changes: 36 additions & 14 deletions components/Work/TopInfo.styled.ts
Original file line number Diff line number Diff line change
@@ -11,7 +11,6 @@ const ActionButtons = styled("div", {

button: {
marginRight: "$gr3",
fontFamily: "$northwesternSansLight",
paddingTop: "$gr3",

"&:last-child": {
@@ -45,33 +44,50 @@ const TopInfoContent = styled("div", {
});

const TopInfoWrapper = styled("section", {
margin: "$gr5 0",
margin: "$gr5 0 $gr6",

[`> header`]: {
display: "flex",
flexDirection: "column",

h1: {
lineHeight: "1em",
fontWeight: "400",
fontFamily: "$northwesternDisplayBold",
fontSize: "$8",
letterSpacing: "-0.015em",
fontSize: "$gr7",
letterSpacing: "-0.025em",
margin: "0",

"@sm": {
fontSize: "$gr7",
},
},

p: {
fontSize: "$gr5",
fontSize: "$gr4",
color: "$black50",
fontFamily: "$northwesternSansLight",
lineHeight: "1.47em",
},
},
});

"@sm": {
fontSize: "$gr4",
},
const TopInfoHeaderContent = styled("div", {
display: "flex",
justifyContent: "space-between",
padding: "0 0 $gr3",
gap: "$gr2",

"@sm": {
flexDirection: "column",
gap: "0",
},

"> div:last-child": {
flexShrink: 0,
display: "flex",
alignItems: "flex-start",
justifyContent: "flex-end",
width: "38.2%",

"@sm": {
justifyContent: "center",
width: "100%",
},
},
});
@@ -86,4 +102,10 @@ const TopInfoCollection = styled("div", {
},
});

export { ActionButtons, TopInfoCollection, TopInfoContent, TopInfoWrapper };
export {
ActionButtons,
TopInfoCollection,
TopInfoContent,
TopInfoHeaderContent,
TopInfoWrapper,
};
20 changes: 16 additions & 4 deletions components/Work/TopInfo.tsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import {
ActionButtons,
TopInfoCollection,
TopInfoContent,
TopInfoHeaderContent,
TopInfoWrapper,
} from "@/components//Work/TopInfo.styled";
import {
@@ -15,6 +16,7 @@ import { Button } from "@nulib/design-system";
import Card from "@/components/Shared/Card";
import { DefinitionListWrapper } from "@/components/Shared/DefinitionList.styled";
import Expand from "@/components/Shared/Expand/Expand";
import IIIFShare from "../Shared/IIIF/Share";
import { Manifest } from "@iiif/presentation-3";
import type { Work } from "@nulib/dcapi-types";
import WorkActionsDialog from "@/components/Work/ActionsDialog/ActionsDialog";
@@ -71,10 +73,20 @@ const WorkTopInfo: React.FC<TopInfoProps> = ({
return (
<TopInfoWrapper>
<header>
<Label label={manifest.label} as="h1" data-testid="title" />
{manifest?.summary && (
<Summary summary={manifest.summary} as="p" data-testid="summary" />
)}
<TopInfoHeaderContent>
<div>
<Label label={manifest.label} as="h1" data-testid="title" />
{manifest?.summary && (
<Summary
summary={manifest.summary}
as="p"
data-testid="summary"
/>
)}
</div>
<IIIFShare uri={manifest.id} />
</TopInfoHeaderContent>

<ActionButtons>
<Button
name="find"
24 changes: 14 additions & 10 deletions hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { useCallback, useEffect, useState } from "react";
import { MouseEvent, useCallback, useEffect, useState } from "react";

export type CopyStatus = "copied" | "failed" | undefined;
export type CopyStatus = "" | "" | undefined;

export const useCopyToClipboard = (
text: string,
notifyTimeout = 2500,
): [CopyStatus, () => void] => {
notifyTimeout = 5000,
): [CopyStatus, (event: MouseEvent) => void] => {
const [copyStatus, setCopyStatus] = useState<CopyStatus>();
const copy = useCallback(() => {
navigator.clipboard.writeText(text).then(
() => setCopyStatus("copied"),
() => setCopyStatus("failed"),
);
}, [text]);
const copy = useCallback(
(event: MouseEvent) => {
event?.preventDefault();
navigator.clipboard.writeText(text).then(
() => setCopyStatus("✔"),
() => setCopyStatus("✗"),
);
},
[text],
);

useEffect(() => {
if (!copyStatus) {
19 changes: 14 additions & 5 deletions hooks/useGenerativeAISearchToggle.ts
Original file line number Diff line number Diff line change
@@ -5,20 +5,26 @@ import { UserContext } from "@/context/user-context";
import useLocalStorage from "@/hooks/useLocalStorage";
import { useRouter } from "next/router";

const defaultModalState = {
export const defaultAIState = {
enabled: "false",
expires: undefined,
};

export const defaultModalState = {
isOpen: false,
title: "Use Generative AI",
};

export default function useGenerativeAISearchToggle() {
const router = useRouter();

const [ai, setAI] = useLocalStorage("ai", "false");
const [ai, setAI] = useLocalStorage("ai", defaultAIState);
const { user } = React.useContext(UserContext);

const [dialog, setDialog] = useState(defaultModalState);

const isAIPreference = ai === "true";
const expires = Date.now() + 1000 * 60 * 60;
const isAIPreference = ai.enabled === "true";
const isChecked = isAIPreference && user?.isLoggedIn;

const loginUrl = `${DCAPI_ENDPOINT}/auth/login?goto=${goToLocation()}`;
@@ -36,7 +42,7 @@ export default function useGenerativeAISearchToggle() {
if (router.isReady) {
const { query } = router;
if (query.ai === "true") {
setAI("true");
setAI({ enabled: "true", expires });
}
}
}, [router.asPath]);
@@ -61,7 +67,10 @@ export default function useGenerativeAISearchToggle() {
if (!user?.isLoggedIn) {
setDialog({ ...dialog, isOpen: checked });
} else {
setAI(checked ? "true" : "false");
setAI({
enabled: checked ? "true" : "false",
expires: checked ? expires : undefined,
});
}
}

2 changes: 1 addition & 1 deletion hooks/useLocalStorage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";

function useLocalStorage(key: string, initialValue: string) {
function useLocalStorage(key: string, initialValue: any) {
// Get the initial value from localStorage or use the provided initialValue
const [storedValue, setStoredValue] = useState(() => {
if (typeof window !== "undefined") {
46 changes: 46 additions & 0 deletions lib/dc-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { DC_API_SEARCH_URL } from "@/lib/constants/endpoints";
import { URLSearchParams } from "url";
import { iiifSearchUri } from "@/lib/dc-api";

describe("iiifSearchUri", () => {
it("returns the expected uri", () => {
const query = { q: "Joan Baez" };
const uri = new URL(iiifSearchUri(query));
const params = new URLSearchParams(uri.search);

// check that the URI has the expected origin and path
expect(DC_API_SEARCH_URL).toContain(uri.origin);
expect(DC_API_SEARCH_URL).toContain(uri.pathname);

// check that the query string has the expected params
expect(params.get("query")).toEqual("Joan Baez");
expect(params.get("as")).toEqual("iiif");
});

it("returns the expected uri with a custom size", () => {
const query = { q: "John Fahey" };
const uri = new URL(iiifSearchUri(query, 100));
const params = new URLSearchParams(uri.search);

// check that the size param is set to 100
expect(params.get("query")).toEqual("John Fahey");
expect(params.get("as")).toEqual("iiif");
expect(params.get("size")).toEqual("100");
});

it("returns the expected uri with appended facets as params", () => {
const query = {
q: "Muddy Waters",
workType: "Image",
genre: "photographs",
};
const uri = new URL(iiifSearchUri(query));
const params = new URLSearchParams(uri.search);

// check that the facets are appended as params
expect(params.get("query")).toEqual("Muddy Waters");
expect(params.get("as")).toEqual("iiif");
expect(params.get("workType")).toEqual("Image");
expect(params.get("genre")).toEqual("photographs");
});
});
29 changes: 29 additions & 0 deletions lib/dc-api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { DCAPI_ENDPOINT, DC_API_SEARCH_URL } from "./constants/endpoints";
import axios, { AxiosError, RawAxiosRequestHeaders } from "axios";

import type { ApiSearchRequestBody } from "@/types/api/request";
import { NextRouter } from "next/router";

interface ApiGetRequestParams {
url: string;
@@ -72,6 +74,31 @@ async function getIIIFResource<R>(
}
}

function iiifSearchUri(query: NextRouter["query"], size?: number): string {
const url = new URL(DC_API_SEARCH_URL);
Object.keys(query).forEach((key) => {
url.searchParams.append(key, query[key] as string);
url.searchParams.delete("q");
query.q ? url.searchParams.append("query", query.q as string) : null;
});

if (size) url.searchParams.append("size", size.toString());
url.searchParams.append("as", "iiif");

return url.toString();
}

function iiifCollectionUri(id?: string, size?: number): string | undefined {
if (!id) return;

const url = new URL(`${DCAPI_ENDPOINT}/collections/${id}`);

if (size) url.searchParams.append("size", size.toString());
url.searchParams.append("as", "iiif");

return url.toString();
}

function handleError(err: unknown) {
const error = err as AxiosError;
if (error.response) {
@@ -96,4 +123,6 @@ export {
apiPostRequest,
getIIIFResource,
handleError,
iiifCollectionUri,
iiifSearchUri,
};
2 changes: 2 additions & 0 deletions mocks/sample-collection1.ts
Original file line number Diff line number Diff line change
@@ -10,6 +10,8 @@ export const sampleCollection1 = {
featured: true,
finding_aid_url: null,
id: "04c17199-1b74-4f9f-853c-1c069f1c4f2e",
iiif_collection:
"https://api.dc.library.northwestern.edu/api/v2/works/04c17199-1b74-4f9f-853c-1c069f1c4f2e?as=iiif",
indexed_at: "what goes here",
keywords: [],
modified_date: "2022-02-24T23:51:15.854797Z",
8 changes: 4 additions & 4 deletions package-lock.json
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@
"@next/bundle-analyzer": "^14.0.3",
"@next/font": "^14.0.3",
"@next/third-parties": "^14.2.3",
"@nulib/dcapi-types": "^2.5.0",
"@nulib/dcapi-types": "^2.6.0",
"@nulib/design-system": "^1.6.2",
"@nulib/use-markdown": "^0.2.1",
"@radix-ui/colors": "^3.0.0",
8 changes: 6 additions & 2 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -16,6 +16,7 @@ import React from "react";
import { SearchProvider } from "@/context/search-context";
import { User } from "@/types/context/user";
import { UserProvider } from "@/context/user-context";
import { defaultAIState } from "@/hooks/useGenerativeAISearchToggle";
import { defaultOpenGraphData } from "@/lib/open-graph";
import { getUser } from "@/lib/user-helpers";
import globalStyles from "@/styles/global";
@@ -37,8 +38,8 @@ function MyApp({ Component, pageProps }: MyAppProps) {
const [mounted, setMounted] = React.useState(false);
const [user, setUser] = React.useState<User>();

const [ai] = useLocalStorage("ai", "false");
const isUsingAI = ai === "true";
const [ai, setAI] = useLocalStorage("ai", defaultAIState);
const isUsingAI = ai?.enabled === "true";

React.useEffect(() => {
async function getData() {
@@ -47,6 +48,9 @@ function MyApp({ Component, pageProps }: MyAppProps) {
setMounted(true);
}
getData();

// Check if AI is enabled and if it has expired
if (ai?.expires && ai.expires < Date.now()) setAI(defaultAIState);
}, []);

React.useEffect(() => {
19 changes: 18 additions & 1 deletion pages/collections/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
CollectionHeader,
Description,
HeroWrapper,
Interstitial,
@@ -32,11 +33,14 @@ import { Collection as CollectionType } from "@nulib/dcapi-types";
import Container from "@/components/Shared/Container";
import Facts from "@/components/Shared/Facts";
import Head from "next/head";
import Heading from "@/components/Heading/Heading";
import Hero from "@/components/Hero/Hero";
import IIIFShare from "@/components/Shared/IIIF/Share";
import Layout from "components/layout";
import ReadMore from "@/components/Shared/ReadMore";
import { buildDataLayer } from "@/lib/ga/data-layer";
import { getHeroCollection } from "@/lib/iiif/collection-helpers";
import { iiifCollectionUri } from "@/lib/dc-api";
import { loadCollectionStructuredData } from "@/lib/json-ld";
import useGenerativeAISearchToggle from "@/hooks/useGenerativeAISearchToggle";
import { useRouter } from "next/router";
@@ -57,6 +61,8 @@ const Collection: NextPage = () => {

const description = collection?.description;

const iiifResource = iiifCollectionUri(collection?.id);

/** Get the Collection */
useEffect(() => {
async function getData() {
@@ -182,9 +188,20 @@ const Collection: NextPage = () => {
<TabsTrigger value="metadata">All Subjects</TabsTrigger>
</TabsList>
<TabsContent value="explore">
<CollectionHeader>
<Heading
as="h2"
css={{
margin: "0 0 $gr2 !important",
}}
>
{collection?.title}
</Heading>{" "}
<IIIFShare uri={String(collection.iiif_collection)} />
</CollectionHeader>
{description && (
<Description data-testid="description">
<ReadMore text={description} words={55} />
{<ReadMore text={description} words={55} />}
</Description>
)}
{topMetadata.length > 0 && (
22 changes: 22 additions & 0 deletions tests/404.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { test as base, expect } from "@playwright/test";

const WORK_404_ID = "00000000-0000-0000-0000-000000000000";

const test = base.extend({});

test.describe("404 page component", async () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/items/${WORK_404_ID}`);
});

test("renders the 404 page", async ({ page }) => {
await expect(page).toHaveURL(`/items/${WORK_404_ID}`);

const figure = await page.locator("main .swiper figure");

await expect(figure.locator(".slide-label")).toHaveText("Page Not Found");
await expect(figure.locator(".slide-summary")).toHaveText(
"Sorry the page you are looking for does not exist. It's possible the resource, work, or collection is no longer available. If you think you reached this page in error, please contact us.",
);
});
});
8 changes: 7 additions & 1 deletion tests/fixtures/work-page.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { type Page } from "@playwright/test";

export const CANARY_WORK_ID = "cb8a19a7-3dec-47f3-80c0-12872ae61f8f";

export class WorkPage {
readonly route: string = "/items";
readonly route: string = `/items/${CANARY_WORK_ID}`;

constructor(public readonly page: Page) {}

async goto() {
await this.page.goto(this.route);
}
}
32 changes: 23 additions & 9 deletions tests/search.spec.ts
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@ const test = base.extend<SearchPageFixtures>({
await openGraphPage.goto();
await use(openGraphPage);
},

// A fixture to help with the Search Page shared functionality
searchPage: async ({ page }, use) => {
const searchPage = new SearchPage(page);
@@ -91,6 +92,7 @@ test.describe("Search page component", () => {
await searchInput.fill(searches[1].term);
await searchBtn.click();

await page.waitForLoadState("domcontentloaded");
await expect(page).toHaveURL(`/search?q=${searches[1].term}`);
await searchPage.verifyTopResultsCount(searches[1].expectedResultCount);
await expect(searchInput).toHaveValue(searches[1].term);
@@ -99,6 +101,7 @@ test.describe("Search page component", () => {
searches[1].expectedResultCount,
);

await page.waitForLoadState("domcontentloaded");
await searchPage.verifyTopResultsCount(searches[1].expectedResultCount);
await searchPage.verifyTotalsResultDisplay({
count: search2,
@@ -110,6 +113,7 @@ test.describe("Search page component", () => {
await expect(page).toHaveURL(/\/search/);

// Verify original counts are back in place
await page.waitForLoadState("domcontentloaded");
await searchPage.verifyTopResultsCount(TOTAL_RESULTS);
await searchPage.verifyGridItemCount(TOTAL_RESULTS);
});
@@ -124,10 +128,10 @@ test.describe("Search page component", () => {
const audioBtn = facetInlineComponent.getByRole("radio", { name: "Audio" });
const videoBtn = facetInlineComponent.getByRole("radio", { name: "Video" });
const clearAllBtn = page.getByRole("button", {
name: "Clear All",
name: "Reset",
});
const publicWorksToggle = page.getByRole("switch", {
name: "Public works only",
name: "Public only",
});
const facetUserComponent = page
.getByTestId("facet-user-component")
@@ -139,38 +143,45 @@ test.describe("Search page component", () => {
const PUBLIC_WORKS_COUNT = 179;

// Work Type facet button checks
await page.waitForLoadState("domcontentloaded");
await expect(allBtn).toHaveAttribute("aria-checked", "true");
await expect(imageBtn).toHaveAttribute("aria-checked", "false");

// Select Image facet
// await imageBtn.click();
// await expect(imageBtn).toHaveAttribute("aria-checked", "true");
// await expect(allBtn).toHaveAttribute("aria-checked", "false");
// await searchPage.verifyTopResultsCount(IMAGE_COUNT);
// await searchPage.verifyGridItemCount(IMAGE_COUNT);
await imageBtn.click();
await page.waitForLoadState("domcontentloaded");
await expect(imageBtn).toHaveAttribute("aria-checked", "true");
await expect(allBtn).toHaveAttribute("aria-checked", "false");
await searchPage.verifyTopResultsCount(IMAGE_COUNT);
await searchPage.verifyGridItemCount(IMAGE_COUNT);

// Select Audio facet
await audioBtn.click();
await page.waitForLoadState("domcontentloaded");
await expect(audioBtn).toHaveAttribute("aria-checked", "true");
await expect(imageBtn).toHaveAttribute("aria-checked", "false");
await searchPage.verifyTopResultsCount(AUDIO_COUNT);
await searchPage.verifyGridItemCount(AUDIO_COUNT);

// Select Video facet
await videoBtn.click();
await page.waitForLoadState("domcontentloaded");
await expect(videoBtn).toHaveAttribute("aria-checked", "true");
await expect(audioBtn).toHaveAttribute("aria-checked", "false");
await searchPage.verifyTopResultsCount(VIDEO_COUNT);
await searchPage.verifyGridItemCount(VIDEO_COUNT);

// Toggle Public Works
// Select All (work types) facet
await allBtn.click();
await page.waitForLoadState("domcontentloaded");
await searchPage.verifyTopResultsCount(TOTAL_RESULTS);

// Toggle Public Works
await publicWorksToggle.click();
await page.waitForLoadState("domcontentloaded");

await searchPage.verifyTopResultsCount(PUBLIC_WORKS_COUNT);
await searchPage.verifyGridItemCount(PUBLIC_WORKS_COUNT);
await expect(facetUserComponent).toContainText("1");

// Test Filter Facet Toggle UI
await clearAllBtn.click();
@@ -179,9 +190,12 @@ test.describe("Search page component", () => {

await imageBtn.click();
await page.waitForLoadState("domcontentloaded");
await publicWorksToggle.click();
await page.waitForLoadState("domcontentloaded");
await expect(facetUserComponent).toContainText("1");

await publicWorksToggle.click();
await page.waitForLoadState("domcontentloaded");
await expect(facetUserComponent).toContainText("2");
});
});
38 changes: 29 additions & 9 deletions tests/work.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { test as base, expect } from "@playwright/test";

import { DC_URL } from "@/lib/constants/endpoints";
import { OpenGraphPage } from "@/tests/fixtures/open-graph";
import { WorkPage } from "@/tests/fixtures/work-page";
import { canaryWork } from "@/tests/fixtures/works/canary-work";
@@ -18,6 +19,7 @@ const test = base.extend<WorkPageFixtures>({
await openGraphPage.goto();
await use(openGraphPage);
},

// A fixture to help with the Search Page shared functionality
workPage: async ({ page }, use) => {
const workPage = new WorkPage(page);
@@ -30,6 +32,11 @@ test.describe("Work page component", async () => {
await page.goto(`/items/${CANARY_WORK_ID}`);
});

/**
* this test is skipped due to timeouts in github CI actions
*/
test.skip();

test("renders Open Graph data and meta title and description", async ({
openGraphPage,
}) => {
@@ -45,17 +52,19 @@ test.describe("Work page component", async () => {
);
});

test("renders the Work top level metadata", async ({ page }) => {
const metadataEl = page.getByTestId("metadata");

await page.getByRole("button", { name: "Dismiss" }).click();
test("renders the Work", async ({ page, workPage }) => {
await expect(page).toHaveURL(`/items/${CANARY_WORK_ID}`);
});

test("renders the Work top level metadata", async ({ page, workPage }) => {
await expect(page.getByTestId("title")).toContainText(
canaryWork.title || "",
);

await expect(page.getByTestId("summary")).toContainText(
canaryWork.description.join(", ") || "",
);
const metadataEl = page.getByTestId("metadata");

await expect(metadataEl.getByText("Alternate Title")).toBeVisible();
await expect(
@@ -199,14 +208,23 @@ test.describe("Work page component", async () => {
.filter({ hasText: "TEST Canary Records" }),
).toHaveAttribute(
"href",
"https://dc.library.northwestern.edu/search?q=collection.id%3A%22820fc328-a333-430b-a974-ac6218a1ffcd%22",
`${DC_URL}/search?collection=TEST+Canary+Records`,
);

// View all button
await expect(page.getByLabel("TEST Canary Records").nth(1)).toHaveAttribute(
// View all buttons
const viewAllButtons = page
.getByTestId("related-items")
.getByRole("link", { name: "View All" });
const similarPattern = new RegExp(`${DC_URL}/search\\?similar=.*`);
const subjectPattern = new RegExp(`${DC_URL}/search\\?subject=.*`);

await expect(viewAllButtons.first()).toHaveAttribute(
"href",
"https://dc.library.northwestern.edu/search?q=collection.id%3A%22820fc328-a333-430b-a974-ac6218a1ffcd%22",
`${DC_URL}/search?collection=TEST+Canary+Records`,
);
await expect(viewAllButtons.nth(1)).toHaveAttribute("href", similarPattern);
await expect(viewAllButtons.nth(2)).toHaveAttribute("href", subjectPattern);
await expect(viewAllButtons.nth(3)).toHaveAttribute("href", subjectPattern);

// Test the Collection carousel
const collectionsSliderItems = relatedItems
@@ -239,7 +257,7 @@ test.describe("Work page component", async () => {
.filter({ hasText: "More Like This" }),
).toHaveAttribute(
"href",
"https://dc.library.northwestern.edu/search?similar=cb8a19a7-3dec-47f3-80c0-12872ae61f8f",
`${DC_URL}/search?similar=cb8a19a7-3dec-47f3-80c0-12872ae61f8f`,
);

// TODO: Something is wrong with the More Like This slider
@@ -275,6 +293,8 @@ test.describe("Work page component", async () => {

await expect(subject1SliderItems).toBeVisible();
await expect(subject2SliderItems).toBeVisible();

console.log("renders the Explore Further section Clover sliders (end)");
});

test("renders the Find this item and Cite this item modal windows", async ({

0 comments on commit 8bc8a87

Please sign in to comment.