-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: departures multi-section auto-sizing
* Update departures widget generation to pass layout parameters from configuration to the frontend. * Replace existing auto-sizing implementation, which only worked for a single section, with a version that handles multiple sections using the layout parameters. * The auto-sizing itself (independent of the React component) is unit-tested. These are the first real frontend tests in Screens, so we take the opportunity to: * Update Jest to its latest version. * Replace the third-party `@types/jest` package with a direct dependency on `@jest/globals`, which is already part of Jest. The only advantage of the former is that it allows using `describe`, `test`, etc. without importing them. * Add `eslint-plugin-jest` to our ESLint setup. * Add `fishery` for defining factories to build test data.
- Loading branch information
1 parent
9eeafc3
commit de3849a
Showing
19 changed files
with
8,350 additions
and
16,846 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
// https://jestjs.io/docs/configuration | ||
|
||
/* eslint-disable @typescript-eslint/no-var-requires */ | ||
/* global require, module */ | ||
|
||
const requireJSON5 = require("require-json5"); | ||
const { pathsToModuleNameMapper } = require("ts-jest"); | ||
const { compilerOptions } = requireJSON5("./tsconfig"); | ||
|
||
/** @type {import('jest').Config} */ | ||
module.exports = { | ||
preset: "ts-jest", | ||
errorOnDeprecated: true, | ||
resetMocks: true, | ||
roots: ["<rootDir>"], | ||
modulePaths: [compilerOptions.baseUrl], | ||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths), | ||
}; |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
172 changes: 33 additions & 139 deletions
172
assets/src/components/v2/departures/normal_departures.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,158 +1,52 @@ | ||
import React, { | ||
ComponentType, | ||
useState, | ||
forwardRef, | ||
useLayoutEffect, | ||
useRef, | ||
useEffect, | ||
useContext, | ||
} from "react"; | ||
import React, { ComponentType, useLayoutEffect, useRef, useState } from "react"; | ||
import weakKey from "weak-key"; | ||
|
||
import NormalSection, { | ||
Row as NormalRow, | ||
} from "Components/v2/departures/normal_section"; | ||
import NoticeSection from "Components/v2/departures/notice_section"; | ||
import { LastFetchContext } from "../screen_container"; | ||
import NormalSection from "./normal_section"; | ||
import NoticeSection from "./notice_section"; | ||
import { Section, trimSections } from "./section"; | ||
|
||
type Section = | ||
| (NormalSection & { type: "normal_section" }) | ||
| (NoticeSection & { type: "notice_section" }); | ||
import { warn } from "Util/sentry"; | ||
import { hasOverflowY } from "Util/util"; | ||
|
||
type RendererProps = { | ||
type NormalDepartures = { | ||
sections: Section[]; | ||
sectionSizes: number[]; | ||
}; | ||
|
||
const NormalDeparturesRenderer = forwardRef<HTMLDivElement, RendererProps>( | ||
({ sections, sectionSizes }, ref) => { | ||
return ( | ||
<div className="departures-container"> | ||
<div className="departures" ref={ref}> | ||
{sections.map((section, i) => { | ||
const key = weakKey(section); | ||
|
||
if (section.type === "normal_section") { | ||
return ( | ||
<NormalSection | ||
rows={trimRows(section.rows, sectionSizes[i])} | ||
key={key} | ||
/> | ||
); | ||
} else { | ||
return <NoticeSection {...section} key={key} />; | ||
} | ||
})} | ||
</div> | ||
</div> | ||
); | ||
}, | ||
); | ||
const NormalDepartures: ComponentType<NormalDepartures> = ({ sections }) => { | ||
const ref = useRef<HTMLDivElement>(null); | ||
const [trimmedSections, setTrimmedSections] = useState(sections); | ||
|
||
const trimRows = (rows, n) => { | ||
const { trimmed } = rows.reduce( | ||
({ count, trimmed }, row: NormalRow) => { | ||
if (row.type == "notice_row") { | ||
if (count < n) { | ||
return { count: count + 1, trimmed: [...trimmed, row] }; | ||
} else { | ||
return { count, trimmed }; | ||
} | ||
} | ||
// Restart trimming if the sections prop is changed (i.e. new data). | ||
useLayoutEffect(() => setTrimmedSections(sections), [sections]); | ||
|
||
const trimmedRow = { | ||
...row, | ||
times_with_crowding: row.times_with_crowding.slice(0, n - count), | ||
}; | ||
const addedCount = trimmedRow.times_with_crowding.length; | ||
// Iteratively trim sections until the container doesn't overflow. | ||
useLayoutEffect(() => { | ||
if (hasOverflowY(ref)) { | ||
const newSections = trimSections(trimmedSections); | ||
|
||
if (addedCount > 0) { | ||
return { count: count + addedCount, trimmed: [...trimmed, trimmedRow] }; | ||
if (trimmedSections != newSections) { | ||
setTrimmedSections(newSections); | ||
} else { | ||
return { count, trimmed }; | ||
warn("layout failed: departures will overflow"); | ||
} | ||
}, | ||
{ count: 0, trimmed: [] }, | ||
); | ||
|
||
return trimmed; | ||
}; | ||
|
||
const getInitialSectionSize = (data) => { | ||
return data.rows.reduce((acc, row: NormalRow) => { | ||
switch (row.type) { | ||
case "departure_row": | ||
return acc + row.times_with_crowding.length; | ||
case "notice_row": | ||
return acc + 1; | ||
} | ||
}, 0); | ||
}; | ||
|
||
const getInitialSectionSizes = (sections) => { | ||
return sections.map((section) => getInitialSectionSize(section)); | ||
}; | ||
|
||
const NormalDeparturesSizer = ({ sections, onDoneSizing }) => { | ||
const [tempSectionSizes, setTempSectionSizes] = useState( | ||
getInitialSectionSizes(sections), | ||
); | ||
const ref = useRef<HTMLDivElement>(null); | ||
|
||
useLayoutEffect(() => { | ||
if ( | ||
ref.current?.parentElement?.parentElement && | ||
ref.current.clientHeight > | ||
ref.current.parentElement.parentElement.clientHeight | ||
) { | ||
setTempSectionSizes((sectionSizes) => { | ||
return [sectionSizes[0] - 1]; | ||
}); | ||
} else { | ||
onDoneSizing(tempSectionSizes); | ||
} | ||
}, [sections, tempSectionSizes]); | ||
}, [trimmedSections]); | ||
|
||
return ( | ||
<NormalDeparturesRenderer | ||
sections={sections} | ||
sectionSizes={tempSectionSizes} | ||
ref={ref} | ||
/> | ||
<div className="departures-container" ref={ref}> | ||
<div className="departures"> | ||
{trimmedSections.map((section) => { | ||
const key = weakKey(section); | ||
|
||
if (section.type === "normal_section") { | ||
return <NormalSection {...section} key={key} />; | ||
} else { | ||
return <NoticeSection {...section} key={key} />; | ||
} | ||
})} | ||
</div> | ||
</div> | ||
); | ||
}; | ||
|
||
type NormalDepartures = { | ||
sections: Section[]; | ||
}; | ||
|
||
const NormalDepartures: ComponentType<NormalDepartures> = ({ sections }) => { | ||
const [sectionSizes, setSectionSizes] = useState([]); | ||
const lastFetch = useContext(LastFetchContext); | ||
|
||
// Reset state each time we receive new props, | ||
// so that section sizes are recalculated from scratch. | ||
useEffect(() => { | ||
setSectionSizes([]); | ||
}, [sections]); | ||
|
||
if (sectionSizes.length > 0) { | ||
return ( | ||
<NormalDeparturesRenderer | ||
sections={sections} | ||
sectionSizes={sectionSizes} | ||
key={lastFetch} | ||
/> | ||
); | ||
} else { | ||
return ( | ||
<NormalDeparturesSizer | ||
sections={sections} | ||
onDoneSizing={setSectionSizes} | ||
key={lastFetch} | ||
/> | ||
); | ||
} | ||
}; | ||
|
||
export default NormalDepartures; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.