diff --git a/static-site/src/components/ListResources/IndividualResource.tsx b/static-site/src/components/ListResources/IndividualResource.tsx index a2ec77eac..afe64933d 100644 --- a/static-site/src/components/ListResources/IndividualResource.tsx +++ b/static-site/src/components/ListResources/IndividualResource.tsx @@ -21,11 +21,13 @@ const iconWidth = 20; // not including text const gapSize = 10; export const getMaxResourceWidth = (displayResources: Resource[]) => { return displayResources.reduce((w, r) => { + if (!r.displayName || !r.updateCadence) return w + /* add the pixels for the display name */ let _w = r.displayName.default.length * namePxPerChar; if (r.nVersions) { _w += gapSize + iconWidth; - _w += ((r?.updateCadence?.summary?.length || 0) + 5 + String(r.nVersions).length)*summaryPxPerChar; + _w += ((r.updateCadence.summary.length || 0) + 5 + String(r.nVersions).length)*summaryPxPerChar; } return _w>w ? _w : w; }, 200); // 200 (pixels) is the minimum @@ -113,16 +115,42 @@ interface IndividualResourceProps { export const IndividualResource = ({resource, isMobile}: IndividualResourceProps) => { const setModalResource = useContext(SetModalResourceContext); + if (!setModalResource) throw new Error("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; + /* 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 */ - if (ref.current.offsetTop===ref.current.parentNode.offsetTop) { + if (ref.current.offsetTop===(ref.current.parentNode as HTMLDivElement).offsetTop) { setTopOfColumn(true); } }, []); + // don't show anything if display name is unavailable + if (!resource.displayName) return null + + // add history if mobile and resource is versioned + let history: React.JSX.Element | null = null + if (!isMobile && resource.versioned && resource.updateCadence && resource.nVersions) { + history = ( + Last known update on ${resource.lastUpdated}` + + `
${resource.nVersions} snapshots of this dataset available (click to see them)`}> + setModalResource(resource)} + /> +
+ ) + } + return ( @@ -134,17 +162,7 @@ export const IndividualResource = ({resource, isMobile}: IndividualResourceProps - {resource.versioned && !isMobile && ( - Last known update on ${resource.lastUpdated}` + - `
${resource.nVersions} snapshots of this dataset available (click to see them)`}> - setModalResource(resource)} - /> -
- )} + {history} diff --git a/static-site/src/components/ListResources/Modal.tsx b/static-site/src/components/ListResources/Modal.tsx index d8175ff44..700e3bfb4 100644 --- a/static-site/src/components/ListResources/Modal.tsx +++ b/static-site/src/components/ListResources/Modal.tsx @@ -41,7 +41,8 @@ export const ResourceModal = ({resource, dismissModal}: ResourceModalProps) => { _draw(ref, resource) }, [ref, resource]) - if (!resource) return null; + // modal is only applicable for versioned resources + if (!resource || !resource.dates || !resource.updateCadence) return null; const summary = _snapshotSummary(resource.dates); return ( @@ -132,8 +133,10 @@ const Title = styled.div` function _snapshotSummary(dates: string[]) { const d = [...dates].sort() - const d1 = new Date(d.at( 0)).getTime(); - const d2 = new Date(d.at(-1)).getTime(); + 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; let duration = ''; if (days < 100) duration=`${days} days`; @@ -143,6 +146,9 @@ function _snapshotSummary(dates: string[]) { } function _draw(ref, resource: Resource) { + // do nothing if resource has no dates + if (!resource.dates) return + /* 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 */ @@ -172,7 +178,8 @@ function _draw(ref, resource: Resource) { /* Create the x-scale and draw the x-axis */ const x = d3.scaleTime() - .domain([flatData[0].date, new Date()]) // the domain extends to the present day + // 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 .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 e7a32f2c3..82a244ae6 100644 --- a/static-site/src/components/ListResources/ResourceGroup.tsx +++ b/static-site/src/components/ListResources/ResourceGroup.tsx @@ -19,6 +19,8 @@ interface ResourceGroupHeaderProps { const ResourceGroupHeader = ({group, isMobile, setCollapsed, collapsible, isCollapsed, resourcesToShowWhenCollapsed, quickLinks}: ResourceGroupHeaderProps) => { const setModalResource = useContext(SetModalResourceContext); + if (!setModalResource) throw new Error("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])); const quickLinksToDisplay = (quickLinks || []).filter((ql) => !!resourcesByName[ql.name] || ql.groupName===group.groupName) @@ -252,7 +254,7 @@ function _setDisplayName(resources: Resource[]) { 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); + 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 matchIdx = r.nameParts.length-2; } diff --git a/static-site/src/components/ListResources/Showcase.tsx b/static-site/src/components/ListResources/Showcase.tsx index 6c1e0d5cc..0b65fa5f5 100644 --- a/static-site/src/components/ListResources/Showcase.tsx +++ b/static-site/src/components/ListResources/Showcase.tsx @@ -102,7 +102,9 @@ const CardOuter = styled.div` const themeColors = [...theme.titleColors]; const getColor = () => { - themeColors.push(themeColors.shift()); + // rotate colors by moving the first color (which is always defined) to the end + themeColors.push(themeColors.shift()!); + // return the last color return themeColors.at(-1); } diff --git a/static-site/src/components/ListResources/index.tsx b/static-site/src/components/ListResources/index.tsx index 2f80dde97..661394fef 100644 --- a/static-site/src/components/ListResources/index.tsx +++ b/static-site/src/components/ListResources/index.tsx @@ -124,9 +124,15 @@ function ListResourcesResponsive(props: ListResourcesResponsiveProps) { const [elWidth, setElWidth] = useState(0); useEffect(() => { const observer = new ResizeObserver(([entry]) => { - setElWidth(entry.contentRect.width); + if (entry) { + // don't do anything if entry is undefined + setElWidth(entry.contentRect.width); + } }); - observer.observe(ref.current); + if (ref.current) { + // don't do anything if ref is undefined + observer.observe(ref.current); + } return () => { observer.disconnect(); }; diff --git a/static-site/src/components/ListResources/useDataFetch.ts b/static-site/src/components/ListResources/useDataFetch.ts index 162f4bec9..ce11082cd 100644 --- a/static-site/src/components/ListResources/useDataFetch.ts +++ b/static-site/src/components/ListResources/useDataFetch.ts @@ -64,30 +64,34 @@ interface Partitions { */ function partitionByPathogen(pathVersions: PathVersions, pathPrefix: string, versioned: boolean) { return Object.entries(pathVersions).reduce((store: Partitions, [name, dates]) => { - const nameParts = name.split('/'); const sortedDates = [...dates].sort(); - let groupName = nameParts[0]; + // 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 + let groupName = nameParts[0]!; - if (!store[groupName]) store[groupName] = [] const resourceDetails: Resource = { name, groupName, /* decoupled from nameParts */ nameParts, sortingName: _sortableName(nameParts), url: `/${pathPrefix}${name}`, - lastUpdated: sortedDates.at(-1), + lastUpdated: sortedDates.at(-1)!, versioned }; if (versioned) { - resourceDetails.firstUpdated = sortedDates[0]; - resourceDetails.lastUpdated = sortedDates.at(-1); + 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))); } - store[groupName].push(resourceDetails) + if (!store[groupName]) store[groupName] = []; + store[groupName]!.push(resourceDetails) return store; }, {}); @@ -104,7 +108,7 @@ function groupsFrom(partitions: Partitions, pathPrefix: string, defaultGroupLink 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 */ @@ -127,7 +131,7 @@ function groupsFrom(partitions: Partitions, pathPrefix: string, defaultGroupLink function _sortableName(words: string[]) { const w = words.map((word) => { const m = word.match(/^(\d+)([ym])$/); - if (m) { + if (m && m[1]) { if (m[2]==='y') return String(parseInt(m[1])*12).padStart(4,'0') return m[1].padStart(4,'0') } @@ -162,7 +166,7 @@ function updateCadence(dateObjects) { return {summary: "rarely", description: `This dataset has been updated ${intervals.length+1} times in the past 2 years.`}; } - const lastUpdateDaysAgo = Math.round(((new Date()) - dateObjects.at(-1))/msInADay); + const lastUpdateDaysAgo = Math.round(((new Date()).getTime() - dateObjects.at(-1))/msInADay); const median = intervals[Math.floor(intervals.length/2)]; const mad = intervals.map((x) => Math.abs(x-median)).sort((a, b) => a-b)[Math.floor(intervals.length/2)]