diff --git a/src/components/Viewer/InformationPanel/InformationPanel.tsx b/src/components/Viewer/InformationPanel/InformationPanel.tsx index 10425f26..b8a0e1b3 100644 --- a/src/components/Viewer/InformationPanel/InformationPanel.tsx +++ b/src/components/Viewer/InformationPanel/InformationPanel.tsx @@ -1,270 +1,266 @@ -import { - Content, - List, - Scroll, - Trigger, - Wrapper, -} from "src/components/Viewer/InformationPanel/InformationPanel.styled"; import React, { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { ErrorBoundary } from "react-error-boundary"; + import { - ViewerContextStore, - useViewerDispatch, - useViewerState, - type PluginConfig, + ViewerContextStore, + useViewerDispatch, + useViewerState, + type PluginConfig, } from "src/context/viewer-context"; -import AnnotationPage from "src/components/Viewer/InformationPanel/Annotation/Page"; -import ContentSearch from "src/components/Viewer/InformationPanel/ContentSearch/ContentSearch"; -import { AnnotationResources, AnnotationResource } from "src/types/annotations"; -import Information from "src/components/Viewer/InformationPanel/About/About"; import { - InternationalString, - AnnotationPageNormalized, - CanvasNormalized, -} from "@iiif/presentation-3"; + Content, + List, + Scroll, + Trigger, + Wrapper, +} from "./InformationPanel.styled"; + +import AnnotationPage from "./Annotation/Page"; +import ContentSearch from "./ContentSearch/ContentSearch"; +import Information from "./About/About"; +import ContentStateAnnotationPage from "./ContentState/Page"; + import { Label } from "src/components/Primitives"; -import { setupPlugins } from "src/lib/plugin-helpers"; import ErrorFallback from "src/components/UI/ErrorFallback/ErrorFallback"; -import { ErrorBoundary } from "react-error-boundary"; -import { useTranslation } from "react-i18next"; -import ContentStateAnnotationPage from "./ContentState/Page"; +import { + AnnotationResources, + AnnotationResource, +} from "src/types/annotations"; +import { + InternationalString, + AnnotationPageNormalized, + CanvasNormalized, +} from "@iiif/presentation-3"; + +import { getAvailableTabs } from "src/lib/information-panel-helpers"; const UserScrollTimeout = 1500; // 1500ms without a user-generated scroll event reverts to auto-scrolling interface NavigatorProps { - activeCanvas: string; - annotationResources?: AnnotationResources; - searchServiceUrl?: string; - setContentSearchResource: React.Dispatch< - React.SetStateAction - >; - contentSearchResource?: AnnotationResource; + activeCanvas: string; + annotationResources?: AnnotationResources; + searchServiceUrl?: string; + setContentSearchResource: React.Dispatch< + React.SetStateAction + >; + contentSearchResource?: AnnotationResource; + pluginsWithInfoPanel?: PluginConfig[]; } export const InformationPanel: React.FC = ({ - activeCanvas, - annotationResources, - searchServiceUrl, - setContentSearchResource, - contentSearchResource, + activeCanvas, + annotationResources, + searchServiceUrl, + setContentSearchResource, + contentSearchResource, + pluginsWithInfoPanel, }) => { - const { t } = useTranslation(); - const dispatch: any = useViewerDispatch(); - const viewerState: ViewerContextStore = useViewerState(); - const { - contentStateAnnotation, - informationPanelResource, - isAutoScrolling, - isUserScrolling, - vault, - configOptions, - plugins, - } = viewerState; - const { informationPanel } = configOptions; + const { t } = useTranslation(); + const dispatch: any = useViewerDispatch(); + const viewerState: ViewerContextStore = useViewerState(); + const { + contentStateAnnotation, + informationPanelResource, + isAutoScrolling, + isUserScrolling, + vault, + configOptions, + } = viewerState; + const { informationPanel } = configOptions; - const renderAbout = informationPanel?.renderAbout; - const renderAnnotation = informationPanel?.renderAnnotation; - const canvas = vault.get({ - id: activeCanvas, - type: "Canvas", - }) as CanvasNormalized; + const renderAbout = informationPanel?.renderAbout; + const renderAnnotation = informationPanel?.renderAnnotation; + const canvas = vault.get({ + id: activeCanvas, + type: "Canvas", + }) as CanvasNormalized; - const renderContentSearch = informationPanel?.renderContentSearch; - const renderToggle = informationPanel?.renderToggle; - const contentStateAnnotationSource = - // @ts-ignore - contentStateAnnotation?.target?.source || contentStateAnnotation?.target; - const hasContentStateAnnotation = - Boolean(contentStateAnnotation) && - // @ts-ignore - contentStateAnnotationSource.id === activeCanvas; - const hasAnnotations = - Boolean(annotationResources?.length) || hasContentStateAnnotation; + const renderContentSearch = informationPanel?.renderContentSearch; + const renderToggle = informationPanel?.renderToggle; + const contentStateAnnotationSource = + // @ts-ignore + contentStateAnnotation?.target?.source || contentStateAnnotation?.target; + const hasContentStateAnnotation = + Boolean(contentStateAnnotation) && + // @ts-ignore + contentStateAnnotationSource.id === activeCanvas; + const hasAnnotations = + Boolean(annotationResources?.length) || hasContentStateAnnotation; - const { pluginsWithInfoPanel } = setupPlugins(plugins); + function renderPluginInformationPanel(plugin: PluginConfig, i: number) { + const PluginInformationPanelComponent = plugin?.informationPanel + ?.component as unknown as React.ElementType; - function renderPluginInformationPanel(plugin: PluginConfig, i: number) { - const PluginInformationPanelComponent = plugin?.informationPanel - ?.component as unknown as React.ElementType; + if (PluginInformationPanelComponent === undefined) { + return <>; + } - if (PluginInformationPanelComponent === undefined) { - return <>; - } + return ( + + + + + + ); + } - return ( - - - - - - ); - } + /** + * Close the information panel + */ + const handleInformationPanelClose = () => { + dispatch({ + type: "updateInformationOpen", + isInformationOpen: false, + }); + }; - /** - * Close the information panel - */ - const handleInformationPanelClose = () => { - dispatch({ - type: "updateInformationOpen", - isInformationOpen: false, - }); - }; + useEffect(() => { + const availableTabs = getAvailableTabs({ + informationPanel, + annotationResources, + contentSearchResource, + pluginsWithInfoPanel, + }); - useEffect(() => { - /** - * If a default tab is set, set the active tab to that value - */ - if ( - [ - "manifest-about", - "manifest-annotations", - "manifest-content-search", - ].includes(String(informationPanel?.defaultTab)) - ) { - dispatch({ - type: "updateInformationPanelResource", - informationPanelResource: informationPanel?.defaultTab, - }); - } else if (hasContentStateAnnotation) { - dispatch({ - type: "updateInformationPanelResource", - informationPanelResource: "manifest-annotations", - }); - } else { - dispatch({ - type: "updateInformationPanelResource", - informationPanelResource: "manifest-about", - }); - } - }, []); + const defaultTab = + (informationPanel?.defaultTab && + availableTabs.includes(String(informationPanel.defaultTab)) && + informationPanel.defaultTab) || + availableTabs[0] || + "manifest-about"; - useEffect(() => { - if (!hasAnnotations) { - dispatch({ - type: "updateInformationPanelResource", - informationPanelResource: "manifest-about", - }); - } - }, [hasAnnotations]); + dispatch({ + type: "updateInformationPanelResource", + informationPanelResource: defaultTab, + }); + }, [ + informationPanel, + annotationResources, + contentSearchResource, + pluginsWithInfoPanel, + dispatch, + ]); - function handleScroll() { - if (!isAutoScrolling) { - clearTimeout(isUserScrolling); - const timeout = setTimeout(() => { - dispatch({ - type: "updateUserScrolling", - isUserScrolling: undefined, - }); - }, UserScrollTimeout); + function handleScroll() { + if (!isAutoScrolling) { + clearTimeout(isUserScrolling); + const timeout = setTimeout(() => { + dispatch({ + type: "updateUserScrolling", + isUserScrolling: undefined, + }); + }, UserScrollTimeout); - dispatch({ - type: "updateUserScrolling", - isUserScrolling: timeout, - }); - } - } + dispatch({ + type: "updateUserScrolling", + isUserScrolling: timeout, + }); + } + } - const handleValueChange = (value: string) => { - dispatch({ - type: "updateInformationPanelResource", - informationPanelResource: value, - }); - }; + const handleValueChange = (value: string) => { + dispatch({ + type: "updateInformationPanelResource", + informationPanelResource: value, + }); + }; - return ( - - - {renderToggle && ( - - {t("informationPanelTabsClose")} - - )} - {renderAbout && ( - - {t("informationPanelTabsAbout")} - - )} - {renderContentSearch && contentSearchResource && ( - - {t("informationPanelTabsSearch")} - - )} - {renderAnnotation && hasAnnotations && ( - - {informationPanel?.annotationTabLabel || - t("informationPanelTabsAnnotations")} - - )} + return ( + + + {renderToggle && ( + + {t("informationPanelTabsClose")} + + )} + {renderAbout && ( + + {t("informationPanelTabsAbout")} + + )} + {renderContentSearch && contentSearchResource && ( + + {t("informationPanelTabsSearch")} + + )} + {renderAnnotation && hasAnnotations && ( + + {informationPanel?.annotationTabLabel || + t("informationPanelTabsAnnotations")} + + )} - {pluginsWithInfoPanel && - pluginsWithInfoPanel.map((plugin, i) => ( - - - ))} - - - {renderAbout && ( - - - - )} - {renderContentSearch && contentSearchResource && ( - - - - )} - {renderAnnotation && annotationResources && ( - - {contentStateAnnotation && hasContentStateAnnotation && ( - - )} - {annotationResources.map((annotationPage) => ( - - ))} - - )} + {pluginsWithInfoPanel && + pluginsWithInfoPanel.map((plugin, i) => ( + + + ))} + + + {renderAbout && ( + + + + )} + {renderContentSearch && contentSearchResource && ( + + + + )} + {renderAnnotation && annotationResources && ( + + {contentStateAnnotation && hasContentStateAnnotation && ( + + )} + {annotationResources.map((annotationPage) => ( + + ))} + + )} - {pluginsWithInfoPanel && - pluginsWithInfoPanel.map((plugin, i) => - renderPluginInformationPanel(plugin, i), - )} - - - ); + {pluginsWithInfoPanel && + pluginsWithInfoPanel.map((plugin, i) => + renderPluginInformationPanel(plugin, i), + )} + + + ); }; export default InformationPanel; diff --git a/src/components/Viewer/Viewer/Content.test.tsx b/src/components/Viewer/Viewer/Content.test.tsx index 6b87e18b..b4f500b4 100644 --- a/src/components/Viewer/Viewer/Content.test.tsx +++ b/src/components/Viewer/Viewer/Content.test.tsx @@ -68,44 +68,8 @@ describe("ViewerContent", () => { }); }); -describe("ViewerContent with no Annotation Resources", () => { - test("renders InformationPanel by default", () => { - render( - - - , - ); - expect(screen.getByTestId("mock-information-panel")); - }); - - test("does not render InformationPanel or Toggle when configured not to display it", () => { - render( - - - , - ); - expect(screen.queryByTestId("mock-information-panel")).toBeNull(); - }); -}); - -describe("ViewerContent with Annotation Resources", () => { - test("renders Annotations in InformationPanel even if initial default configuration turns off InformationPanel", () => { +describe("ViewerContent InformationPanel visibility", () => { + test("does not render InformationPanel when no tabs are configured", () => { render( { isInformationOpen: true, configOptions: { informationPanel: { - open: false, renderAbout: false, - renderToggle: false, + renderToggle: true, }, }, }} @@ -126,17 +89,15 @@ describe("ViewerContent with Annotation Resources", () => { expect(screen.queryByTestId("mock-information-panel")).toBeNull(); }); - test("renders the InformationPanel as closed when configured to do so", () => { + test("renders InformationPanel when About tab is enabled and panel is open", () => { render( { , ); - expect(screen.queryByTestId("mock-information-panel")).toBeNull(); + expect(screen.getByTestId("mock-information-panel")).toBeInTheDocument(); }); -}); -describe("ViewerContent with Annotation Resources", () => { - const propsWithAnnotationResources = { - ...props, - annotationResources, - }; - - test("renders InformationPanel even if initial default configuration turns off InformationPanel", async () => { + test("renders InformationPanel when Annotation tab is enabled and annotation resources exist and panel is open", () => { + const propsWithAnnotationResources = { ...props, annotationResources }; render( { isInformationOpen: true, configOptions: { informationPanel: { - ...defaultState.configOptions.informationPanel, - open: false, renderAbout: false, - renderToggle: false, + renderAnnotation: true, + renderToggle: true, }, }, }} @@ -173,52 +127,80 @@ describe("ViewerContent with Annotation Resources", () => { , ); - expect( - await screen.findByTestId("mock-information-panel"), - ).toBeInTheDocument(); + expect(screen.getByTestId("mock-information-panel")).toBeInTheDocument(); }); - test("does not render the Information Panel when toggle state is off", () => { + test("renders InformationPanel when Content Search tab is enabled and resource exists and panel is open", () => { render( - + , ); - expect(screen.queryByTestId("mock-information-panel")).toBeNull(); + expect(screen.getByTestId("mock-information-panel")).toBeInTheDocument(); }); - test("does not render the Information Panel if configured to hide Information Pane and hide annotations", () => { +test("renders InformationPanel when there is a tabbed plugin enabled", () => { + const pluginWithPanel = [ + { + id: "DemoPlugin", + informationPanel: { + label: { en: ["Demo Panel"] }, + component: () =>
Demo Plugin Panel
, + }, + }, + ]; + + render( + + + + ); + + expect(screen.getByTestId("mock-information-panel")).toBeInTheDocument(); +}); + + test("does not render InformationPanel if panel is closed, even if tabs are present", () => { render( - + , ); expect(screen.queryByTestId("mock-information-panel")).toBeNull(); diff --git a/src/components/Viewer/Viewer/Content.tsx b/src/components/Viewer/Viewer/Content.tsx index ca319203..958cdc0f 100644 --- a/src/components/Viewer/Viewer/Content.tsx +++ b/src/components/Viewer/Viewer/Content.tsx @@ -1,21 +1,25 @@ +import React from "react"; import { AnnotationPageNormalized, Canvas, IIIFExternalWebResource, } from "@iiif/presentation-3"; -import { AnnotationResource, AnnotationResources } from "src/types/annotations"; + +import { useViewerState } from "src/context/viewer-context"; +import { hasAnyPanel } from "src/lib/information-panel-helpers"; +import { setupPlugins } from "src/lib/plugin-helpers"; + import { Aside, Content, Main, MediaWrapper, -} from "src/components/Viewer/Viewer/Viewer.styled"; - -import InformationPanel from "src/components/Viewer/InformationPanel/InformationPanel"; -import Media from "src/components/Viewer/Media/Media"; +} from "../Viewer/Viewer.styled"; +import InformationPanel from "../InformationPanel/InformationPanel"; import Painting from "../Painting/Painting"; -import React from "react"; -import { useViewerState } from "src/context/viewer-context"; +import Media from "../Media/Media"; + +import { AnnotationResource, AnnotationResources } from "src/types/annotations"; export interface ViewerContentProps { activeCanvas: string; @@ -41,37 +45,24 @@ const ViewerContent: React.FC = ({ painting, }) => { const { - contentStateAnnotation, isInformationOpen, configOptions, sequence, - visibleCanvases, + plugins, } = useViewerState(); const { informationPanel } = configOptions; - /** - * The information panel should be rendered if toggled true and if - * there is content (About or Annotations Resources) to display. - */ - const visibleCanvasesIds = visibleCanvases.map((canvas) => canvas.id); - - const hasAnnotations = - annotationResources.length > 0 || - // @ts-ignore - visibleCanvasesIds.includes(contentStateAnnotation?.target?.source?.id); - - // Only force the aside open for annotations when no toggle is rendered. - // If a toggle is visible, it must control open/close behavior. - const isForcedAside = - hasAnnotations && - informationPanel?.renderAnnotation && - informationPanel?.renderToggle === false && - isInformationOpen; + const { pluginsWithInfoPanel } = setupPlugins(plugins); - const isAside = - (informationPanel?.renderAbout && isInformationOpen) || isForcedAside; + const hasPanel = hasAnyPanel({ + informationPanel: informationPanel, + annotationResources, + contentSearchResource, + pluginsWithInfoPanel, + }); const renderToggle = informationPanel?.renderToggle; + const isAside = hasPanel && isInformationOpen; return ( = ({ searchServiceUrl={searchServiceUrl} setContentSearchResource={setContentSearchResource} contentSearchResource={contentSearchResource} + pluginsWithInfoPanel={pluginsWithInfoPanel} /> )} diff --git a/src/lib/information-panel-helpers.test.ts b/src/lib/information-panel-helpers.test.ts new file mode 100644 index 00000000..b2329181 --- /dev/null +++ b/src/lib/information-panel-helpers.test.ts @@ -0,0 +1,205 @@ +import { hasAnyPanel, getAvailableTabs } from "./information-panel-helpers"; + +describe("hasAnyPanel", () => { + it("returns true when renderAbout is true", () => { + expect( + hasAnyPanel({ + informationPanel: { + renderAbout: true, + renderAnnotation: false, + renderContentSearch: false + }, + pluginsWithInfoPanel: [], + annotationResources: [], + contentSearchResource: undefined, + }), + ).toBe(true); + }); + + it("returns true when renderAnnotation is true and annotationResources has items", () => { + expect( + hasAnyPanel({ + informationPanel: { + renderAbout: false, + renderAnnotation: true, + renderContentSearch: false, + }, + pluginsWithInfoPanel: [], + annotationResources: [{ id: "a", type: "AnnotationPage" }], + contentSearchResource: undefined, + }), + ).toBe(true); + }); + + it("returns false when renderAnnotation is true but annotationResources is empty", () => { + expect( + hasAnyPanel({ + informationPanel: { + renderAbout: false, + renderAnnotation: true, + renderContentSearch: false, + }, + pluginsWithInfoPanel: [], + annotationResources: [], + contentSearchResource: undefined, + }), + ).toBe(false); + }); + + it("returns true when renderContentSearch is true and contentSearchResource is present", () => { + expect( + hasAnyPanel({ + informationPanel: { + renderAbout: false, + renderAnnotation: false, + renderContentSearch: true, + }, + pluginsWithInfoPanel: [], + annotationResources: [], + contentSearchResource: { id: "search-id", type: "AnnotationPage" }, + }), + ).toBe(true); + }); + + it("returns false when renderContentSearch is true but contentSearchResource is undefined", () => { + expect( + hasAnyPanel({ + informationPanel: { + renderAbout: false, + renderAnnotation: false, + renderContentSearch: true, + }, + pluginsWithInfoPanel: [], + annotationResources: [], + contentSearchResource: undefined, + }), + ).toBe(false); + }); + + it("returns true when pluginsWithInfoPanel has items", () => { + expect( + hasAnyPanel({ + informationPanel: { + renderAbout: false, + renderAnnotation: false, + renderContentSearch: false, + }, + pluginsWithInfoPanel: [{ id: "PluginTab" }], + annotationResources: [], + contentSearchResource: undefined, + }), + ).toBe(true); + }); + + it("returns false when all panel types are false/empty", () => { + expect( + hasAnyPanel({ + informationPanel: { + renderAbout: false, + renderAnnotation: false, + renderContentSearch: false, + }, + pluginsWithInfoPanel: [], + annotationResources: [], + contentSearchResource: undefined, + }), + ).toBe(false); + }); +}); + +describe("getAvailableTabs", () => { + it("returns manifest-about when renderAbout is true", () => { + expect( + getAvailableTabs({ + informationPanel: { + renderAbout: true, + renderAnnotation: false, + renderContentSearch: false, + }, + annotationResources: [], + contentSearchResource: undefined, + pluginsWithInfoPanel: [], + }) + ).toStrictEqual(["manifest-about"]); + }); + + it("returns manifest-annotations when renderAnnotation is true and annotationResources has items", () => { + expect( + getAvailableTabs({ + informationPanel: { + renderAbout: false, + renderAnnotation: true, + renderContentSearch: false, + }, + annotationResources: [{ id: "a", type: "AnnotationPage" }], + contentSearchResource: undefined, + pluginsWithInfoPanel: [], + }) + ).toStrictEqual(["manifest-annotations"]); + }); + + it("returns manifest-content-search when renderContentSearch is true and contentSearchResource is present", () => { + expect( + getAvailableTabs({ + informationPanel: { + renderAbout: false, + renderAnnotation: false, + renderContentSearch: true, + }, + annotationResources: [], + contentSearchResource: { id: "search-id", type: "AnnotationPage" }, + pluginsWithInfoPanel: [], + }) + ).toStrictEqual(["manifest-content-search"]); + }); + + it("returns plugin tab ids when pluginsWithInfoPanel has items", () => { + expect( + getAvailableTabs({ + informationPanel: { + renderAbout: false, + renderAnnotation: false, + renderContentSearch: false, + }, + annotationResources: [], + contentSearchResource: undefined, + pluginsWithInfoPanel: [{ id: "PluginTab" }, { id: "AnotherPlugin" }], + }) + ).toStrictEqual(["PluginTab", "AnotherPlugin"]); + }); + + it("returns all tab ids in priority order", () => { + expect( + getAvailableTabs({ + informationPanel: { + renderAbout: true, + renderAnnotation: true, + renderContentSearch: true, + }, + annotationResources: [{ id: "a", type: "AnnotationPage" }], + contentSearchResource: { id: "search-id", type: "AnnotationPage" }, + pluginsWithInfoPanel: [{ id: "PluginTab" }], + }) + ).toStrictEqual([ + "manifest-about", + "manifest-annotations", + "manifest-content-search", + "PluginTab", + ]); + }); + + it("returns an empty array when nothing is enabled or present", () => { + expect( + getAvailableTabs({ + informationPanel: { + renderAbout: false, + renderAnnotation: false, + renderContentSearch: false, + }, + annotationResources: [], + contentSearchResource: undefined, + pluginsWithInfoPanel: [], + }) + ).toStrictEqual([]); + }); +}); diff --git a/src/lib/information-panel-helpers.ts b/src/lib/information-panel-helpers.ts new file mode 100644 index 00000000..d78c8408 --- /dev/null +++ b/src/lib/information-panel-helpers.ts @@ -0,0 +1,30 @@ +export function hasAnyPanel({ + informationPanel, + annotationResources, + contentSearchResource, + pluginsWithInfoPanel, +}) { + return [ + informationPanel?.renderAbout, + informationPanel?.renderAnnotation && annotationResources?.length > 0, + informationPanel?.renderContentSearch && contentSearchResource, + pluginsWithInfoPanel?.length > 0, + ].some(Boolean); +} + +export function getAvailableTabs({ + informationPanel, + annotationResources, + contentSearchResource, + pluginsWithInfoPanel, +}) { + const tabs = [ + informationPanel?.renderAbout && "manifest-about", + informationPanel?.renderAnnotation && annotationResources?.length > 0 && "manifest-annotations", + informationPanel?.renderContentSearch && contentSearchResource && "manifest-content-search", + ...(pluginsWithInfoPanel?.map((p) => String(p.id)) ?? []), + ]; + + // remove falsy values + return tabs.filter(Boolean) as string[]; +}