diff --git a/CHANGELOG-hmp-152.md b/CHANGELOG-hmp-152.md new file mode 100644 index 0000000000..c233d492a9 --- /dev/null +++ b/CHANGELOG-hmp-152.md @@ -0,0 +1,3 @@ + - Add support for multiple comma-separated protocols.io links. + - Improve parsing of protocols.io links. + - Restore display of public protocols.io links. diff --git a/context/app/default_config.py b/context/app/default_config.py index 6699c92f35..763b8cd01f 100644 --- a/context/app/default_config.py +++ b/context/app/default_config.py @@ -41,3 +41,7 @@ class DefaultConfig(object): SECRET_KEY = 'should-be-overridden' APP_CLIENT_ID = 'should-be-overridden' APP_CLIENT_SECRET = 'should-be-overridden' + + PROTOCOLS_IO_CLIENT_ID = 'should-be-overridden' + PROTOCOLS_IO_CLIENT_SECRET = 'should-be-overridden' + PROTOCOLS_IO_CLIENT_AUTH_TOKEN = 'should-be-overridden' diff --git a/context/app/static/js/components/Providers.jsx b/context/app/static/js/components/Providers.jsx index f829d523c1..0e56f2ee6d 100644 --- a/context/app/static/js/components/Providers.jsx +++ b/context/app/static/js/components/Providers.jsx @@ -1,10 +1,12 @@ import React, { useMemo } from 'react'; +import { SWRConfig } from 'swr'; import { FlaskDataContext, AppContext } from 'js/components/Contexts'; import { ThemeProvider } from 'styled-components'; import PropTypes from 'prop-types'; import { MuiThemeProvider, StylesProvider, createGenerateClassName } from '@material-ui/core/styles'; import CssBaseline from '@material-ui/core/CssBaseline'; import GlobalStyles from 'js/components/globalStyles'; +import { ProtocolAPIContext } from 'js/components/detailPage/Protocol/ProtocolAPIContext'; import theme from '../theme'; import GlobalFonts from '../fonts'; @@ -37,22 +39,35 @@ function Providers({ [groupsToken, workspacesToken, isWorkspacesUser, isHubmapUser, isAuthenticated, userEmail, endpoints], ); + const protocolsContext = useMemo( + () => ({ protocolsClientId: flaskData?.protocolsClientId, clientAuthToken: flaskData?.protocolsClientToken }), + [flaskData], + ); + return ( // injectFirst ensures styled-components takes priority over mui for styling - - - - - - - - - {children} - - - - - + + + + + + + + + + + {children} + + + + + + + ); } diff --git a/context/app/static/js/components/detailPage/Protocol/Protocol.jsx b/context/app/static/js/components/detailPage/Protocol/Protocol.jsx index 2616cee9ac..46f335d2e6 100644 --- a/context/app/static/js/components/detailPage/Protocol/Protocol.jsx +++ b/context/app/static/js/components/detailPage/Protocol/Protocol.jsx @@ -3,38 +3,49 @@ import PropTypes from 'prop-types'; import Divider from '@material-ui/core/Divider'; import OutboundIconLink from 'js/shared-styles/Links/iconLinks/OutboundIconLink'; -import useProtocolData from 'js/hooks/useProtocolData'; +import EmailIconLink from 'js/shared-styles/Links/iconLinks/EmailIconLink'; +import useProtocolData, { useFormattedProtocolUrls } from 'js/hooks/useProtocolData'; import SectionHeader from 'js/shared-styles/sections/SectionHeader'; import { DetailPageSection } from 'js/components/detailPage/style'; import { StyledPaper } from './style'; import SectionItem from '../SectionItem'; -function ProtocolLink({ title, resolverHostnameAndDOI }) { +function ProtocolLink({ url, index }) { + const { loading, data, error } = useProtocolData(url); + if (error || loading || !data) { + if (index !== 0) { + // Only show loading message for first protocol link + return null; + } + // Extra `div` wrapper is necessary to prevent the email icon link from taking up the full width and breaking text + return ( + +
+ Protocols are loading. If protocols take a significant time to load, please contact{' '} + help@hubmapconsortium.org about this issue + and mention the HuBMAP ID. +
+
+ ); + } return ( - - {resolverHostnameAndDOI ? ( - {resolverHostnameAndDOI} - ) : ( - 'Please wait...' - )} + + {data?.payload?.url} ); } function Protocol({ protocol_url }) { - const matchedDoiSuffix = protocol_url.match(/\w*$/)[0]; - - const protocolData = useProtocolData(matchedDoiSuffix, 1); - - const title = protocolData?.protocol?.title; - const resolverHostnameAndDOI = protocolData?.protocol?.doi; + const protocolUrls = useFormattedProtocolUrls(protocol_url, 1); return ( Protocols - + {protocolUrls.map((url, index) => ( + + ))} ); diff --git a/context/app/static/js/components/detailPage/Protocol/ProtocolAPIContext.tsx b/context/app/static/js/components/detailPage/Protocol/ProtocolAPIContext.tsx new file mode 100644 index 0000000000..03d6c52d28 --- /dev/null +++ b/context/app/static/js/components/detailPage/Protocol/ProtocolAPIContext.tsx @@ -0,0 +1,10 @@ +import { createContext, useContext } from 'react'; + +type ProtocolAPIContextType = { + clientId: string; + clientAuthToken: string; +}; + +export const ProtocolAPIContext = createContext(null); + +export const useProtocolAPIContext = () => useContext(ProtocolAPIContext); diff --git a/context/app/static/js/components/publications/PublicationVignette/hooks.ts b/context/app/static/js/components/publications/PublicationVignette/hooks.ts index 67ac367266..4244d572c0 100644 --- a/context/app/static/js/components/publications/PublicationVignette/hooks.ts +++ b/context/app/static/js/components/publications/PublicationVignette/hooks.ts @@ -1,6 +1,6 @@ import useSWR from 'swr'; import { useAppContext } from 'js/components/Contexts'; -import { multiFetcher } from 'js/helpers/multiFetcher'; +import { multiFetcher } from 'js/helpers/swr'; import { fillUrls } from './utils'; import { PublicationVignette } from '../types'; @@ -16,10 +16,7 @@ export function usePublicationVignetteConfs({ uuid, vignetteDirName, vignette }: const urls = vignette.figures?.map( ({ file }) => `${assetsEndpoint}/${uuid}/vignettes/${vignetteDirName}/${file}?token=${groupsToken}`, ); - const { data } = useSWR(urls, multiFetcher, { - revalidateOnFocus: false, - revalidateOnReconnect: false, - }); + const { data } = useSWR(urls, multiFetcher); if (data) { const urlHandler = (url: string, isZarr: boolean) => { @@ -34,8 +31,8 @@ export function usePublicationVignetteConfs({ uuid, vignetteDirName, vignette }: }; }; // 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)); + // TODO: Improve this `object` type; I couldn't figure out how to import the appropriate `VitessceConfig` type from Vitessce. + const formattedData: object[] = data.map((d) => fillUrls(d as object, urlHandler, requestInitHandler)); return formattedData; } return undefined; diff --git a/context/app/static/js/helpers/multiFetcher.ts b/context/app/static/js/helpers/multiFetcher.ts deleted file mode 100644 index e12f94db6a..0000000000 --- a/context/app/static/js/helpers/multiFetcher.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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/app/static/js/helpers/swr.ts b/context/app/static/js/helpers/swr.ts new file mode 100644 index 0000000000..da171bba08 --- /dev/null +++ b/context/app/static/js/helpers/swr.ts @@ -0,0 +1,32 @@ +/** + * SWR fetcher which accepts an array of URLs and returns the responses as JSON + * A custom requestInit object can be passed to fetch as well. + * @example // without requestInit + * const { data } = useSWR(urls, multiFetcher); + * @example // with requestInit + * const { data } = useSWR({ urls, token }, ({ urls, token }) => multiFetcher(urls, { headers: { Authorization: `Bearer ${token}` } }) + * @param urls - Array of URLs to fetch + * @param requestInit - Optional RequestInit object to pass to fetch + */ + +export async function multiFetcher(urls: string[], requestInit: RequestInit = {}) { + const f = (url: string) => fetch(url, requestInit).then((response) => response.json()); + if (urls.length === 0) { + return Promise.resolve([] as T[]); + } + return Promise.all(urls.map((url) => f(url))) as Promise; +} + +/** + * SWR fetcher which accepts a single URL and returns the response as JSON. + * A custom requestInit object can be passed to fetch as well. + * @example // without requestInit + * const { data } = useSWR(urls, multiFetcher); + * @example // with requestInit + * const { data } = useSWR({ urls, token }, ({ urls, token }) => multiFetcher(urls, { headers: { Authorization: `Bearer ${token}` } }) + * @param urls - Array of URLs to fetch + * @param requestInit - Optional RequestInit object to pass to fetch + */ +export async function fetcher(url: string, requestInit: RequestInit = {}) { + return multiFetcher([url], requestInit).then((data) => data[0]) as Promise; +} diff --git a/context/app/static/js/hooks/useProtocolData.js b/context/app/static/js/hooks/useProtocolData.js index cd3f592af2..958a96db98 100644 --- a/context/app/static/js/hooks/useProtocolData.js +++ b/context/app/static/js/hooks/useProtocolData.js @@ -1,22 +1,43 @@ -import React from 'react'; +import { useMemo } from 'react'; +import useSWR from 'swr'; -function useProtocolData(doiSuffix, lastVersion = 1) { - const [protocol, setProtocol] = React.useState({}); - React.useEffect(() => { - async function getAndSetProtocol() { - const url = `https://www.protocols.io/api/v3/protocols/${doiSuffix}?last_version=${lastVersion}`; - const response = await fetch(url); - if (!response.ok) { - console.error('Protocol API failed:', url, response); - return; - } - const data = await response.json(); - setProtocol(data); +import { fetcher } from 'js/helpers/swr'; +import { useAppContext } from 'js/components/Contexts'; + +export function useFormattedProtocolUrls(protocolUrls, lastVersion) { + return useMemo(() => { + if (protocolUrls.length === 0) { + return []; } - getAndSetProtocol(); - }, [doiSuffix, lastVersion]); + // Handle case with multiple URLs provided in one string and remove leading/trailing whitespace + // If only one string is provided, it will be returned as an array + // "dx.doi.org/10.17504/protocols.io.5qpvob93dl4o/v1, dx.doi.org/10.17504/protocols.io.dm6gpb7p5lzp/v1" -> + // ["dx.doi.org/10.17504/protocols.io.5qpvob93dl4o/v1", "dx.doi.org/10.17504/protocols.io.dm6gpb7p5lzp/v1"] + const protocols = protocolUrls.split(',').map((url) => url.trim()); + // Strip `http://` and `https://` from the beginning of the URL if it exists + // https://dx.doi.org/10.17504/protocols.io.btnfnmbn -> dx.doi.org/10.17504/protocols.io.btnfnmbn + const noHttpPrefix = protocols.map((url) => url.replace(/^(?:https?:\/\/)?/i, '')); + // Strip `dx.doi.org/` from the beginning of the URL if it exists + // dx.doi.org/10.17504/protocols.io.btnfnmbn -> 10.17504/protocols.io.btnfnmbn + const noDomainPrefix = noHttpPrefix.map((url) => url.replace(/^dx\.doi\.org\//i, '')); + // Strip version number from end of the URL if it exists + // 10.17504/protocols.io.btnfnmbn/v1 -> 10.17504/protocols.io.btnfnmbn + const noVersionSuffix = noDomainPrefix.map((url) => url.replace(/\/v\d+$/, '')); + // Format into the API call URL + // 10.17504/protocols.io.btnfnmbn -> https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.btnfnmbn?last_version=1 + const formattedUrls = noVersionSuffix.map( + (doi) => `https://www.protocols.io/api/v4/protocols/${doi}?last_version=${lastVersion}`, + ); + return formattedUrls; + }, [protocolUrls, lastVersion]); +} - return protocol; +function useProtocolData(protocolUrl) { + const { protocolsClientToken } = useAppContext(); + const result = useSWR([protocolUrl, protocolsClientToken], ([url, token]) => + fetcher(url, { headers: { Authorization: `Bearer ${token}` } }), + ); + return result; } export default useProtocolData; diff --git a/context/app/static/js/hooks/useProtocolData.spec.js b/context/app/static/js/hooks/useProtocolData.spec.js new file mode 100644 index 0000000000..d0a8d8720e --- /dev/null +++ b/context/app/static/js/hooks/useProtocolData.spec.js @@ -0,0 +1,83 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { useFormattedProtocolUrls } from './useProtocolData'; + +const getResult = (protocols, lastVersion) => { + const { result } = renderHook(({ urls, version }) => useFormattedProtocolUrls(urls, version), { + initialProps: { urls: protocols, version: lastVersion }, + }); + return result.current; +}; + +describe('useFormattedProtocolUrls', () => { + it('should format a single URL with no version number', () => { + const protocolUrls = 'https://dx.doi.org/10.17504/protocols.io.btnfnmbn'; + const lastVersion = 1; + const result = getResult(protocolUrls, lastVersion); + expect(result).toEqual(['https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.btnfnmbn?last_version=1']); + }); + + it('should format multiple URLs with version numbers', () => { + const protocolUrls = + 'https://dx.doi.org/10.17504/protocols.io.btnfnmbn/v1, https://dx.doi.org/10.17504/protocols.io.7d5h6en/v2'; + const lastVersion = 1; + const result = getResult(protocolUrls, lastVersion); + expect(result).toEqual([ + 'https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.btnfnmbn?last_version=1', + 'https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.7d5h6en?last_version=1', + ]); + }); + + it('should handle URLs with http:// prefix', () => { + const protocolUrls = 'http://dx.doi.org/10.17504/protocols.io.btnfnmbn'; + const lastVersion = 1; + const result = getResult(protocolUrls, lastVersion); + expect(result).toEqual(['https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.btnfnmbn?last_version=1']); + }); + + it('should handle URLs with https:// prefix', () => { + const protocolUrls = 'https://dx.doi.org/10.17504/protocols.io.btnfnmbn'; + const lastVersion = 1; + const result = getResult(protocolUrls, lastVersion); + expect(result).toEqual(['https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.btnfnmbn?last_version=1']); + }); + + it('should handle URLs with dx.doi.org/ prefix', () => { + const protocolUrls = 'dx.doi.org/10.17504/protocols.io.btnfnmbn'; + const lastVersion = 1; + const result = getResult(protocolUrls, lastVersion); + expect(result).toEqual(['https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.btnfnmbn?last_version=1']); + }); + + it('should handle URLs with multiple prefixes', () => { + const protocolUrls = + 'https://dx.doi.org/10.17504/protocols.io.btnfnmbn/v1,http://dx.doi.org/10.17504/protocols.io.7d5h6en/v2'; + const lastVersion = 1; + const result = getResult(protocolUrls, lastVersion); + expect(result).toEqual([ + 'https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.btnfnmbn?last_version=1', + 'https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.7d5h6en?last_version=1', + ]); + }); + + it('should handle URLs with no http or https prefix', () => { + const protocolUrls = 'dx.doi.org/10.17504/protocols.io.btnfnmbn/v1'; + const lastVersion = 1; + const result = getResult(protocolUrls, lastVersion); + expect(result).toEqual(['https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.btnfnmbn?last_version=1']); + }); + + it('should handle URLs with no version number', () => { + const protocolUrls = 'https://dx.doi.org/10.17504/protocols.io.btnfnmbn'; + const lastVersion = 2; + const result = getResult(protocolUrls, lastVersion); + expect(result).toEqual(['https://www.protocols.io/api/v4/protocols/10.17504/protocols.io.btnfnmbn?last_version=2']); + }); + + it('should handle empty input', () => { + const protocolUrls = ''; + const lastVersion = 1; + const result = getResult(protocolUrls, lastVersion); + expect(result).toEqual([]); + }); +}); diff --git a/context/app/utils.py b/context/app/utils.py index 405820f5c7..b03bb5fb33 100644 --- a/context/app/utils.py +++ b/context/app/utils.py @@ -34,6 +34,8 @@ def get_default_flask_data(): 'xmodalityEndpoint': current_app.config['XMODALITY_ENDPOINT'], 'workspacesEndpoint': current_app.config['WORKSPACES_ENDPOINT'], 'workspacesWsEndpoint': current_app.config['WORKSPACES_WS_ENDPOINT'], + 'protocolsClientId': current_app.config['PROTOCOLS_IO_CLIENT_ID'], + 'protocolsClientToken': current_app.config['PROTOCOLS_IO_CLIENT_AUTH_TOKEN'], }, 'globalAlertMd': current_app.config.get('GLOBAL_ALERT_MD') } diff --git a/example-app.conf b/example-app.conf index 27ef790580..2f27374d84 100644 --- a/example-app.conf +++ b/example-app.conf @@ -8,6 +8,11 @@ SECRET_KEY = 'abc123!' APP_CLIENT_ID = 'TODO' APP_CLIENT_SECRET = 'TODO' +PROTOCOLS_IO_CLIENT_ID = 'TODO' +PROTOCOLS_IO_CLIENT_SECRET = 'TODO' +PROTOCOLS_IO_CLIENT_AUTH_TOKEN = 'TODO' + + # If the API is not available, uncomment "IS_MOCK"; # Restart is required for it to take effect. # IS_MOCK = True