Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace Datasets DatasetSelect component on groups page with ListResources [#870] #904

Merged
merged 4 commits into from
Jun 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
genehack marked this conversation as resolved.
Show resolved Hide resolved
}
return (
<SortContainer>
Sort pathogens by:
Sort pathogens by:
genehack marked this conversation as resolved.
Show resolved Hide resolved
<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>,
victorlin marked this conversation as resolved.
Show resolved Hide resolved
) : {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