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();