Skip to content

Commit

Permalink
Merge pull request #1073: Improve type narrowing and error handling
Browse files Browse the repository at this point in the history
  • Loading branch information
victorlin authored Nov 19, 2024
2 parents c6b3d53 + f8feb3e commit e1df53f
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 77 deletions.
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') {
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) {
/* 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);
}

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
}
}
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

0 comments on commit e1df53f

Please sign in to comment.