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

Improve type narrowing and error handling #1073

Merged
merged 10 commits into from
Nov 19, 2024
18 changes: 9 additions & 9 deletions static-site/src/components/ListResources/IndividualResource.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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) {
Expand Down Expand Up @@ -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<HTMLDivElement>(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') {
victorlin marked this conversation as resolved.
Show resolved Hide resolved
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 */
Expand Down
33 changes: 16 additions & 17 deletions static-site/src/components/ListResources/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.Dispatch<React.SetStateAction<Resource | undefined>> | null>(null);

Expand All @@ -16,7 +17,7 @@ export const ResourceModal = ({
resource,
dismissModal,
}: {
resource?: Resource
resource: VersionedResource
dismissModal: () => void
}) => {
const [ref, setRef] = useState(null);
Expand All @@ -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 (
<div ref={scrollRef}>
Expand Down Expand Up @@ -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) {
genehack marked this conversation as resolved.
Show resolved Hide resolved
/* 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 = {
Expand All @@ -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})`)
Expand Down
25 changes: 17 additions & 8 deletions static-site/src/components/ListResources/ResourceGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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]));
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}
}
})
}

Expand Down
54 changes: 54 additions & 0 deletions static-site/src/components/ListResources/errors.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
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);
genehack marked this conversation as resolved.
Show resolved Hide resolved
}

override render() {
if (this.state.hasError) {
return (
<ErrorContainer>
{"Something isn't working!"}
<br/>
{`Error: ${this.state.errorMessage}`}
<br/>
{"Please "}<a href="/contact" style={{fontWeight: 300}}>get in touch</a>{" if this keeps happening"}
</ErrorContainer>
)
}
return this.props.children;
}
}
17 changes: 10 additions & 7 deletions static-site/src/components/ListResources/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -93,7 +94,7 @@ function ListResources({

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

{ groups?.[0]?.lastUpdated && (
{ versioned && (
<SortOptions sortMethod={sortMethod} changeSortMethod={changeSortMethod}/>
) || (
<HugeSpacer/>
Expand All @@ -116,8 +117,8 @@ function ListResources({

<Tooltip style={{fontSize: '1.6rem'}} id="listResourcesTooltip"/>

{ versioned && (
<ResourceModal resource={modalResource} dismissModal={() => setModalResource(undefined)}/>
{ versioned && modalResource && (
<ResourceModal resource={convertVersionedResource(modalResource)} dismissModal={() => setModalResource(undefined)}/>
)}

</ListResourcesContainer>
Expand Down Expand Up @@ -165,9 +166,11 @@ function ListResourcesResponsive(props: ListResourcesResponsiveProps) {
};
}, []);
return (
<div ref={ref}>
<ListResources {...props} elWidth={elWidth}/>
</div>
<ErrorBoundary>
<div ref={ref}>
<ListResources {...props} elWidth={elWidth}/>
</div>
</ErrorBoundary>
)
}

Expand Down
52 changes: 50 additions & 2 deletions static-site/src/components/ListResources/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { InternalError } from "./errors";
import { Tile } from "../ExpandableTiles/types"

export interface FilterOption {
Expand All @@ -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
Expand All @@ -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
}
genehack marked this conversation as resolved.
Show resolved Hide resolved
}
throw new InternalError("Resource is not versioned.");
}

export interface ResourceDisplayName {
hovered: string
default: string
Expand Down
2 changes: 1 addition & 1 deletion static-site/src/components/ListResources/useDataFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
Loading