diff --git a/static-site/src/components/ListResources/IndividualResource.tsx b/static-site/src/components/ListResources/IndividualResource.tsx index 3e7d276ae..91db2798a 100644 --- a/static-site/src/components/ListResources/IndividualResource.tsx +++ b/static-site/src/components/ListResources/IndividualResource.tsx @@ -86,7 +86,7 @@ export function TooltipWrapper({description, children}) { {children} ) -} +} interface IconContainerProps { Icon: IconType @@ -144,7 +144,7 @@ export const IndividualResource = ({resource, isMobile}: IndividualResourceProps // add history if mobile and resource has version info let history: React.JSX.Element | null = null - if (!isMobile && resource.updateCadence && resource.nVersions) { + if (!isMobile && resource.updateCadence && resource.nVersions && resource.lastUpdated) { history = ( Last known update on ${resource.lastUpdated}` + @@ -158,12 +158,14 @@ export const IndividualResource = ({resource, isMobile}: IndividualResourceProps ) } + const description = resource.lastUpdated ? `Last known update on ${resource.lastUpdated}` : ""; + return ( - + setModalResource(resource)}> @@ -192,5 +194,5 @@ export const ResourceLinkWrapper = ({children, onShiftClick}) => {
{children}
- ) + ) } diff --git a/static-site/src/components/ListResources/ResourceGroup.tsx b/static-site/src/components/ListResources/ResourceGroup.tsx index 82a244ae6..b0a801af4 100644 --- a/static-site/src/components/ListResources/ResourceGroup.tsx +++ b/static-site/src/components/ListResources/ResourceGroup.tsx @@ -31,7 +31,7 @@ const ResourceGroupHeader = ({group, isMobile, setCollapsed, collapsible, isColl - + {group.groupUrl ? ( @@ -49,7 +49,7 @@ const ResourceGroupHeader = ({group, isMobile, setCollapsed, collapsible, isColl (however there may have been a more recent update since we indexed the data)'}> - {`Most recent snapshot: ${group.lastUpdated}`} + {group.lastUpdated && `Most recent snapshot: ${group.lastUpdated}`} )} @@ -164,7 +164,7 @@ const ResourceGroupContainer = styled.div` `; const IndividualResourceContainer = styled.div<{$maxResourceWidth: number}>` - /* Columns are a simple CSS solution which works really well _if_ we can calculate the expected maximum + /* Columns are a simple CSS solution which works really well _if_ we can calculate the expected maximum resource width */ column-width: ${(props) => props.$maxResourceWidth}px; column-gap: 20px; @@ -239,7 +239,7 @@ function NextstrainLogo() { /** * Adds the `displayName` property to each resource. * Given successive nameParts: - * [ seasonal-flu, h1n1pdm] + * [ seasonal-flu, h1n1pdm] * [ seasonal-flu, h3n2] * We want to produce two names: a default name, which contains all parts, * and a displayName which hides the fields that match the preceding name. @@ -255,7 +255,7 @@ function _setDisplayName(resources: Resource[]) { name = r.nameParts.join(sep); } else { let matchIdx = r.nameParts.map((word, j) => word === resources[i-1]?.nameParts[j]).findIndex((v) => !v); - if (matchIdx===-1) { // -1 means every word is in the preceding name, but we should display the last word anyway + if (matchIdx===-1) { // -1 means every word is in the preceding name, but we should display the last word anyway matchIdx = r.nameParts.length-2; } name = r.nameParts.map((word, j) => j < matchIdx ? ' '.repeat(word.length) : word).join(sep); @@ -277,4 +277,4 @@ function collapseThresolds(numGroups: number) { resourcesToShowWhenCollapsed = 40; } return {collapseThreshold, resourcesToShowWhenCollapsed} -} \ No newline at end of file +} diff --git a/static-site/src/components/ListResources/index.tsx b/static-site/src/components/ListResources/index.tsx index 2db23f7da..5632abdca 100644 --- a/static-site/src/components/ListResources/index.tsx +++ b/static-site/src/components/ListResources/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect, useCallback, createContext, useContext } from 'react'; +import React, { FormEvent, useState, useRef, useEffect, useCallback, createContext, useContext } from 'react'; import styled from 'styled-components'; import Select, { MultiValue } from 'react-select'; import ScrollableAnchor, { goToAnchor } from '../../../vendored/react-scrollable-anchor/index'; @@ -12,7 +12,8 @@ import { ErrorContainer } from "../../pages/404"; import { TooltipWrapper } from "./IndividualResource"; import {ResourceModal, SetModalResourceContext} from "./Modal"; import { CardImgWrapper, CardInner, CardOuter, CardTitle, Showcase } from "../Showcase"; -import { FilterCard, FilterOption, Group, GroupDisplayNames, QuickLink, Resource } from './types'; +import { FilterCard, FilterOption, Group, GroupDisplayNames, QuickLink, Resource, ResourceListingInfo } from './types'; +import { HugeSpacer } from "../../layouts/generalComponents"; interface ListResourcesProps extends ListResourcesResponsiveProps { elWidth: number @@ -23,23 +24,29 @@ const LIST_ANCHOR = "list"; const SetSelectedFilterOptions = createContext> | null>(null); /** - * A React component to fetch data and display the available resources, - * including past versions ("snapshots"). + * A React component that uses a callback to fetch data about + * available resources, and then display them, including past versions + * ("snapshots"), if those exist. * * Note that currently this only uses 'dataset' resources. In the future this * will be expanded. Similarly, we define versioned: boolean here in the UI whereas * this may be better expressed as a property of the API response. */ function ListResources({ - sourceId, versioned=true, elWidth, quickLinks, defaultGroupLinks=false, groupDisplayNames, showcase, + resourceListingCallback: resourceListingCallback, }: ListResourcesProps) { - const {groups, dataFetchError} = useDataFetch(sourceId, versioned, defaultGroupLinks, groupDisplayNames); + const {groups, dataFetchError} = useDataFetch( + versioned, + defaultGroupLinks, + groupDisplayNames, + resourceListingCallback, + ); const showcaseCards = useShowcaseCards(showcase, groups); const [selectedFilterOptions, setSelectedFilterOptions] = useState([]); const [sortMethod, changeSortMethod] = useState("alphabetical"); @@ -50,7 +57,7 @@ function ListResources({ if (dataFetchError) { return ( - + {"Whoops - listing resources isn't working!"}
{'Please '}get in touch{" if this keeps happening"} @@ -69,17 +76,25 @@ function ListResources({ return ( - - Showcase resources: click to filter the resources to a pathogen - + { showcaseCards.length > 0 && ( + <> + + Showcase resources: click to filter the resources to a pathogen + - - - + + + + + )} - + { groups?.[0]?.lastUpdated && ( + + ) || ( + + )} @@ -108,7 +123,6 @@ function ListResources({ interface ListResourcesResponsiveProps { - sourceId: string versioned: boolean quickLinks: QuickLink[] @@ -116,6 +130,7 @@ interface ListResourcesResponsiveProps { defaultGroupLinks: boolean groupDisplayNames: GroupDisplayNames showcase: FilterCard[] + resourceListingCallback: () => Promise; } /** @@ -155,12 +170,12 @@ export default ListResourcesResponsive function SortOptions({sortMethod, changeSortMethod}) { - function onChangeValue(event) { - changeSortMethod(event.target.value); + function onChangeValue(event:FormEvent): void { + changeSortMethod(event.currentTarget.value); } return ( - Sort pathogens by: + Sort pathogens by: display name */ diff --git a/static-site/src/components/ListResources/useDataFetch.ts b/static-site/src/components/ListResources/useDataFetch.ts index ef68e585e..b5eccdfb3 100644 --- a/static-site/src/components/ListResources/useDataFetch.ts +++ b/static-site/src/components/ListResources/useDataFetch.ts @@ -1,53 +1,52 @@ import { useState, useEffect } from 'react'; -import { Group, GroupDisplayNames, PathVersions, Resource } from './types'; +import { Group, GroupDisplayNames, PathVersions, Resource, ResourceListingInfo } from './types'; /** - * Fetches the datasets for the provided `sourceId` and parses the data into an - * array of groups, each representing a "pathogen" and detailing the available - * resources for each, and the available versions (snapshots) for each of those - * resources. + * Uses the provided callback to fetch resources and parse those + * resources into the `pathVersions` and `pathPrefix` values. The + * callback is expected (and encouraged!) to throw() on any errors. * - * The current implementation defines `versioned: boolean` for the entire API - * response, however in the future we may shift this to the API response and it - * may vary across the resources returned. + * Continues on to parse the `pathVersions`/`pathPrefix` data + * structures into an array of groups, each representing a "pathogen" + * and detailing the available resources for each, and the available + * versions (snapshots) for each of those resources. In the case of + * un-versioned resources, versions will be a zero-length array (i.e., + * `[]`) + * + * The current implementation defines `versioned: boolean` for the + * entire API response, however in the future we may shift this to the + * API response and it may vary across the resources returned. */ -export function useDataFetch(sourceId: string, versioned: boolean, defaultGroupLinks: boolean, groupDisplayNames: GroupDisplayNames) { +export function useDataFetch( + versioned: boolean, + defaultGroupLinks: boolean, + groupDisplayNames: GroupDisplayNames, + resourceListingCallback: () => Promise, +) : {groups: Group[] | undefined, dataFetchError: boolean} { const [groups, setGroups] = useState(); - const [dataFetchError, setDataFetchError] = useState(); - useEffect(() => { - const url = `/list-resources/${sourceId}`; + const [dataFetchError, setDataFetchError] = useState(false); - async function fetchAndParse() { - let pathVersions: PathVersions, pathPrefix: string; + useEffect((): void => { + async function fetchAndParse(): Promise { try { - const response = await fetch(url, {headers: {accept: "application/json"}}); - if (response.status !== 200) { - console.error(`ERROR: fetching data from "${url}" returned status code ${response.status}`); - return setDataFetchError(true); - } - ({ pathVersions, pathPrefix } = (await response.json()).dataset[sourceId]); - } catch (err) { - console.error(`Error while fetching data from "${url}"`); - console.error(err); - return setDataFetchError(true); - } + const { pathPrefix, pathVersions } = await resourceListingCallback(); - /* group/partition the resources by pathogen (the first word of the - resource path). This grouping is constant for all UI options so we do it a - single time following the data fetch */ - try { + /* group/partition the resources by pathogen (the first word + of the resource path). This grouping is constant for all UI + options so we do it a single time following the data fetch */ const partitions = partitionByPathogen(pathVersions, pathPrefix, versioned); setGroups(groupsFrom(partitions, pathPrefix, defaultGroupLinks, groupDisplayNames)); } catch (err) { - console.error(`Error while parsing fetched data`); + console.error(`Error while fetching and/or parsing data`); console.error(err); return setDataFetchError(true); } } fetchAndParse(); - }, [sourceId, versioned, defaultGroupLinks, groupDisplayNames]); + }, [versioned, defaultGroupLinks, groupDisplayNames, resourceListingCallback]); + return {groups, dataFetchError} } @@ -60,15 +59,12 @@ interface Partitions { /** * Groups the provided array of pathVersions into an object with keys * representing group names (pathogen names) and values which are arrays of - * resource objects. + * resource objects. */ function partitionByPathogen(pathVersions: PathVersions, pathPrefix: string, versioned: boolean) { return Object.entries(pathVersions).reduce((store: Partitions, [name, dates]) => { const sortedDates = [...dates].sort(); - // do nothing if resource has no dates - if (sortedDates.length < 1) return store - const nameParts = name.split('/'); // split() will always return at least 1 string const groupName = nameParts[0]!; @@ -79,11 +75,10 @@ function partitionByPathogen(pathVersions: PathVersions, pathPrefix: string, ver nameParts, sortingName: _sortableName(nameParts), url: `/${pathPrefix}${name}`, - lastUpdated: sortedDates.at(-1)!, + lastUpdated: sortedDates.at(-1), }; if (versioned) { resourceDetails.firstUpdated = sortedDates[0]!; - resourceDetails.lastUpdated = sortedDates.at(-1)!; resourceDetails.dates = sortedDates; resourceDetails.nVersions = sortedDates.length; resourceDetails.updateCadence = updateCadence(sortedDates.map((date)=> new Date(date))); @@ -187,4 +182,4 @@ function updateCadence(dateObjects) { return {summary: "monthly", description}; } return {summary: "rarely", description}; -} \ No newline at end of file +} diff --git a/static-site/src/pages/groups.jsx b/static-site/src/pages/groups.jsx index 1db066d54..7b3000a33 100644 --- a/static-site/src/pages/groups.jsx +++ b/static-site/src/pages/groups.jsx @@ -4,23 +4,44 @@ import { SmallSpacer, HugeSpacer, FlexCenter } from "../layouts/generalComponent import * as splashStyles from "../components/splash/styles"; import { fetchAndParseJSON } from "../util/datasetsHelpers"; import DatasetSelect from "../components/Datasets/dataset-select"; +import ListResources from "../components/ListResources/index"; import { GroupCards } from "../components/splash/groupCards"; import GenericPage from "../layouts/generic-page"; import { UserContext } from "../layouts/userDataWrapper"; import { DataFetchErrorParagraph } from "../components/splash/errorMessages"; import { groupsTitle, GroupsAbstract } from "../../data/SiteConfig"; -const datasetColumns = ({isNarrative}) => [ +const resourceListingCallback = async () => { + const sourceUrl = "/charon/getAvailable?prefix=/groups/"; + + const response = await fetch(sourceUrl); + if (response.status !== 200) { + throw new Error(`fetching data from "${sourceUrl}" returned status code ${response.status}`); + } + + const datasets = (await response.json()).datasets; + // Use an empty array as the value side, to indicate that there are + // no dated versions associated with this data + const pathVersions = Object.assign( + ...datasets.map((ds) => ({ + [ds.request.replace(/^\/groups\//, "")]: [] + })), + ); + + return { pathPrefix: "groups/", pathVersions }; +}; + +const datasetColumns = [ { - name: isNarrative ? "Narrative" : "Dataset", + name: "Narrative", value: (d) => d.request.replace("/groups/", "").replace(/\//g, " / "), - url: (d) => d.url + url: (d) => d.url, }, { name: "Group Name", value: (d) => d.request.split("/")[2], - url: (d) => `/groups/${d.request.split("/")[2]}` - } + url: (d) => `/groups/${d.request.split("/")[2]}`, + }, ]; const GroupListingInfo = () => { @@ -92,31 +113,21 @@ class GroupsPage extends React.Component { - - Available Datasets - - - - {`Note that this listing is refreshed every ~6 hours. - To see a current listing, visit the page for that group by clicking on that group's tile, above.`} - - - - {this.state.dataLoaded && ( - - )} + + + Available Narratives + {this.state.dataLoaded && ( + columns={datasetColumns}/> )} { this.state.errorFetchingData && } diff --git a/static-site/src/sections/pathogens.jsx b/static-site/src/sections/pathogens.jsx index 90feae2b6..e610077a2 100644 --- a/static-site/src/sections/pathogens.jsx +++ b/static-site/src/sections/pathogens.jsx @@ -21,6 +21,18 @@ const abstract = ( ); +const resourceListingCallback = async () => { + const sourceId = "core" + const sourceUrl = `list-resources/${sourceId}`; + + const response = await fetch(sourceUrl, {headers: {accept: "application/json"}}); + if (response.status !== 200) { + throw new Error(`fetching data from "${sourceUrl}" returned status code ${response.status}`); + } + + return (await response.json()).dataset[sourceId]; +}; + class Index extends React.Component { render() { return ( @@ -35,9 +47,13 @@ class Index extends React.Component { - + quickLinks={coreQuickLinks} defaultGroupLinks + groupDisplayNames={coreGroupDisplayNames} + resourceListingCallback={resourceListingCallback}/> + ); diff --git a/static-site/src/sections/staging-page.jsx b/static-site/src/sections/staging-page.jsx index 1b87b68f4..c35915003 100644 --- a/static-site/src/sections/staging-page.jsx +++ b/static-site/src/sections/staging-page.jsx @@ -18,6 +18,18 @@ const abstract = ( ); +const resourceListingCallback = async () => { + const sourceId = "staging" + const sourceUrl = `list-resources/${sourceId}`; + + const response = await fetch(sourceUrl, {headers: {accept: "application/json"}}); + if (response.status !== 200) { + throw new Error(`fetching data from "${sourceUrl}" returned status code ${response.status}`); + } + + return (await response.json()).dataset[sourceId]; +}; + class Index extends React.Component { constructor(props) { super(props); @@ -30,7 +42,7 @@ class Index extends React.Component { We update state which results in an error banner being shown. */ if (!this.state.resourcePath && this.props.router.query?.staging) { this.setState({resourcePath: "staging/"+this.props.router.query.staging.join("/")}) - } + } } componentDidMount() {this.checkRouterParams()} @@ -62,7 +74,8 @@ class Index extends React.Component { - +