diff --git a/static-site/src/components/ListResources/IndividualResource.tsx b/static-site/src/components/ListResources/IndividualResource.tsx index 2a1136dd3..2e1ad18f6 100644 --- a/static-site/src/components/ListResources/IndividualResource.tsx +++ b/static-site/src/components/ListResources/IndividualResource.tsx @@ -3,8 +3,9 @@ import React, {useState, useRef, useEffect, useContext} from 'react'; import styled from 'styled-components'; import { MdHistory } from "react-icons/md"; import { SetModalResourceContext } from './Modal'; -import { ResourceDisplayName, Resource } from './types'; +import { ResourceDisplayName, Resource, DisplayNamedResource } from './types'; import { IconType } from 'react-icons'; +import { InternalError } from './errors'; export const LINK_COLOR = '#5097BA' export const LINK_HOVER_COLOR = '#31586c' @@ -19,10 +20,8 @@ export const LINK_HOVER_COLOR = '#31586c' const [resourceFontSize, namePxPerChar, summaryPxPerChar] = [16, 10, 9]; const iconWidth = 20; // not including text const gapSize = 10; -export const getMaxResourceWidth = (displayResources: Resource[]) => { +export const getMaxResourceWidth = (displayResources: DisplayNamedResource[]) => { return displayResources.reduce((w, r) => { - if (!r.displayName) return w - /* add the pixels for the display name */ let _w = r.displayName.default.length * namePxPerChar; if (r.nVersions && r.updateCadence) { @@ -129,15 +128,16 @@ export const IndividualResource = ({ isMobile: boolean }) => { const setModalResource = useContext(SetModalResourceContext); - if (!setModalResource) throw new Error("Context not provided!") + if (!setModalResource) throw new InternalError("Context not provided!") const ref = useRef(null); const [topOfColumn, setTopOfColumn] = useState(false); useEffect(() => { - // don't do anything if the ref is undefined or the parent is not a div (IndividualResourceContainer) - if (!ref.current - || !ref.current.parentNode - || ref.current.parentNode.nodeName != 'DIV') return; + if (ref.current === null || + ref.current.parentNode === null || + ref.current.parentNode.nodeName != 'DIV') { + throw new InternalError("ref must be defined and the parent must be a div (IndividualResourceContainer)."); + } /* The column CSS is great but doesn't allow us to know if an element is at the top of its column, so we resort to JS */ diff --git a/static-site/src/components/ListResources/Modal.tsx b/static-site/src/components/ListResources/Modal.tsx index 585b84515..e94bca1fb 100644 --- a/static-site/src/components/ListResources/Modal.tsx +++ b/static-site/src/components/ListResources/Modal.tsx @@ -4,7 +4,8 @@ import styled from 'styled-components'; import * as d3 from "d3"; import { MdClose } from "react-icons/md"; import { dodge } from "./dodge"; -import { Resource } from './types'; +import { Resource, VersionedResource } from './types'; +import { InternalError } from './errors'; export const SetModalResourceContext = createContext> | null>(null); @@ -16,7 +17,7 @@ export const ResourceModal = ({ resource, dismissModal, }: { - resource?: Resource + resource: VersionedResource dismissModal: () => void }) => { const [ref, setRef] = useState(null); @@ -42,9 +43,6 @@ export const ResourceModal = ({ _draw(ref, resource) }, [ref, resource]) - // modal is only applicable for versioned resources - if (!resource || !resource.dates || !resource.updateCadence) return null; - const summary = _snapshotSummary(resource.dates); return (
@@ -134,28 +132,30 @@ const Title = styled.div` function _snapshotSummary(dates: string[]) { const d = [...dates].sort() - if (d.length < 1) throw new Error("Missing dates.") - - const d1 = new Date(d.at( 0)!).getTime(); - const d2 = new Date(d.at(-1)!).getTime(); - const days = (d2 - d1)/1000/60/60/24; + const d1 = d[0]; + const d2 = d.at(-1); + if (d1 === undefined || d2 === undefined) { + throw new InternalError("Missing dates."); + } + const days = (new Date(d2).getTime() - new Date(d1).getTime())/1000/60/60/24; let duration = ''; if (days < 100) duration=`${days} days`; else if (days < 365*2) duration=`${Math.round(days/(365/12))} months`; else duration=`${Math.round(days/365)} years`; - return {duration, first: d[0], last:d.at(-1)}; + return {duration, first: d1, last: d2}; } -function _draw(ref, resource: Resource) { - // do nothing if resource has no dates - if (!resource.dates) return - +function _draw(ref, resource: VersionedResource) { /* Note that _page_ resizes by themselves will not result in this function rerunning, which isn't great, but for a modal I think it's perfectly acceptable */ const sortedDateStrings = [...resource.dates].sort(); const flatData = sortedDateStrings.map((version) => ({version, 'date': new Date(version)})); + if (flatData[0] === undefined) { + throw new InternalError("Resource does not have any dates."); + } + const width = ref.clientWidth; const graphIndent = 20; const heights = { @@ -179,8 +179,7 @@ function _draw(ref, resource: Resource) { /* Create the x-scale and draw the x-axis */ const x = d3.scaleTime() - // presence of dates on resource has already been checked so this assertion is safe - .domain([flatData[0]!.date, new Date()]) // the domain extends to the present day + .domain([flatData[0].date, new Date()]) // the domain extends to the present day .range([graphIndent, width-graphIndent]) svg.append('g') .attr("transform", `translate(0, ${heights.height - heights.marginBelowAxis})`) diff --git a/static-site/src/components/ListResources/ResourceGroup.tsx b/static-site/src/components/ListResources/ResourceGroup.tsx index f44438e43..5a9345aba 100644 --- a/static-site/src/components/ListResources/ResourceGroup.tsx +++ b/static-site/src/components/ListResources/ResourceGroup.tsx @@ -5,7 +5,8 @@ import { MdHistory, MdFormatListBulleted, MdChevronRight } from "react-icons/md" import { IndividualResource, getMaxResourceWidth, TooltipWrapper, IconContainer, ResourceLinkWrapper, ResourceLink, LINK_COLOR, LINK_HOVER_COLOR } from "./IndividualResource" import { SetModalResourceContext } from "./Modal"; -import { Group, QuickLink, Resource } from './types'; +import { DisplayNamedResource, Group, QuickLink, Resource } from './types'; +import { InternalError } from './errors'; const ResourceGroupHeader = ({ group, @@ -25,7 +26,7 @@ const ResourceGroupHeader = ({ quickLinks: QuickLink[] }) => { const setModalResource = useContext(SetModalResourceContext); - if (!setModalResource) throw new Error("Context not provided!") + if (!setModalResource) throw new InternalError("Context not provided!") /* Filter the known quick links to those which appear in resources of this group */ const resourcesByName = Object.fromEntries(group.resources.map((r) => [r.name, r])); @@ -137,8 +138,8 @@ export const ResourceGroup = ({ const {collapseThreshold, resourcesToShowWhenCollapsed} = collapseThresolds(numGroups); const collapsible = group.resources.length > collapseThreshold; const [isCollapsed, setCollapsed] = useState(collapsible); // if it is collapsible, start collapsed - const displayResources = isCollapsed ? group.resources.slice(0, resourcesToShowWhenCollapsed) : group.resources; - _setDisplayName(displayResources) + const resources = isCollapsed ? group.resources.slice(0, resourcesToShowWhenCollapsed) : group.resources; + const displayResources = _setDisplayName(resources) /* isMobile: boolean determines whether we expose snapshots, as we hide them on small screens */ const isMobile = elWidth < 500; @@ -257,20 +258,28 @@ function NextstrainLogo() { * "seasonal-flu | h1n1pdm" * " | h3n2" */ -function _setDisplayName(resources: Resource[]) { +function _setDisplayName(resources: Resource[]): DisplayNamedResource[] { const sep = "│"; // ASCII 179 - resources.forEach((r, i) => { + return resources.map((r, i) => { let name; if (i===0) { name = r.nameParts.join(sep); } else { - let matchIdx = r.nameParts.map((word, j) => word === resources[i-1]?.nameParts[j]).findIndex((v) => !v); + const previousResource = resources[i-1]; + if (previousResource === undefined) { + throw new InternalError("Previous resource is undefined. Check that this is not run on i===0."); + } + let matchIdx = r.nameParts.map((word, j) => word === previousResource.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 matchIdx = r.nameParts.length-2; } name = r.nameParts.map((word, j) => j < matchIdx ? ' '.repeat(word.length) : word).join(sep); } - r.displayName = {hovered: r.nameParts.join(sep), default: name} + + return { + ...r, + displayName: {hovered: r.nameParts.join(sep), default: name} + } }) } diff --git a/static-site/src/components/ListResources/errors.tsx b/static-site/src/components/ListResources/errors.tsx new file mode 100644 index 000000000..c4bd9eeff --- /dev/null +++ b/static-site/src/components/ListResources/errors.tsx @@ -0,0 +1,54 @@ +import React, { ErrorInfo, ReactNode } from "react"; +import { ErrorContainer } from "../../pages/404"; + +export class InternalError extends Error { + constructor(message: string) { + super(message); + } +} + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + errorMessage: string; +} + +export class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + errorMessage:"", + }; + } + + static getDerivedStateFromError(error: Error): State { + return { + hasError: true, + errorMessage: error instanceof InternalError ? error.message : "Unknown error (thrown value was not an InternalError)", + }; + } + + override componentDidCatch(error: Error, info: ErrorInfo) { + console.error(error); + console.error(info); + } + + override render() { + if (this.state.hasError) { + return ( + + {"Something isn't working!"} +
+ {`Error: ${this.state.errorMessage}`} +
+ {"Please "}get in touch{" if this keeps happening"} +
+ ) + } + return this.props.children; + } +} \ 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 da3e0f86a..4bc9a714f 100644 --- a/static-site/src/components/ListResources/index.tsx +++ b/static-site/src/components/ListResources/index.tsx @@ -12,8 +12,9 @@ import { ErrorContainer } from "../../pages/404"; import { TooltipWrapper } from "./IndividualResource"; import {ResourceModal, SetModalResourceContext} from "./Modal"; import { ExpandableTiles } from "../ExpandableTiles"; -import { FilterTile, FilterOption, Group, QuickLink, Resource, ResourceListingInfo, SortMethod } from './types'; +import { FilterTile, FilterOption, Group, QuickLink, Resource, ResourceListingInfo, SortMethod, convertVersionedResource } from './types'; import { HugeSpacer } from "../../layouts/generalComponents"; +import { ErrorBoundary } from './errors'; const LIST_ANCHOR = "list"; @@ -93,7 +94,7 @@ function ListResources({ - { groups?.[0]?.lastUpdated && ( + { versioned && ( ) || ( @@ -116,8 +117,8 @@ function ListResources({ - { versioned && ( - setModalResource(undefined)}/> + { versioned && modalResource && ( + setModalResource(undefined)}/> )} @@ -165,9 +166,11 @@ function ListResourcesResponsive(props: ListResourcesResponsiveProps) { }; }, []); return ( -
- -
+ +
+ +
+
) } diff --git a/static-site/src/components/ListResources/types.ts b/static-site/src/components/ListResources/types.ts index aae85efd2..57c8e5c64 100644 --- a/static-site/src/components/ListResources/types.ts +++ b/static-site/src/components/ListResources/types.ts @@ -1,3 +1,4 @@ +import { InternalError } from "./errors"; import { Tile } from "../ExpandableTiles/types" export interface FilterOption { @@ -10,13 +11,30 @@ export type SortMethod = "lastUpdated" | "alphabetical"; export interface Group { groupName: string nResources: number - nVersions?: number - lastUpdated: string // date + nVersions: number | undefined + lastUpdated: string | undefined resources: Resource[] groupUrl?: string groupDisplayName?: string } +export interface VersionedGroup extends Group { + nVersions: number + lastUpdated: string +} + +export function convertVersionedGroup(group: Group): VersionedGroup { + if (group.nVersions !== undefined && + group.lastUpdated !== undefined) { + return { + ...group, + nVersions: group.nVersions, + lastUpdated: group.lastUpdated, + } + } + throw new InternalError("Group is not versioned."); +} + export interface Resource { name: string displayName?: ResourceDisplayName @@ -31,6 +49,36 @@ export interface Resource { updateCadence?: UpdateCadence } +export interface DisplayNamedResource extends Resource { + displayName: ResourceDisplayName +} + +export interface VersionedResource extends Resource { + lastUpdated: string // date + firstUpdated: string // date + dates: string[] + nVersions: number + updateCadence: UpdateCadence +} + +export function convertVersionedResource(resource: Resource): VersionedResource { + if (resource.lastUpdated !== undefined && + resource.firstUpdated !== undefined && + resource.dates !== undefined && + resource.nVersions !== undefined && + resource.updateCadence !== undefined) { + return { + ...resource, + lastUpdated: resource.lastUpdated, + firstUpdated: resource.firstUpdated, + dates: resource.dates, + nVersions: resource.nVersions, + updateCadence: resource.updateCadence + } + } + throw new InternalError("Resource is not versioned."); +} + export interface ResourceDisplayName { hovered: string default: string diff --git a/static-site/src/components/ListResources/useDataFetch.ts b/static-site/src/components/ListResources/useDataFetch.ts index 270f7c81e..939aaf45f 100644 --- a/static-site/src/components/ListResources/useDataFetch.ts +++ b/static-site/src/components/ListResources/useDataFetch.ts @@ -106,7 +106,7 @@ function groupsFrom( groupName: groupName, nResources: resources.length, nVersions: resources.reduce((total, r) => r.nVersions ? total+r.nVersions : total, 0) || undefined, - lastUpdated: resources.map((r) => r.lastUpdated).sort().at(-1)!, + lastUpdated: resources.map((r) => r.lastUpdated).sort().at(-1), resources, } /* add optional properties */ diff --git a/static-site/src/components/ListResources/useSortAndFilter.ts b/static-site/src/components/ListResources/useSortAndFilter.ts index 81d47df35..53343c6f4 100644 --- a/static-site/src/components/ListResources/useSortAndFilter.ts +++ b/static-site/src/components/ListResources/useSortAndFilter.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { FilterOption, Group, Resource, SortMethod } from './types'; +import { convertVersionedGroup, FilterOption, Group, Resource, SortMethod, VersionedGroup } from './types'; export const useSortAndFilter = ( @@ -13,28 +13,6 @@ export const useSortAndFilter = ( /* Following console log is really useful for development */ // console.log(`useSortAndFilter() sortMethod "${sortMethod}" ` + (selectedFilterOptions.length ? `filtering to ${selectedFilterOptions.map((el) => el.value).join(", ")}` : '(no filtering)')) - let _sortGroups: (groupA: Group, groupB: Group) => 1 | -1 | 0, - _sortResources: (a: Resource, b: Resource) => 1 | -1 | 0; - switch (sortMethod) { - case "lastUpdated": - _sortGroups = (groupA: Group, groupB: Group) => _newestFirstSort(groupA.lastUpdated, groupB.lastUpdated); - _sortResources = (a: Resource, b: Resource) => { - if (!a.lastUpdated || !b.lastUpdated || a.lastUpdated === b.lastUpdated) { - // resources updated on the same day or without a last updated date - // sort alphabetically - return _lexicographicSort(a.name, b.name) - } - else { - return _newestFirstSort(a.lastUpdated, b.lastUpdated); - } - } - break; - case "alphabetical": - _sortGroups = (groupA: Group, groupB: Group) => _lexicographicSort(groupA.groupName.toLowerCase(), groupB.groupName.toLowerCase()), - _sortResources = (a: Resource, b: Resource) => _lexicographicSort(a.name, b.name) - break; - } - const searchValues = selectedFilterOptions.map((o) => o.value); function _filterResources(resource: Resource) { if (searchValues.length===0) return true; @@ -43,17 +21,44 @@ export const useSortAndFilter = ( .every((x) => x); } - const resourceGroups = originalData - .map((group) => ({ - ...group, - resources: group.resources - .filter(_filterResources) - .sort(_sortResources) - })) - .filter((group) => !!group.resources.length) - .sort(_sortGroups); + function sortAndFilter( + groups: T[], + sortGroups: (a: T, b: T) => number, + sortResources: (a: Resource, b: Resource) => number, + ): T[] { + return groups + .map((group) => ({ + ...group, + resources: group.resources + .filter(_filterResources) + .sort(sortResources) + })) + .filter((group) => !!group.resources.length) + .sort(sortGroups); + } - setState(resourceGroups); + if (sortMethod === "lastUpdated") { + const groups = originalData.map((group: Group) => convertVersionedGroup(group)) + const _sortGroups = (groupA: VersionedGroup, groupB: VersionedGroup) => _newestFirstSort(groupA.lastUpdated, groupB.lastUpdated) + const _sortResources = (a: Resource, b: Resource) => { + if (!a.lastUpdated || !b.lastUpdated || a.lastUpdated === b.lastUpdated) { + // resources updated on the same day or without a last updated date + // sort alphabetically + return _lexicographicSort(a.name, b.name) + } + else { + return _newestFirstSort(a.lastUpdated, b.lastUpdated); + } + } + const resourceGroups = sortAndFilter(groups, _sortGroups, _sortResources) + setState(resourceGroups) + } else if (sortMethod === "alphabetical") { + const groups = originalData; + const _sortGroups = (groupA: Group, groupB: Group) => _lexicographicSort(groupA.groupName.toLowerCase(), groupB.groupName.toLowerCase()) + const _sortResources = (a: Resource, b: Resource) => _lexicographicSort(a.name, b.name) + const resourceGroups = sortAndFilter(groups, _sortGroups, _sortResources) + setState(resourceGroups) + } }, [sortMethod, selectedFilterOptions, originalData, setState]) }