Skip to content

Commit

Permalink
Merge pull request #904 from nextstrain/group-resource-list-870
Browse files Browse the repository at this point in the history
Replace Datasets DatasetSelect component on groups page with ListResources [#870]
  • Loading branch information
genehack authored Jun 14, 2024
2 parents e70f530 + 078e8bf commit 65c8eb0
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 94 deletions.
10 changes: 6 additions & 4 deletions static-site/src/components/ListResources/IndividualResource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export function TooltipWrapper({description, children}) {
{children}
</div>
)
}
}

interface IconContainerProps {
Icon: IconType
Expand Down Expand Up @@ -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 = (
<TooltipWrapper description={resource.updateCadence.description +
`<br/>Last known update on ${resource.lastUpdated}` +
Expand All @@ -158,12 +158,14 @@ export const IndividualResource = ({resource, isMobile}: IndividualResourceProps
)
}

const description = resource.lastUpdated ? `Last known update on ${resource.lastUpdated}` : "";

return (
<Container ref={ref}>

<FlexRow>

<TooltipWrapper description={`Last known update on ${resource.lastUpdated}`}>
<TooltipWrapper description={description}>
<ResourceLinkWrapper onShiftClick={() => setModalResource(resource)}>
<Name displayName={resource.displayName} href={resource.url} topOfColumn={topOfColumn}/>
</ResourceLinkWrapper>
Expand Down Expand Up @@ -192,5 +194,5 @@ export const ResourceLinkWrapper = ({children, onShiftClick}) => {
<div onClick={onClick}>
{children}
</div>
)
)
}
12 changes: 6 additions & 6 deletions static-site/src/components/ListResources/ResourceGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const ResourceGroupHeader = ({group, isMobile, setCollapsed, collapsible, isColl
<NextstrainLogo/>

<FlexColumnContainer>

<HeaderRow>
{group.groupUrl ? (
<TooltipWrapper description={`Click to load the default (and most recent) analysis for ${group.groupDisplayName || group.groupName}`}>
Expand All @@ -49,7 +49,7 @@ const ResourceGroupHeader = ({group, isMobile, setCollapsed, collapsible, isColl
<TooltipWrapper description={`The most recently updated datasets in this group were last updated on ${group.lastUpdated}` +
'<br/>(however there may have been a more recent update since we indexed the data)'}>
<span>
{`Most recent snapshot: ${group.lastUpdated}`}
{group.lastUpdated && `Most recent snapshot: ${group.lastUpdated}`}
</span>
</TooltipWrapper>
)}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand All @@ -277,4 +277,4 @@ function collapseThresolds(numGroups: number) {
resourcesToShowWhenCollapsed = 40;
}
return {collapseThreshold, resourcesToShowWhenCollapsed}
}
}
51 changes: 33 additions & 18 deletions static-site/src/components/ListResources/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -23,23 +24,29 @@ const LIST_ANCHOR = "list";
const SetSelectedFilterOptions = createContext<React.Dispatch<React.SetStateAction<readonly FilterOption[]>> | 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<readonly FilterOption[]>([]);
const [sortMethod, changeSortMethod] = useState("alphabetical");
Expand All @@ -50,7 +57,7 @@ function ListResources({

if (dataFetchError) {
return (
<ErrorContainer>
<ErrorContainer>
{"Whoops - listing resources isn't working!"}
<br/>
{'Please '}<a href="/contact" style={{fontWeight: 300}}>get in touch</a>{" if this keeps happening"}
Expand All @@ -69,17 +76,25 @@ function ListResources({
return (
<ListResourcesContainer>

<Byline>
Showcase resources: click to filter the resources to a pathogen
</Byline>
{ showcaseCards.length > 0 && (
<>
<Byline>
Showcase resources: click to filter the resources to a pathogen
</Byline>

<SetSelectedFilterOptions.Provider value={setSelectedFilterOptions}>
<Showcase cards={showcaseCards} CardComponent={FilterShowcaseTile} />
</SetSelectedFilterOptions.Provider>
<SetSelectedFilterOptions.Provider value={setSelectedFilterOptions}>
<Showcase cards={showcaseCards} CardComponent={FilterShowcaseTile} />
</SetSelectedFilterOptions.Provider>
</>
)}

<Filter options={availableFilterOptions} selectedFilterOptions={selectedFilterOptions} setSelectedFilterOptions={setSelectedFilterOptions}/>

<SortOptions sortMethod={sortMethod} changeSortMethod={changeSortMethod}/>
{ groups?.[0]?.lastUpdated && (
<SortOptions sortMethod={sortMethod} changeSortMethod={changeSortMethod}/>
) || (
<HugeSpacer/>
)}

<SetModalResourceContext.Provider value={setModalResource}>
<ScrollableAnchor id={LIST_ANCHOR}>
Expand Down Expand Up @@ -108,14 +123,14 @@ function ListResources({


interface ListResourcesResponsiveProps {
sourceId: string
versioned: boolean
quickLinks: QuickLink[]

/** Should the group name itself be a url? (which we let the server redirect) */
defaultGroupLinks: boolean
groupDisplayNames: GroupDisplayNames
showcase: FilterCard[]
resourceListingCallback: () => Promise<ResourceListingInfo>;
}

/**
Expand Down Expand Up @@ -155,12 +170,12 @@ export default ListResourcesResponsive


function SortOptions({sortMethod, changeSortMethod}) {
function onChangeValue(event) {
changeSortMethod(event.target.value);
function onChangeValue(event:FormEvent<HTMLInputElement>): void {
changeSortMethod(event.currentTarget.value);
}
return (
<SortContainer>
Sort pathogens by:
Sort pathogens by:
<input id="alphabetical" type="radio" onChange={onChangeValue} value="alphabetical"
checked={"alphabetical"===sortMethod} style={{cursor: "alphabetical"===sortMethod ? 'default' : 'pointer'}}/>
<TooltipWrapper description={'Pathogen groups ordered alphabetically. ' +
Expand Down
7 changes: 6 additions & 1 deletion static-site/src/components/ListResources/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface Resource {
nameParts: string[]
sortingName: string
url: string
lastUpdated: string // date
lastUpdated?: string // date
firstUpdated?: string // date
dates?: string[]
nVersions?: number
Expand All @@ -34,6 +34,11 @@ export interface ResourceDisplayName {
default: string
}

export interface ResourceListingInfo {
pathPrefix: string
pathVersions: PathVersions
}

/**
* Mapping from group name -> display name
*/
Expand Down
71 changes: 33 additions & 38 deletions static-site/src/components/ListResources/useDataFetch.ts
Original file line number Diff line number Diff line change
@@ -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<ResourceListingInfo>,
) : {groups: Group[] | undefined, dataFetchError: boolean} {
const [groups, setGroups] = useState<Group[]>();
const [dataFetchError, setDataFetchError] = useState<boolean>();
useEffect(() => {
const url = `/list-resources/${sourceId}`;
const [dataFetchError, setDataFetchError] = useState<boolean>(false);

async function fetchAndParse() {
let pathVersions: PathVersions, pathPrefix: string;
useEffect((): void => {
async function fetchAndParse(): Promise<void> {
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}
}

Expand All @@ -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]!;
Expand All @@ -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)));
Expand Down Expand Up @@ -187,4 +182,4 @@ function updateCadence(dateObjects) {
return {summary: "monthly", description};
}
return {summary: "rarely", description};
}
}
Loading

0 comments on commit 65c8eb0

Please sign in to comment.