Skip to content

Commit

Permalink
feat: departures multi-section auto-sizing
Browse files Browse the repository at this point in the history
* 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
digitalcora committed May 16, 2024
1 parent 9eeafc3 commit de3849a
Show file tree
Hide file tree
Showing 19 changed files with 8,350 additions and 16,846 deletions.
5 changes: 5 additions & 0 deletions assets/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginReactConfig from "eslint-plugin-react/configs/recommended.js";
import eslintConfigPrettier from "eslint-config-prettier";
import jestPlugin from "eslint-plugin-jest";

export default [
{ languageOptions: { globals: globals.browser } },
pluginJs.configs.recommended,
...tseslint.configs.recommended,
pluginReactConfig,
eslintConfigPrettier,
{
files: ["test/**"],
...jestPlugin.configs["flat/style"],
},
{
settings: { react: { version: "detect" } },
rules: {
Expand Down
18 changes: 18 additions & 0 deletions assets/jest.config.js
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),
};
24,511 changes: 7,878 additions & 16,633 deletions assets/package-lock.json

Large diffs are not rendered by default.

37 changes: 7 additions & 30 deletions assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"scripts": {
"deploy": "webpack --mode production",
"test": "jest",
"test:watch": "jest --watch",
"watch": "concurrently --kill-others --prefix-colors auto npm:watch-*",
"watch-build": "webpack --mode development --stats minimal --watch",
"watch-tsc": "tsc --noEmit --preserveWatchOutput --watch",
Expand Down Expand Up @@ -44,8 +45,8 @@
"@babel/preset-stage-1": "^7.8.3",
"@babel/preset-typescript": "^7.14.5",
"@eslint/js": "^9.1.1",
"@jest/globals": "^29.7.0",
"@svgr/webpack": "^5.5.0",
"@types/jest": "^27.0.0",
"@types/lodash": "^4.14.171",
"@types/react": "^17.0.14",
"@types/react-dom": "^17.0.9",
Expand All @@ -57,50 +58,26 @@
"css-loader": "^5.2.7",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-jest": "^28.5.0",
"eslint-plugin-react": "^7.34.1",
"file-loader": "^6.2.0",
"fishery": "^2.2.2",
"globals": "^15.1.0",
"jest": "^27.0.6",
"jest": "^29.7.0",
"mini-css-extract-plugin": "^1.6.2",
"optimize-css-assets-webpack-plugin": "^6.0.1",
"prettier": "^3.2.5",
"require-json5": "^1.3.0",
"sass": "^1.62.1",
"sass-loader": "^10.1.0",
"source-map-loader": "^1.1.3",
"terser-webpack-plugin": "^4.2.3",
"ts-jest": "^27.0.4",
"ts-jest": "^29.1.2",
"ts-loader": "^8.3.0",
"typescript": "^4.3.5",
"typescript-eslint": "^7.8.0",
"webpack": "^4.46.0",
"webpack-cli": "^4.7.2",
"whatwg-fetch": "^3.6.2"
},
"jest": {
"clearMocks": true,
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"testRegex": "(src|tests)/.*\\.test\\.tsx?$",
"modulePaths": [
"<rootDir>/src"
],
"moduleFileExtensions": [
"ts",
"tsx",
"js",
"jsx",
"json",
"node"
],
"setupFilesAfterEnv": [
"<rootDir>/tests/setup.ts"
],
"collectCoverage": true,
"globals": {
"ts-jest": {
"tsConfig": "./tsconfig.test.json"
}
}
}
}
172 changes: 33 additions & 139 deletions assets/src/components/v2/departures/normal_departures.tsx
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;
7 changes: 7 additions & 0 deletions assets/src/components/v2/departures/normal_section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ import weakKey from "weak-key";
import DepartureRow from "./departure_row";
import NoticeRow from "./notice_row";

export type Layout = {
base: number | null;
max: number | null;
min: number;
};

export type Row =
| (DepartureRow & { type: "departure_row" })
| (NoticeRow & { type: "notice_row" });

type NormalSection = {
layout: Layout;
rows: Row[];
};

Expand Down
Loading

0 comments on commit de3849a

Please sign in to comment.