diff --git a/development/ts-migration-dashboard/app/components/App.tsx b/development/ts-migration-dashboard/app/components/App.tsx index 317856a1f..e5ee1f3d0 100644 --- a/development/ts-migration-dashboard/app/components/App.tsx +++ b/development/ts-migration-dashboard/app/components/App.tsx @@ -1,7 +1,9 @@ -import React from 'react'; -import classnames from 'classnames'; -import { Tooltip as ReactTippy } from 'react-tippy'; +import React, { useState } from 'react'; import { readPartitionsFile } from '../../common/partitions-file'; +import type { ModulePartitionChild } from '../../common/build-module-partitions'; +import Box from './Box'; +import Connections from './Connections'; +import type { BoxRect, BoxModel } from './types'; type Summary = { numConvertedModules: number; @@ -12,16 +14,85 @@ function calculatePercentageComplete(summary: Summary) { return ((summary.numConvertedModules / summary.numModules) * 100).toFixed(1); } -export default function App() { - const partitions = readPartitionsFile(); +const partitions = readPartitionsFile(); +const allModules = partitions.flatMap((partition) => { + return partition.children; +}); +const modulesById = allModules.reduce>( + (obj, module) => { + return { ...obj, [module.id]: module }; + }, + {}, +); +const overallTotal = { + numConvertedModules: allModules.filter((module) => module.hasBeenConverted) + .length, + numModules: allModules.length, +}; - const allModules = partitions.flatMap((partition) => { - return partition.children; - }); - const overallTotal = { - numConvertedModules: allModules.filter((module) => module.hasBeenConverted) - .length, - numModules: allModules.length, +export default function App() { + const [boxRectsByModuleId, setBoxRectsById] = useState | null>(null); + const boxesByModuleId = + boxRectsByModuleId === null + ? {} + : Object.values(boxRectsByModuleId).reduce>( + (obj, boxRect) => { + const module = modulesById[boxRect.moduleId]; + + const dependencyBoxRects = module.dependencyIds.reduce( + (dependencyBoxes, dependencyId) => { + if (boxRectsByModuleId[dependencyId] === undefined) { + return dependencyBoxes; + } + return [...dependencyBoxes, boxRectsByModuleId[dependencyId]]; + }, + [], + ); + + const dependentBoxRects = module.dependentIds.reduce( + (dependentBoxes, dependentId) => { + if (boxRectsByModuleId[dependentId] === undefined) { + return dependentBoxes; + } + return [...dependentBoxes, boxRectsByModuleId[dependentId]]; + }, + [], + ); + + return { + ...obj, + [boxRect.moduleId]: { + rect: boxRect, + dependencyBoxRects, + dependentBoxRects, + }, + }; + }, + {}, + ); + const [activeBoxRectId, setActiveBoxRectId] = useState(null); + const activeBoxRect = + boxRectsByModuleId === null || activeBoxRectId === null + ? null + : boxRectsByModuleId[activeBoxRectId]; + + const registerBox = (id: string, boxRect: BoxRect) => { + setBoxRectsById((existingBoxRectsById) => { + if (existingBoxRectsById === undefined) { + return { [id]: boxRect }; + } + return { ...existingBoxRectsById, [id]: boxRect }; + }); + }; + const toggleConnectionsFor = (id: string) => { + if (activeBoxRectId !== undefined && activeBoxRectId === id) { + setActiveBoxRectId(null); + } else { + setActiveBoxRectId(id); + } }; return ( @@ -50,17 +121,35 @@ export default function App() {

- Each box -

+ Each box on this page represents a file in the codebase. Gray boxes +   -
- on this page represents a file that either we want to convert or - we've already converted to TypeScript (hover over a box to see the - filename). Boxes that are -
+ + represent files that need to be converted to TypeScript. Green boxes +   -
- greyed out are test or Storybook files. + + are files that have already been converted. Faded boxes + +   + + +   + + are test or Storybook files. +

+ +

+ You can hover over a box to see the name of the file that it + represents. You can also click on a box to see connections between + other files;{' '} + red lines + lead to dependencies (other files that import the file);{' '} + blue lines + lead to dependents (other files that are imported by the file).

@@ -103,36 +192,25 @@ export default function App() {

level {partition.level}
- {partition.children.map(({ name, hasBeenConverted }) => { - const isTest = /\.test\.(?:js|tsx?)/u.test(name); - const isStorybookModule = /\.stories\.(?:js|tsx?)/u.test( - name, - ); + {partition.children.map((module) => { + const areConnectionsVisible = activeBoxRectId === module.id; return ( - -
- + ); })}
); })} + {activeBoxRect === null ? null : ( + + )}
); diff --git a/development/ts-migration-dashboard/app/components/Box.tsx b/development/ts-migration-dashboard/app/components/Box.tsx new file mode 100644 index 000000000..675cb63db --- /dev/null +++ b/development/ts-migration-dashboard/app/components/Box.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useRef } from 'react'; +import classnames from 'classnames'; +import { Tooltip as ReactTippy } from 'react-tippy'; +import type { ModulePartitionChild } from '../../common/build-module-partitions'; +import type { BoxRect } from './types'; + +export default function Box({ + module, + register, + toggleConnectionsFor, + areConnectionsVisible, +}: { + module: ModulePartitionChild; + register: (id: string, boxRect: BoxRect) => void; + toggleConnectionsFor: (id: string) => void; + areConnectionsVisible: boolean; +}) { + const isTest = /\.test\.(?:js|tsx?)/u.test(module.id); + const isStorybookModule = /\.stories\.(?:js|tsx?)/u.test(module.id); + const ref = useRef(null); + + useEffect(() => { + if (ref.current?.offsetParent) { + const rect = ref.current.getBoundingClientRect(); + const offsetParentRect = ref.current.offsetParent.getBoundingClientRect(); + const top = rect.top - offsetParentRect.top; + const left = rect.left - offsetParentRect.left; + const centerX = left + rect.width / 2; + const centerY = top + rect.height / 2; + register(module.id, { + moduleId: module.id, + top, + left, + width: rect.width, + height: rect.height, + centerX, + centerY, + }); + } + }, [ref]); + + const onClick = (event: React.MouseEvent) => { + event.preventDefault(); + toggleConnectionsFor(module.id); + }; + + return ( + +
+ + ); +} diff --git a/development/ts-migration-dashboard/app/components/Connections.tsx b/development/ts-migration-dashboard/app/components/Connections.tsx new file mode 100644 index 000000000..329bfb3e5 --- /dev/null +++ b/development/ts-migration-dashboard/app/components/Connections.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import type { BoxModel } from './types'; + +function buildShapePoints(coordinates: [number, number][]): string { + return coordinates.map(([x, y]) => `${x},${y}`).join(' '); +} + +function buildPathD(coordinates: [number, number][]): string { + return coordinates + .map(([x, y], index) => { + if (index === 0) { + return `M ${x},${y}`; + } + return `L ${x},${y}`; + }) + .join(' '); +} + +function Arrowhead({ + type, + x, + y, +}: { + type: 'dependency' | 'dependent'; + x: number; + y: number; +}) { + return ( + + ); +} + +function Line({ + type, + originX, + originY, + originYOffset = 0, + destinationX, + destinationY, + destinationYOffset = 0, +}: { + type: 'dependency' | 'dependent'; + originX: number; + originY: number; + originYOffset?: number; + destinationX: number; + destinationY: number; + destinationYOffset?: number; +}) { + const coordinates: [number, number][] = + type === 'dependency' + ? [ + [originX, originY], + [originX, originY + originYOffset], + [destinationX, originY + originYOffset], + [destinationX, destinationY], + ] + : [ + [originX, originY], + [originX, destinationY - destinationYOffset], + [destinationX, destinationY - destinationYOffset], + [destinationX, destinationY], + ]; + return ( + + ); +} + +function LineStart({ + type, + x, + y, +}: { + type: 'dependency' | 'dependent'; + x: number; + y: number; +}) { + return ( + + ); +} + +export default function Connections({ activeBox }: { activeBox: BoxModel }) { + return ( + + {activeBox.dependencyBoxRects.length === 0 ? null : ( + + )} + {activeBox.dependencyBoxRects.map((dependencyBoxRect) => { + return ( + + + + + ); + })} + {activeBox.dependentBoxRects.map((dependentBoxRect) => { + return ( + + + + + ); + })} + + ); +} diff --git a/development/ts-migration-dashboard/app/components/types.ts b/development/ts-migration-dashboard/app/components/types.ts new file mode 100644 index 000000000..65b2349da --- /dev/null +++ b/development/ts-migration-dashboard/app/components/types.ts @@ -0,0 +1,15 @@ +export type BoxRect = { + moduleId: string; + top: number; + left: number; + width: number; + height: number; + centerX: number; + centerY: number; +}; + +export type BoxModel = { + rect: BoxRect; + dependencyBoxRects: BoxRect[]; + dependentBoxRects: BoxRect[]; +}; diff --git a/development/ts-migration-dashboard/app/styles/custom-elements.scss b/development/ts-migration-dashboard/app/styles/custom-elements.scss index 216c1ed6d..e53562024 100644 --- a/development/ts-migration-dashboard/app/styles/custom-elements.scss +++ b/development/ts-migration-dashboard/app/styles/custom-elements.scss @@ -2,6 +2,10 @@ --blue-gray-350: hsl(209deg 13.7% 62.4%); --blue-gray-100: hsl(209.8deg 16.5% 89%); --green: hsl(113deg 100% 38%); + --red: hsl(13deg 98% 61%); + --blue: hsl(246deg 97% 55%); + --light-cyan: hsl(178deg 100% 85%); + --cyan: hsl(178deg 100% 42%); } .page-header { @@ -69,6 +73,10 @@ border: 1px solid rgb(0 0 0 / 50%); } +.partitions { + position: relative; +} + .partition { display: flex; gap: 0.5rem; @@ -94,8 +102,9 @@ .module { width: 1.5rem; height: 1.5rem; - border: 1px solid rgba(0 0 0 / 25%); + border: 1px solid hsla(0deg 0% 0% / 25%); border-radius: 0.25rem; + cursor: pointer; &--inline { display: inline-block; @@ -120,6 +129,55 @@ &--storybook { opacity: 0.3; } + + &--active { + border-color: var(--cyan); + background-color: var(--light-cyan); + border-width: 2px; + } +} + +.module-connections { + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + pointer-events: none; +} + +.module-connection { + &__dependency-arrowhead { + fill: var(--red); + } + + &__dependency { + fill: none; + stroke: var(--red); + color: var(--red); + stroke-width: 2px; + } + + &__dependency-point { + fill: var(--red); + r: 3px; + } + + &__dependent-arrowhead { + fill: var(--blue); + } + + &__dependent { + fill: none; + stroke: var(--blue); + color: var(--blue); + stroke-width: 2px; + } + + &__dependent-point { + fill: var(--blue); + r: 3px; + } } /* Package overrides */ diff --git a/development/ts-migration-dashboard/common/build-module-partitions.ts b/development/ts-migration-dashboard/common/build-module-partitions.ts index 8ceaff18a..aa58568b3 100644 --- a/development/ts-migration-dashboard/common/build-module-partitions.ts +++ b/development/ts-migration-dashboard/common/build-module-partitions.ts @@ -9,24 +9,42 @@ import { } from './constants'; /** - * Represents a module that has been imported somewhere in the codebase. + * Represents a module that has been imported somewhere in the codebase, whether + * it is local to the project or an NPM package. * - * @property id - The name of a file or NPM module. + * @property id - If an NPM package, then the name of the package; otherwise the + * path to a file within the project. * @property dependents - The modules which are imported by this module. * @property level - How many modules it takes to import this module (from the * root of the dependency tree). - * @property isExternal - Whether the module refers to a NPM module. + * @property isExternal - Whether the module refers to a NPM package. * @property hasBeenConverted - Whether the module was one of the files we * wanted to convert to TypeScript and has been converted. */ type Module = { id: string; dependents: Module[]; + dependencies: Module[]; level: number; isExternal: boolean; hasBeenConverted: boolean; }; +/** + * Represents a module that belongs to a certain level within the dependency + * graph, displayed as a box in the UI. + * + * @property id - The id of the module. + * @property hasBeenConverted - Whether or not the module has been converted to + * TypeScript. + */ +export type ModulePartitionChild = { + id: string; + hasBeenConverted: boolean; + dependentIds: string[]; + dependencyIds: string[]; +}; + /** * Represents a set of modules that sit at a certain level within the final * dependency tree. @@ -34,16 +52,10 @@ type Module = { * @property level - How many modules it takes to import this module (from the * root of the dependency tree). * @property children - The modules that share this same level. - * @property children[].name - The name of the item being imported. - * @property children[].hasBeenConverted - Whether or not the module (assuming - * that it is a file in our codebase) has been converted to TypeScript. */ export type ModulePartition = { level: number; - children: { - name: string; - hasBeenConverted: boolean; - }[]; + children: ModulePartitionChild[]; }; /** @@ -196,10 +208,11 @@ function buildModulesWithLevels( // includes `foo.js`, so we say `foo.js` is a circular dependency of `baz.js`, // and we don't need to follow it. - const modulesToFill: Module[] = entryModuleIds.map((moduleId) => { + let modulesToFill: Module[] = entryModuleIds.map((moduleId) => { return { id: moduleId, dependents: [], + dependencies: [], level: 0, isExternal: false, hasBeenConverted: /\.tsx?$/u.test(moduleId), @@ -208,76 +221,101 @@ function buildModulesWithLevels( const modulesById: Record = {}; while (modulesToFill.length > 0) { - const currentModule = modulesToFill.shift() as Module; - const childModulesToFill: Module[] = []; - (dependenciesByModuleId[currentModule.id] ?? []).forEach( - (givenChildModuleId) => { - const npmPackageMatch = givenChildModuleId.match( - /^node_modules\/(?:(@[^/]+)\/)?([^/]+)\/.+$/u, - ); + const currentModule = modulesToFill[0]; - let childModuleId; - if (npmPackageMatch) { - childModuleId = - npmPackageMatch[1] === undefined - ? `${npmPackageMatch[2]}` - : `${npmPackageMatch[1]}/${npmPackageMatch[2]}`; - } else { - childModuleId = givenChildModuleId; - } - - // Skip circular dependencies - if ( - findDirectAndIndirectDependentIdsOf(currentModule).has(childModuleId) - ) { - return; - } - - // Skip files that weren't on the original list of JavaScript files to - // convert, as we don't want them to show up on the status dashboard - if ( - !npmPackageMatch && - !allowedFilePaths.includes(childModuleId.replace(/\.tsx?$/u, '.js')) - ) { - return; - } - - const existingChildModule = modulesById[childModuleId]; - - if (existingChildModule === undefined) { - const childModule: Module = { - id: childModuleId, - dependents: [currentModule], - level: currentModule.level + 1, - isExternal: Boolean(npmPackageMatch), - hasBeenConverted: /\.tsx?$/u.test(childModuleId), - }; - childModulesToFill.push(childModule); - } else { - if (currentModule.level + 1 > existingChildModule.level) { - existingChildModule.level = currentModule.level + 1; - // Update descendant modules' levels - childModulesToFill.push(existingChildModule); - } else { - // There is no point in descending into dependencies of this module - // if the new level of the module would be <= the existing level, - // because all of the dependencies from this point on are guaranteed - // to have a higher level and are therefore already at the right - // level. - } - - if (!existingChildModule.dependents.includes(currentModule)) { - existingChildModule.dependents.push(currentModule); - } - } - }, - ); - if (childModulesToFill.length > 0) { - modulesToFill.push(...childModulesToFill); + if (currentModule.level > 100) { + throw new Error( + "Can't build module partitions, as the dependency graph is being traversed ad infinitum.", + ); } + + const dependencyModulesToFill: Module[] = []; + for (const nonCanonicalDependencyModuleId of dependenciesByModuleId[ + currentModule.id + ]) { + // If the file path of the module is located in `node_modules`, use the + // name of the package as the ID + let dependencyModuleId; + const npmPackageMatch = nonCanonicalDependencyModuleId.match( + /^node_modules\/(?:(@[^/]+)\/)?([^/]+)\/.+$/u, + ); + if (npmPackageMatch) { + dependencyModuleId = + npmPackageMatch[1] === undefined + ? `${npmPackageMatch[2]}` + : `${npmPackageMatch[1]}/${npmPackageMatch[2]}`; + } else { + dependencyModuleId = nonCanonicalDependencyModuleId; + } + + // Skip circular dependencies + if ( + findDirectAndIndirectDependentIdsOf(currentModule).has( + dependencyModuleId, + ) + ) { + continue; + } + + // Skip files that weren't on the original list of JavaScript files to + // convert, as we don't want them to show up on the status dashboard + if ( + !npmPackageMatch && + !allowedFilePaths.includes( + dependencyModuleId.replace(/\.tsx?$/u, '.js'), + ) + ) { + continue; + } + + let existingDependencyModule = modulesById[dependencyModuleId]; + + if (existingDependencyModule === undefined) { + existingDependencyModule = { + id: dependencyModuleId, + dependents: [currentModule], + dependencies: [], + level: currentModule.level + 1, + isExternal: Boolean(npmPackageMatch), + hasBeenConverted: /\.tsx?$/u.test(dependencyModuleId), + }; + dependencyModulesToFill.push(existingDependencyModule); + } else if (currentModule.level + 1 > existingDependencyModule.level) { + existingDependencyModule.level = currentModule.level + 1; + // Update descendant modules' levels + dependencyModulesToFill.push(existingDependencyModule); + } else { + // There is no point in descending into dependencies of this module + // if the new level of the module would be <= the existing level, + // because all of the dependencies from this point on are guaranteed + // to have a higher level and are therefore already at the right + // level. + } + + if ( + !existingDependencyModule.dependents.some( + (m) => m.id === currentModule.id, + ) + ) { + existingDependencyModule.dependents.push(currentModule); + } + + if ( + !currentModule.dependencies.some( + (m) => m.id === existingDependencyModule.id, + ) + ) { + currentModule.dependencies.push(existingDependencyModule); + } + } + if (dependencyModulesToFill.length > 0) { + modulesToFill.push(...dependencyModulesToFill); + } + if (!(currentModule.id in modulesById)) { modulesById[currentModule.id] = currentModule; } + modulesToFill = modulesToFill.slice(1); } return modulesById; @@ -288,20 +326,21 @@ function buildModulesWithLevels( * which import that file directly or through some other file. * * @param module - A module in the graph. - * @returns The ids of the modules which are incoming connections to + * @returns The ids of the modules which are direct and indirect dependents of * the module. */ function findDirectAndIndirectDependentIdsOf(module: Module): Set { - const modulesToProcess = [module]; + let modulesToProcess = [module]; const allDependentIds = new Set(); while (modulesToProcess.length > 0) { - const currentModule = modulesToProcess.shift() as Module; - currentModule.dependents.forEach((dependent) => { + const currentModule = modulesToProcess[0]; + for (const dependent of currentModule.dependents) { if (!allDependentIds.has(dependent.id)) { allDependentIds.add(dependent.id); modulesToProcess.push(dependent); } - }); + } + modulesToProcess = modulesToProcess.slice(1); } return allDependentIds; } @@ -328,8 +367,22 @@ function partitionModulesByLevel( } Object.values(modulesById).forEach((module) => { modulePartitions[module.level].children.push({ - name: module.id, + id: module.id, hasBeenConverted: module.hasBeenConverted, + dependentIds: module.dependents.map((dependent) => dependent.id).sort(), + dependencyIds: module.dependencies + .map((dependency) => dependency.id) + .sort(), + }); + }); + Object.values(modulePartitions).forEach((partition) => { + partition.children.sort((a, b) => { + if (a.id < b.id) { + return -1; + } else if (a.id > b.id) { + return 1; + } + return 0; }); }); return modulePartitions.reverse();