diff --git a/CHANGELOG-hmp-235.md b/CHANGELOG-hmp-235.md new file mode 100644 index 0000000000..74760ff298 --- /dev/null +++ b/CHANGELOG-hmp-235.md @@ -0,0 +1 @@ +- Add support for text-only publication vignettes without Vitessce visualizations. diff --git a/context/app/static/js/components/Contexts.jsx b/context/app/static/js/components/Contexts.jsx index a60aa2cd67..02c773bb70 100644 --- a/context/app/static/js/components/Contexts.jsx +++ b/context/app/static/js/components/Contexts.jsx @@ -1,7 +1,20 @@ -import React, { useContext } from 'react'; +import { useContext, createContext } from 'react'; -const FlaskDataContext = React.createContext({}); -const AppContext = React.createContext({}); +// TODO: +// I tried converting this to a .tsx file, but it made storybook fail; created HMP-250 to track this +// We should continue specifying shapes of contexts as we start using them in our TS files +const FlaskDataContext = createContext({}); + +/** + * @typedef AppContextType + * @property {string} assetsEndpoint + * @property {string} groupsToken + */ + +/** + * @type {AppContextType} + */ +const AppContext = createContext({}); FlaskDataContext.displayName = 'FlaskDataContext'; diff --git a/context/app/static/js/components/publications/PublicationVignette/PublicationVignette.jsx b/context/app/static/js/components/publications/PublicationVignette/PublicationVignette.jsx index e09f01153e..2fcd19ad62 100644 --- a/context/app/static/js/components/publications/PublicationVignette/PublicationVignette.jsx +++ b/context/app/static/js/components/publications/PublicationVignette/PublicationVignette.jsx @@ -1,88 +1,26 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import ReactMarkdown from 'react-markdown'; -import { useAppContext } from 'js/components/Contexts'; import VisualizationWrapper from 'js/components/detailPage/visualization/VisualizationWrapper'; import useStickyToggle from 'js/hooks/useStickyToggle'; -import { fillUrls } from './utils'; - -async function fetchVitessceConf({ assetsEndpoint, uuid, filePath, groupsToken, vignetteDirName, signal }) { - const urlHandler = (url, isZarr) => { - return `${url.replace('{{ base_url }}', `${assetsEndpoint}/${uuid}/data`)}${isZarr ? '' : `?token=${groupsToken}`}`; - }; - - const requestInitHandler = () => { - return { - headers: { Authorization: `Bearer ${groupsToken}` }, - }; - }; - const response = await fetch( - `${assetsEndpoint}/${uuid}/vignettes/${vignetteDirName}/${filePath}?token=${groupsToken}`, - { - signal, - headers: { - 'Content-Type': 'application/json', - }, - }, - ); - - if (!response.ok) { - console.error('Assets API failed', response); - return undefined; - } - const conf = await response.json(); - return fillUrls(conf, urlHandler, requestInitHandler); -} - -function useVitessceConfs(assetsEndpoint, groupsToken, uuid, vignette, vignetteDirName) { - const [vitessceConfs, setVitessceConfs] = useState(undefined); - - useEffect(() => { - const abortController = new AbortController(); - async function getAndSetVitessceConf() { - const figuresConfs = await Promise.all( - vignette.figures.map((figure) => { - return fetchVitessceConf({ - assetsEndpoint, - uuid, - filePath: figure.file, - groupsToken, - vignetteDirName, - signal: abortController.signal, - }); - }), - ); - setVitessceConfs(figuresConfs); - } - // Only fetch the vitessce confs if they haven't been fetched yet - if (!vitessceConfs) { - getAndSetVitessceConf(); - return () => { - abortController.abort(); - }; - } - return () => { - // Do nothing if the vitessce confs have already been fetched - }; - }, [assetsEndpoint, groupsToken, uuid, vignette.figures, vignetteDirName, vitessceConfs]); - - return vitessceConfs; -} +import { usePublicationVignetteConfs } from './hooks'; function PublicationVignette({ vignette, vignetteDirName, uuid, mounted }) { - const { assetsEndpoint, groupsToken } = useAppContext(); - - const vitessceConfs = useVitessceConfs(assetsEndpoint, groupsToken, uuid, vignette, vignetteDirName); + const vitessceConfs = usePublicationVignetteConfs({ + uuid, + vignette, + vignetteDirName, + }); // Workaround to make the visualization render only after the accordion section has been expanded while // still letting the prerequisites for the visualizations prefetch const hasBeenMounted = useStickyToggle(mounted); - if (vitessceConfs) { - return ( - <> - {vignette.description} + return ( + <> + {vignette.description} + {vitessceConfs && ( - - ); - } - - return null; + )} + + ); } export default PublicationVignette; diff --git a/context/app/static/js/components/publications/PublicationVignette/hooks.ts b/context/app/static/js/components/publications/PublicationVignette/hooks.ts new file mode 100644 index 0000000000..67ac367266 --- /dev/null +++ b/context/app/static/js/components/publications/PublicationVignette/hooks.ts @@ -0,0 +1,42 @@ +import useSWR from 'swr'; +import { useAppContext } from 'js/components/Contexts'; +import { multiFetcher } from 'js/helpers/multiFetcher'; +import { fillUrls } from './utils'; +import { PublicationVignette } from '../types'; + +type PublicationVignetteConfsInput = { + uuid: string; + vignette: PublicationVignette; + vignetteDirName: string; +}; + +export function usePublicationVignetteConfs({ uuid, vignetteDirName, vignette }: PublicationVignetteConfsInput) { + const { assetsEndpoint, groupsToken } = useAppContext(); + // Extract file paths from the vignette object to form the urls to fetch for this vignette + const urls = vignette.figures?.map( + ({ file }) => `${assetsEndpoint}/${uuid}/vignettes/${vignetteDirName}/${file}?token=${groupsToken}`, + ); + const { data } = useSWR(urls, multiFetcher, { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }); + + if (data) { + const urlHandler = (url: string, isZarr: boolean) => { + return `${url.replace('{{ base_url }}', `${assetsEndpoint}/${uuid}/data`)}${ + isZarr ? '' : `?token=${groupsToken}` + }`; + }; + + const requestInitHandler = () => { + return { + headers: { Authorization: `Bearer ${groupsToken}` }, + }; + }; + // Formats the vitessce config data to replace the {{ base_url }} placeholder with the actual url. + // TODO: Improve this `unknown`; I couldn't figure out how to import the appropriate `VitessceConfig` type from Vitessce. + const formattedData: unknown[] = data.map((d) => fillUrls(d, urlHandler, requestInitHandler)); + return formattedData; + } + return undefined; +} diff --git a/context/app/static/js/components/publications/types.ts b/context/app/static/js/components/publications/types.ts new file mode 100644 index 0000000000..266eed5b54 --- /dev/null +++ b/context/app/static/js/components/publications/types.ts @@ -0,0 +1,11 @@ +export type VignetteFigure = { + file: string; + name: string; +}; + +export type PublicationVignette = { + name: string; + description: string; + figures?: VignetteFigure[]; + directory_name: string; +}; diff --git a/context/app/static/js/helpers/multiFetcher.ts b/context/app/static/js/helpers/multiFetcher.ts new file mode 100644 index 0000000000..e12f94db6a --- /dev/null +++ b/context/app/static/js/helpers/multiFetcher.ts @@ -0,0 +1,5 @@ +// Fetcher function that lets SWR fetch multiple urls at once +export const multiFetcher = (...urls: string[]) => { + const f = (url: string) => fetch(url).then((response) => response.json()); + return Promise.all(urls.map((url) => f(url))); +}; diff --git a/context/tsconfig.json b/context/tsconfig.json index b1f5c87964..5b22ff1268 100644 --- a/context/tsconfig.json +++ b/context/tsconfig.json @@ -20,8 +20,8 @@ "sourceMap": true, "noEmit": true, "paths": { - "@/*": [ - "./app/static/js/*" + "js/*": [ + "./*" ] } },