chore(graph): consume task graph data in client (#13092)

This commit is contained in:
Philip Fulcher 2022-11-09 16:58:28 -07:00 committed by GitHub
parent f08a3c3c44
commit 2be9a01272
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 1490 additions and 1016 deletions

3
.gitignore vendored
View File

@ -15,7 +15,8 @@ jest.debug.config.js
/.verdaccio/build/local-registry /.verdaccio/build/local-registry
/graph/client/src/assets/environment.js /graph/client/src/assets/environment.js
/graph/client/src/assets/dev/environment.js /graph/client/src/assets/dev/environment.js
/graph/client/src/assets/generated-graphs /graph/client/src/assets/generated-project-graphs
/graph/client/src/assets/generated-task-graphs
/nx-dev/nx-dev/public/documentation /nx-dev/nx-dev/public/documentation
/nx-dev/nx-dev/public/images/open-graph /nx-dev/nx-dev/public/images/open-graph
# Lerna creates this # Lerna creates this

View File

@ -23,9 +23,9 @@ import * as nxExamplesJson from '../fixtures/nx-examples.json';
describe('graph-client', () => { describe('graph-client', () => {
before(() => { before(() => {
cy.intercept('/assets/graphs/e2e.json', { fixture: 'nx-examples.json' }).as( cy.intercept('/assets/project-graphs/e2e.json', {
'getGraph' fixture: 'nx-examples.json',
); }).as('getGraph');
cy.visit('/'); cy.visit('/');
// wait for initial graph to finish loading // wait for initial graph to finish loading
@ -140,7 +140,7 @@ describe('graph-client', () => {
}); });
it('should check all affected project items', () => { it('should check all affected project items', () => {
cy.intercept('/assets/graphs/affected.json', { cy.intercept('/assets/project-graphs/affected.json', {
fixture: 'affected.json', fixture: 'affected.json',
}).as('getAffectedGraph'); }).as('getAffectedGraph');
@ -155,7 +155,7 @@ describe('graph-client', () => {
); );
// switch back to Nx Examples graph before proceeding // switch back to Nx Examples graph before proceeding
cy.intercept('/assets/graphs/e2e.json', { cy.intercept('/assets/project-graphs/e2e.json', {
fixture: 'nx-examples.json', fixture: 'nx-examples.json',
}).as('getGraph'); }).as('getGraph');
cy.get('[data-cy=project-select]').select('e2e', { force: true }); cy.get('[data-cy=project-select]').select('e2e', { force: true });
@ -308,9 +308,9 @@ describe('graph-client', () => {
describe('loading graph client with url params', () => { describe('loading graph client with url params', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('/assets/graphs/*', { fixture: 'nx-examples.json' }).as( cy.intercept('/assets/project-graphs/*', {
'getGraph' fixture: 'nx-examples.json',
); }).as('getGraph');
}); });
// check that params work from old base url of / // check that params work from old base url of /

View File

@ -1,6 +1,6 @@
describe('graph-client release', () => { describe('graph-client release', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('/assets/graphs/*').as('getGraph'); cy.intercept('/assets/project-graphs/*').as('getGraph');
cy.visit('/'); cy.visit('/');

View File

@ -62,8 +62,9 @@
"dev": { "dev": {
"assets": [ "assets": [
"graph/client/src/favicon.ico", "graph/client/src/favicon.ico",
"graph/client/src/assets/graphs/", "graph/client/src/assets/project-graphs/",
"graph/client/src/assets/generated-graphs/", "graph/client/src/assets/generated-project-graphs/",
"graph/client/src/assets/generated-task-graphs/",
{ {
"input": "graph/client/src/assets/dev", "input": "graph/client/src/assets/dev",
"output": "/", "output": "/",
@ -74,7 +75,7 @@
"dev-e2e": { "dev-e2e": {
"assets": [ "assets": [
"graph/client/src/favicon.ico", "graph/client/src/favicon.ico",
"graph/client/src/assets/graphs/", "graph/client/src/assets/project-graphs/",
{ {
"input": "graph/client/src/assets/dev-e2e", "input": "graph/client/src/assets/dev-e2e",
"output": "/", "output": "/",
@ -86,8 +87,8 @@
"assets": [ "assets": [
"graph/client/src/favicon.ico", "graph/client/src/favicon.ico",
{ {
"input": "graph/client/src/assets/graphs", "input": "graph/client/src/assets/project-graphs",
"output": "/assets/graphs", "output": "/assets/project-graphs",
"glob": "e2e.json" "glob": "e2e.json"
}, },
{ {
@ -101,8 +102,8 @@
"assets": [ "assets": [
"graph/client/src/favicon.ico", "graph/client/src/favicon.ico",
{ {
"input": "graph/client/src/assets/graphs", "input": "graph/client/src/assets/project-graphs",
"output": "/assets/graphs", "output": "/assets/project-graphs",
"glob": "e2e.json" "glob": "e2e.json"
}, },
{ {
@ -129,7 +130,7 @@
"assets": [ "assets": [
"graph/client/src/favicon.ico", "graph/client/src/favicon.ico",
{ {
"input": "graph/client/src/assets/graphs", "input": "graph/client/src/assets/project-graphs",
"output": "/assets/graphs", "output": "/assets/graphs",
"glob": "e2e.json" "glob": "e2e.json"
}, },

View File

@ -8,7 +8,7 @@ import {
localStorageRankDirKey, localStorageRankDirKey,
RankDir, RankDir,
rankDirResolver, rankDirResolver,
} from '../rankdir-resolver'; } from '../../rankdir-resolver';
export default function RankdirPanel(): JSX.Element { export default function RankdirPanel(): JSX.Element {
const [rankDir, setRankDir] = useState( const [rankDir, setRankDir] = useState(

View File

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { KeyboardEvent } from 'react'; import type { KeyboardEvent } from 'react';
import { useDebounce } from '../hooks/use-debounce'; import { useDebounce } from '../../hooks/use-debounce';
import { BackspaceIcon, FunnelIcon } from '@heroicons/react/24/outline'; import { BackspaceIcon, FunnelIcon } from '@heroicons/react/24/outline';
import DebouncedTextInput from '../ui-components/debounced-text-input'; import DebouncedTextInput from '../../ui-components/debounced-text-input';
export interface TextFilterPanelProps { export interface TextFilterPanelProps {
textFilter: string; textFilter: string;

View File

@ -6,7 +6,11 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import classNames from 'classnames'; import classNames from 'classnames';
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { localStorageThemeKey, Theme, themeResolver } from '../theme-resolver'; import {
localStorageThemeKey,
Theme,
themeResolver,
} from '../../theme-resolver';
export default function ThemePanel(): JSX.Element { export default function ThemePanel(): JSX.Element {
const [theme, setTheme] = useState( const [theme, setTheme] = useState(

View File

@ -5,7 +5,7 @@ import {
XCircleIcon, XCircleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { memo } from 'react'; import { memo } from 'react';
import { TracingAlgorithmType } from '../machines/interfaces'; import { TracingAlgorithmType } from '../../machines/interfaces';
export interface TracingPanelProps { export interface TracingPanelProps {
start: string; start: string;

View File

@ -13,14 +13,14 @@ import {
searchDepthSelector, searchDepthSelector,
textFilterSelector, textFilterSelector,
} from '../machines/selectors'; } from '../machines/selectors';
import CollapseEdgesPanel from '../sidebar/collapse-edges-panel'; import CollapseEdgesPanel from './panels/collapse-edges-panel';
import FocusedProjectPanel from '../sidebar/focused-project-panel'; import FocusedProjectPanel from './panels/focused-project-panel';
import GroupByFolderPanel from '../sidebar/group-by-folder-panel'; import GroupByFolderPanel from './panels/group-by-folder-panel';
import ProjectList from '../sidebar/project-list'; import ProjectList from './project-list';
import SearchDepth from '../sidebar/search-depth'; import SearchDepth from './panels/search-depth';
import ShowHideProjects from '../sidebar/show-hide-projects'; import ShowHideProjects from './panels/show-hide-projects';
import TextFilterPanel from '../sidebar/text-filter-panel'; import TextFilterPanel from './panels/text-filter-panel';
import TracingPanel from '../sidebar/tracing-panel'; import TracingPanel from './panels/tracing-panel';
import { TracingAlgorithmType } from '../machines/interfaces'; import { TracingAlgorithmType } from '../machines/interfaces';
import { useEnvironmentConfig } from '../hooks/use-environment-config'; import { useEnvironmentConfig } from '../hooks/use-environment-config';

View File

@ -1,6 +1,6 @@
import { DocumentMagnifyingGlassIcon } from '@heroicons/react/24/solid'; import { DocumentMagnifyingGlassIcon } from '@heroicons/react/24/solid';
// nx-ignore-next-line // nx-ignore-next-line
import type { ProjectGraphNode, Task } from '@nrwl/devkit'; import type { ProjectGraphNode } from '@nrwl/devkit';
import { parseParentDirectoriesFromFilePath } from '../util'; import { parseParentDirectoriesFromFilePath } from '../util';
import { WorkspaceLayout } from '../interfaces'; import { WorkspaceLayout } from '../interfaces';
import Tag from '../ui-components/tag'; import Tag from '../ui-components/tag';
@ -55,6 +55,7 @@ function groupProjectsByDirectory(
function ProjectListItem({ function ProjectListItem({
project, project,
selectTask, selectTask,
selectedTaskId,
}: { }: {
project: SidebarProjectWithTargets; project: SidebarProjectWithTargets;
selectTask: ( selectTask: (
@ -62,6 +63,7 @@ function ProjectListItem({
targetName: string, targetName: string,
configurationName: string configurationName: string
) => void; ) => void;
selectedTaskId: string;
}) { }) {
return ( return (
<li className="relative block cursor-default select-none py-1 pl-2 pr-6 text-xs text-slate-600 dark:text-slate-400"> <li className="relative block cursor-default select-none py-1 pl-2 pr-6 text-xs text-slate-600 dark:text-slate-400">
@ -73,6 +75,10 @@ function ProjectListItem({
<br /> <br />
{target.configurations.map((configuration) => ( {target.configurations.map((configuration) => (
<div className="flex items-center"> <div className="flex items-center">
{selectedTaskId ===
`${project.projectGraphNode.name}:${target.targetName}:${configuration.name}` ? (
<span>selected</span>
) : null}
<button <button
data-cy={`focus-button-${configuration.name}`} data-cy={`focus-button-${configuration.name}`}
type="button" type="button"
@ -111,6 +117,7 @@ function SubProjectList({
headerText = '', headerText = '',
projects, projects,
selectTask, selectTask,
selectedTaskId,
}: { }: {
headerText: string; headerText: string;
projects: SidebarProjectWithTargets[]; projects: SidebarProjectWithTargets[];
@ -119,6 +126,7 @@ function SubProjectList({
targetName: string, targetName: string,
configurationName: string configurationName: string
) => void; ) => void;
selectedTaskId: string;
}) { }) {
let sortedProjects = [...projects]; let sortedProjects = [...projects];
sortedProjects.sort((a, b) => { sortedProjects.sort((a, b) => {
@ -139,6 +147,7 @@ function SubProjectList({
key={project.projectGraphNode.name} key={project.projectGraphNode.name}
project={project} project={project}
selectTask={selectTask} selectTask={selectTask}
selectedTaskId={selectedTaskId}
></ProjectListItem> ></ProjectListItem>
); );
})} })}
@ -232,6 +241,7 @@ export function TaskList({
mapToSidebarProjectWithTasks(project, selectedTask) mapToSidebarProjectWithTasks(project, selectedTask)
)} )}
selectTask={selectTask} selectTask={selectTask}
selectedTaskId={selectedTask}
></SubProjectList> ></SubProjectList>
); );
})} })}
@ -249,6 +259,7 @@ export function TaskList({
mapToSidebarProjectWithTasks(project, selectedTask) mapToSidebarProjectWithTasks(project, selectedTask)
)} )}
selectTask={selectTask} selectTask={selectTask}
selectedTaskId={selectedTask}
></SubProjectList> ></SubProjectList>
); );
})} })}
@ -266,6 +277,7 @@ export function TaskList({
mapToSidebarProjectWithTasks(project, selectedTask) mapToSidebarProjectWithTasks(project, selectedTask)
)} )}
selectTask={selectTask} selectTask={selectTask}
selectedTaskId={selectedTask}
></SubProjectList> ></SubProjectList>
); );
})} })}

View File

@ -1,64 +1,41 @@
import TaskList from '../sidebar/task-list'; import TaskList from './task-list';
/* nx-ignore-next-line */ /* nx-ignore-next-line */
import { ProjectGraphNode } from 'nx/src/config/project-graph'; import { ProjectGraphNode } from 'nx/src/config/project-graph';
import { useDepGraphService } from '../hooks/use-dep-graph';
import { useDepGraphSelector } from '../hooks/use-dep-graph-selector';
import {
allProjectsSelector,
workspaceLayoutSelector,
} from '../machines/selectors';
import { useTaskGraphSelector } from '../hooks/use-task-graph-selector';
import { getTaskGraphService } from '../machines/get-services';
/* eslint-disable-next-line */ /* eslint-disable-next-line */
export interface TasksSidebarProps {} export interface TasksSidebarProps {}
export function TasksSidebar(props: TasksSidebarProps) { export function TasksSidebar(props: TasksSidebarProps) {
const mockProjects: ProjectGraphNode[] = [ const projects = useDepGraphSelector(allProjectsSelector);
{ const workspaceLayout = useDepGraphSelector(workspaceLayoutSelector);
name: 'app1', const taskGraph = getTaskGraphService();
type: 'app',
data: {
root: 'apps/app1',
targets: {
build: {
configurations: { production: {}, development: {} },
defaultConfiguration: 'production',
},
},
},
},
{
name: 'nested-app',
type: 'app',
data: {
root: 'apps/nested/app',
targets: { build: { configurations: { production: {} } } },
},
},
{
name: 'app1-e2e',
type: 'e2e',
data: {
root: 'apps/app1-e2e',
targets: { e2e: { configurations: { production: {} } } },
},
},
{
name: 'lib1',
type: 'lib',
data: {
root: 'libs/lib1',
targets: { lint: { configurations: { production: {} } } },
},
},
];
const mockWorkspaceLayout = { const selectedTask = useTaskGraphSelector(
appsDir: 'apps', (state) => state.context.selectedTaskId
libsDir: 'libs', );
}; function selectTask(
projectName: string,
const mockSelectedTask = 'app1:build:production'; targetName: string,
configurationName: string
) {
const taskId = `${projectName}:${targetName}:${configurationName}`;
taskGraph.send({ type: 'selectTask', taskId });
}
return ( return (
<TaskList <TaskList
projects={mockProjects} projects={projects}
workspaceLayout={mockWorkspaceLayout} workspaceLayout={workspaceLayout}
selectedTask={mockSelectedTask} selectedTask={selectedTask}
selectTask={console.log} selectTask={selectTask}
/> />
); );
} }

View File

@ -1,5 +1,8 @@
// nx-ignore-next-line // nx-ignore-next-line
import type { DepGraphClientResponse } from 'nx/src/command-line/dep-graph'; import type {
DepGraphClientResponse,
TaskGraphClientResponse,
} from 'nx/src/command-line/dep-graph';
import { ProjectGraphService } from './interfaces'; import { ProjectGraphService } from './interfaces';
export class FetchProjectGraphService implements ProjectGraphService { export class FetchProjectGraphService implements ProjectGraphService {
@ -18,4 +21,12 @@ export class FetchProjectGraphService implements ProjectGraphService {
return response.json(); return response.json();
} }
async getTaskGraph(url: string): Promise<TaskGraphClientResponse> {
const request = new Request(url, { mode: 'no-cors' });
const response = await fetch(request);
return response.json();
}
} }

View File

@ -0,0 +1,15 @@
import { useSelector } from '@xstate/react';
import { DepGraphState, TaskGraphState } from '../machines/interfaces';
import { useDepGraphService } from './use-dep-graph';
import { getTaskGraphService } from '../machines/get-services';
export type TaskGraphSelector<T> = (depGraphState: TaskGraphState) => T;
export function useTaskGraphSelector<T>(selectorFunc: TaskGraphSelector<T>): T {
const taskGraphMachine = getTaskGraphService();
return useSelector<typeof taskGraphMachine, T>(
taskGraphMachine,
selectorFunc
);
}

View File

@ -1,10 +1,14 @@
// nx-ignore-next-line // nx-ignore-next-line
import type { DepGraphClientResponse } from 'nx/src/command-line/dep-graph'; import type {
DepGraphClientResponse,
TaskGraphClientResponse,
} from 'nx/src/command-line/dep-graph';
export interface ProjectGraphList { export interface GraphListItem {
id: string; id: string;
label: string; label: string;
url: string; projectGraphUrl: string;
taskGraphUrl: string;
} }
export interface WorkspaceLayout { export interface WorkspaceLayout {
@ -15,6 +19,7 @@ export interface WorkspaceLayout {
export interface ProjectGraphService { export interface ProjectGraphService {
getHash: () => Promise<string>; getHash: () => Promise<string>;
getProjectGraph: (url: string) => Promise<DepGraphClientResponse>; getProjectGraph: (url: string) => Promise<DepGraphClientResponse>;
getTaskGraph: (url: string) => Promise<TaskGraphClientResponse>;
} }
export interface Environment { export interface Environment {
environment: 'dev' | 'watch' | 'release'; environment: 'dev' | 'watch' | 'release';
@ -23,6 +28,6 @@ export interface Environment {
export interface AppConfig { export interface AppConfig {
showDebugger: boolean; showDebugger: boolean;
showExperimentalFeatures: boolean; showExperimentalFeatures: boolean;
projectGraphs: ProjectGraphList[]; projects: GraphListItem[];
defaultProjectGraph: string; defaultProject: string;
} }

View File

@ -1,5 +1,8 @@
// nx-ignore-next-line // nx-ignore-next-line
import type { DepGraphClientResponse } from 'nx/src/command-line/dep-graph'; import type {
DepGraphClientResponse,
TaskGraphClientResponse,
} from 'nx/src/command-line/dep-graph';
import { ProjectGraphService } from './interfaces'; import { ProjectGraphService } from './interfaces';
export class LocalProjectGraphService implements ProjectGraphService { export class LocalProjectGraphService implements ProjectGraphService {
@ -10,4 +13,8 @@ export class LocalProjectGraphService implements ProjectGraphService {
async getProjectGraph(url: string): Promise<DepGraphClientResponse> { async getProjectGraph(url: string): Promise<DepGraphClientResponse> {
return new Promise((resolve) => resolve(window.projectGraphResponse)); return new Promise((resolve) => resolve(window.projectGraphResponse));
} }
async getTaskGraph(url: string): Promise<TaskGraphClientResponse> {
return new Promise((resolve) => resolve(window.taskGraphResponse));
}
} }

View File

@ -0,0 +1,155 @@
import { assign } from '@xstate/immer';
import { ActorRef, createMachine, Machine, send, spawn } from 'xstate';
import {
ProjectGraphContext,
ProjectGraphEvents,
GraphPerfReport,
} from './interfaces';
// nx-ignore-next-line
import {
ProjectGraphDependency,
ProjectGraphProjectNode,
} from 'nx/src/config/project-graph';
import { projectGraphMachine } from './project-graph.machine';
import { taskGraphMachine, TaskGraphRecord } from './task-graph.machine';
export interface AppContext {
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
taskGraphs: TaskGraphRecord;
projectGraphActor: ActorRef<any>;
taskGraphActor: ActorRef<any>;
lastPerfReport: GraphPerfReport;
}
export const initialContext: AppContext = {
projects: [],
dependencies: {},
affectedProjects: [],
workspaceLayout: {
libsDir: '',
appsDir: '',
},
taskGraphs: {},
projectGraphActor: null,
taskGraphActor: null,
lastPerfReport: {
numEdges: 0,
numNodes: 0,
renderTime: 0,
},
};
export interface AppSchema {
states: {
idle: {};
initialized: {};
};
}
export type AppEvents =
| {
type: 'setProjects';
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
}
| {
type: 'setTaskGraphs';
taskGraphs: TaskGraphRecord;
};
export const appMachine = createMachine<AppContext, AppEvents>(
{
predictableActionArguments: true,
id: 'App',
initial: 'idle',
context: initialContext,
states: {
idle: {
entry: assign((ctx) => {
ctx.projectGraphActor = spawn(projectGraphMachine, {
name: 'projectGraphActor',
});
ctx.taskGraphActor = spawn(taskGraphMachine, {
name: 'taskGraphActor',
});
}),
},
initialized: {},
},
on: {
setProjects: {
target: 'initialized',
actions: [
'setProjects',
send(
(ctx, event) => ({
type: 'notifyProjectGraphSetProjects',
projects: ctx.projects,
dependencies: ctx.dependencies,
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
}),
{
to: (context) => context.projectGraphActor,
}
),
send(
(ctx, event) => ({
type: 'notifyTaskGraphSetProjects',
projects: ctx.projects,
taskGraphs: ctx.taskGraphs,
}),
{
to: (context) => context.taskGraphActor,
}
),
],
},
setTaskGraphs: {
target: 'initialized',
actions: [
'setTaskGraphs',
send(
(ctx, event) => ({
type: 'notifyTaskGraphSetTaskGraphs',
projects: ctx.projects,
taskGraphs: ctx.taskGraphs,
}),
{
to: (context) => context.taskGraphActor,
}
),
],
},
},
},
{
actions: {
setProjects: assign((ctx, event) => {
if (event.type !== 'setProjects') return;
ctx.projects = event.projects;
ctx.dependencies = event.dependencies;
ctx.workspaceLayout = event.workspaceLayout;
ctx.affectedProjects = event.affectedProjects;
}),
setTaskGraphs: assign((ctx, event) => {
if (event.type !== 'setTaskGraphs') return;
ctx.taskGraphs = event.taskGraphs;
}),
},
}
);

View File

@ -1,15 +0,0 @@
import { interpret, InterpreterStatus } from 'xstate';
import { depGraphMachine } from './dep-graph.machine';
// TODO: figure out what happened to make the interpret return type get so weird
let depGraphService = interpret(depGraphMachine, {
devTools: !!window.useXstateInspect,
});
export function getDepGraphService() {
if (depGraphService.status === InterpreterStatus.NotStarted) {
depGraphService.start();
}
return depGraphService;
}

View File

@ -1,9 +1,12 @@
// nx-ignore-next-line // nx-ignore-next-line
import type { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit'; import type {
ProjectGraphDependency,
ProjectGraphProjectNode,
} from '@nrwl/devkit';
import { interpret } from 'xstate'; import { interpret } from 'xstate';
import { depGraphMachine } from './dep-graph.machine'; import { projectGraphMachine } from './project-graph.machine';
export const mockProjects: ProjectGraphNode[] = [ export const mockProjects: ProjectGraphProjectNode[] = [
{ {
name: 'app1', name: 'app1',
type: 'app', type: 'app',
@ -94,13 +97,16 @@ export const mockDependencies: Record<string, ProjectGraphDependency[]> = {
describe('dep-graph machine', () => { describe('dep-graph machine', () => {
describe('initGraph', () => { describe('initGraph', () => {
it('should set projects, dependencies, and workspaceLayout', () => { it('should set projects, dependencies, and workspaceLayout', () => {
const result = depGraphMachine.transition(depGraphMachine.initialState, { const result = projectGraphMachine.transition(
type: 'initGraph', projectGraphMachine.initialState,
{
type: 'notifyProjectGraphSetProjects',
projects: mockProjects, projects: mockProjects,
dependencies: mockDependencies, dependencies: mockDependencies,
affectedProjects: [], affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' }, workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
}); }
);
expect(result.context.projects).toEqual(mockProjects); expect(result.context.projects).toEqual(mockProjects);
expect(result.context.dependencies).toEqual(mockDependencies); expect(result.context.dependencies).toEqual(mockDependencies);
expect(result.context.workspaceLayout).toEqual({ expect(result.context.workspaceLayout).toEqual({
@ -110,13 +116,16 @@ describe('dep-graph machine', () => {
}); });
it('should start with no projects selected', () => { it('should start with no projects selected', () => {
const result = depGraphMachine.transition(depGraphMachine.initialState, { const result = projectGraphMachine.transition(
type: 'initGraph', projectGraphMachine.initialState,
{
type: 'notifyProjectGraphSetProjects',
projects: mockProjects, projects: mockProjects,
dependencies: mockDependencies, dependencies: mockDependencies,
affectedProjects: [], affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' }, workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
}); }
);
expect(result.value).toEqual('unselected'); expect(result.value).toEqual('unselected');
expect(result.context.selectedProjects).toEqual([]); expect(result.context.selectedProjects).toEqual([]);
@ -125,7 +134,7 @@ describe('dep-graph machine', () => {
describe('selecting projects', () => { describe('selecting projects', () => {
it('should select projects', (done) => { it('should select projects', (done) => {
let service = interpret(depGraphMachine).onTransition((state) => { let service = interpret(projectGraphMachine).onTransition((state) => {
if ( if (
state.matches('customSelected') && state.matches('customSelected') &&
state.context.selectedProjects.includes('app1') && state.context.selectedProjects.includes('app1') &&
@ -138,7 +147,7 @@ describe('dep-graph machine', () => {
service.start(); service.start();
service.send({ service.send({
type: 'initGraph', type: 'notifyProjectGraphSetProjects',
projects: mockProjects, projects: mockProjects,
dependencies: mockDependencies, dependencies: mockDependencies,
affectedProjects: [], affectedProjects: [],
@ -159,7 +168,7 @@ describe('dep-graph machine', () => {
describe('deselecting projects', () => { describe('deselecting projects', () => {
it('should deselect projects', (done) => { it('should deselect projects', (done) => {
let service = interpret(depGraphMachine).onTransition((state) => { let service = interpret(projectGraphMachine).onTransition((state) => {
if ( if (
state.matches('customSelected') && state.matches('customSelected') &&
!state.context.selectedProjects.includes('app1') && !state.context.selectedProjects.includes('app1') &&
@ -172,7 +181,7 @@ describe('dep-graph machine', () => {
service.start(); service.start();
service.send({ service.send({
type: 'initGraph', type: 'notifyProjectGraphSetProjects',
projects: mockProjects, projects: mockProjects,
dependencies: mockDependencies, dependencies: mockDependencies,
affectedProjects: [], affectedProjects: [],
@ -196,30 +205,33 @@ describe('dep-graph machine', () => {
}); });
it('should go to unselected when last project is deselected', () => { it('should go to unselected when last project is deselected', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, { let result = projectGraphMachine.transition(
type: 'initGraph', projectGraphMachine.initialState,
{
type: 'notifyProjectGraphSetProjects',
projects: mockProjects, projects: mockProjects,
dependencies: mockDependencies, dependencies: mockDependencies,
affectedProjects: [], affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' }, workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
}); }
);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'selectProject', type: 'selectProject',
projectName: 'app1', projectName: 'app1',
}); });
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'selectProject', type: 'selectProject',
projectName: 'app2', projectName: 'app2',
}); });
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'deselectProject', type: 'deselectProject',
projectName: 'app1', projectName: 'app1',
}); });
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'deselectProject', type: 'deselectProject',
projectName: 'app2', projectName: 'app2',
}); });
@ -231,15 +243,18 @@ describe('dep-graph machine', () => {
describe('focusing projects', () => { describe('focusing projects', () => {
it('should set the focused project', () => { it('should set the focused project', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, { let result = projectGraphMachine.transition(
type: 'initGraph', projectGraphMachine.initialState,
{
type: 'notifyProjectGraphSetProjects',
projects: mockProjects, projects: mockProjects,
dependencies: mockDependencies, dependencies: mockDependencies,
affectedProjects: [], affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' }, workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
}); }
);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'focusProject', type: 'focusProject',
projectName: 'app1', projectName: 'app1',
}); });
@ -249,7 +264,7 @@ describe('dep-graph machine', () => {
}); });
it('should select the projects by the focused project', (done) => { it('should select the projects by the focused project', (done) => {
let service = interpret(depGraphMachine).onTransition((state) => { let service = interpret(projectGraphMachine).onTransition((state) => {
if ( if (
state.matches('focused') && state.matches('focused') &&
state.context.selectedProjects.includes('app1') && state.context.selectedProjects.includes('app1') &&
@ -264,7 +279,7 @@ describe('dep-graph machine', () => {
service.start(); service.start();
service.send({ service.send({
type: 'initGraph', type: 'notifyProjectGraphSetProjects',
projects: mockProjects, projects: mockProjects,
dependencies: mockDependencies, dependencies: mockDependencies,
affectedProjects: [], affectedProjects: [],
@ -278,20 +293,23 @@ describe('dep-graph machine', () => {
}); });
it('should select no projects on unfocus', () => { it('should select no projects on unfocus', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, { let result = projectGraphMachine.transition(
type: 'initGraph', projectGraphMachine.initialState,
{
type: 'notifyProjectGraphSetProjects',
projects: mockProjects, projects: mockProjects,
dependencies: mockDependencies, dependencies: mockDependencies,
affectedProjects: [], affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' }, workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
}); }
);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'focusProject', type: 'focusProject',
projectName: 'app1', projectName: 'app1',
}); });
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'unfocusProject', type: 'unfocusProject',
}); });
@ -302,60 +320,63 @@ describe('dep-graph machine', () => {
describe('search depth', () => { describe('search depth', () => {
it('should not decrement search depth below 1', () => { it('should not decrement search depth below 1', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, { let result = projectGraphMachine.transition(
type: 'initGraph', projectGraphMachine.initialState,
{
type: 'notifyProjectGraphSetProjects',
projects: mockProjects, projects: mockProjects,
dependencies: mockDependencies, dependencies: mockDependencies,
affectedProjects: [], affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' }, workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
}); }
);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'filterByText', type: 'filterByText',
search: 'app1', search: 'app1',
}); });
expect(result.context.searchDepth).toEqual(1); expect(result.context.searchDepth).toEqual(1);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'incrementSearchDepth', type: 'incrementSearchDepth',
}); });
expect(result.context.searchDepth).toEqual(2); expect(result.context.searchDepth).toEqual(2);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'incrementSearchDepth', type: 'incrementSearchDepth',
}); });
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'incrementSearchDepth', type: 'incrementSearchDepth',
}); });
expect(result.context.searchDepth).toEqual(4); expect(result.context.searchDepth).toEqual(4);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'decrementSearchDepth', type: 'decrementSearchDepth',
}); });
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'decrementSearchDepth', type: 'decrementSearchDepth',
}); });
expect(result.context.searchDepth).toEqual(2); expect(result.context.searchDepth).toEqual(2);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'decrementSearchDepth', type: 'decrementSearchDepth',
}); });
expect(result.context.searchDepth).toEqual(1); expect(result.context.searchDepth).toEqual(1);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'decrementSearchDepth', type: 'decrementSearchDepth',
}); });
expect(result.context.searchDepth).toEqual(1); expect(result.context.searchDepth).toEqual(1);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'decrementSearchDepth', type: 'decrementSearchDepth',
}); });
@ -363,35 +384,38 @@ describe('dep-graph machine', () => {
}); });
it('should activate search depth if incremented or decremented', () => { it('should activate search depth if incremented or decremented', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, { let result = projectGraphMachine.transition(
type: 'initGraph', projectGraphMachine.initialState,
{
type: 'notifyProjectGraphSetProjects',
projects: mockProjects, projects: mockProjects,
dependencies: mockDependencies, dependencies: mockDependencies,
affectedProjects: [], affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' }, workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
}); }
);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'setSearchDepthEnabled', type: 'setSearchDepthEnabled',
searchDepthEnabled: false, searchDepthEnabled: false,
}); });
expect(result.context.searchDepthEnabled).toBe(false); expect(result.context.searchDepthEnabled).toBe(false);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'incrementSearchDepth', type: 'incrementSearchDepth',
}); });
expect(result.context.searchDepthEnabled).toBe(true); expect(result.context.searchDepthEnabled).toBe(true);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'setSearchDepthEnabled', type: 'setSearchDepthEnabled',
searchDepthEnabled: false, searchDepthEnabled: false,
}); });
expect(result.context.searchDepthEnabled).toBe(false); expect(result.context.searchDepthEnabled).toBe(false);
result = depGraphMachine.transition(result, { result = projectGraphMachine.transition(result, {
type: 'decrementSearchDepth', type: 'decrementSearchDepth',
}); });

View File

@ -1,7 +1,7 @@
import { getDepGraphService } from './dep-graph.service'; import { getProjectGraphService } from './get-services';
export class ExternalApi { export class ExternalApi {
depGraphService = getDepGraphService(); depGraphService = getProjectGraphService();
focusProject(projectName: string) { focusProject(projectName: string) {
this.depGraphService.send({ type: 'focusProject', projectName }); this.depGraphService.send({ type: 'focusProject', projectName });

View File

@ -0,0 +1,25 @@
import { interpret, InterpreterStatus } from 'xstate';
import { appMachine } from './app.machine';
let appService = interpret(appMachine, {
devTools: !!window.useXstateInspect,
});
export function getAppService() {
if (appService.status === InterpreterStatus.NotStarted) {
appService.start();
}
return appService;
}
export function getProjectGraphService() {
const appService = getAppService();
const depGraphService = appService.getSnapshot().context.projectGraphActor;
return depGraphService;
}
export function getTaskGraphService() {
const appService = getAppService();
const taskGraph = appService.getSnapshot().context.taskGraphActor;
return taskGraph;
}

View File

@ -4,9 +4,10 @@ import type {
ProjectGraphProjectNode, ProjectGraphProjectNode,
} from '@nrwl/devkit'; } from '@nrwl/devkit';
import { ActionObject, ActorRef, State, StateNodeConfig } from 'xstate'; import { ActionObject, ActorRef, State, StateNodeConfig } from 'xstate';
import { TaskGraphContext, TaskGraphEvents } from './task-graph.machine';
// The hierarchical (recursive) schema for the states // The hierarchical (recursive) schema for the states
export interface DepGraphSchema { export interface ProjectGraphSchema {
states: { states: {
idle: {}; idle: {};
unselected: {}; unselected: {};
@ -26,7 +27,7 @@ export interface GraphPerfReport {
export type TracingAlgorithmType = 'shortest' | 'all'; export type TracingAlgorithmType = 'shortest' | 'all';
// The events that the machine handles // The events that the machine handles
export type DepGraphUIEvents = export type ProjectGraphEvents =
| { | {
type: 'setSelectedProjectsFromGraph'; type: 'setSelectedProjectsFromGraph';
selectedProjectNames: string[]; selectedProjectNames: string[];
@ -56,7 +57,7 @@ export type DepGraphUIEvents =
| { type: 'filterByText'; search: string } | { type: 'filterByText'; search: string }
| { type: 'clearTextFilter' } | { type: 'clearTextFilter' }
| { | {
type: 'initGraph'; type: 'notifyProjectGraphSetProjects';
projects: ProjectGraphProjectNode[]; projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>; dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[]; affectedProjects: string[];
@ -169,10 +170,8 @@ export type RouteEvents =
algorithm: TracingAlgorithmType; algorithm: TracingAlgorithmType;
}; };
export type AllEvents = DepGraphUIEvents | GraphRenderEvents | RouteEvents;
// The context (extended state) of the machine // The context (extended state) of the machine
export interface DepGraphContext { export interface ProjectGraphContext {
projects: ProjectGraphProjectNode[]; projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>; dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[]; affectedProjects: string[];
@ -190,7 +189,7 @@ export interface DepGraphContext {
}; };
graphActor: ActorRef<GraphRenderEvents>; graphActor: ActorRef<GraphRenderEvents>;
routeSetterActor: ActorRef<RouteEvents>; routeSetterActor: ActorRef<RouteEvents>;
routeListenerActor: ActorRef<DepGraphUIEvents>; routeListenerActor: ActorRef<ProjectGraphEvents>;
lastPerfReport: GraphPerfReport; lastPerfReport: GraphPerfReport;
tracing: { tracing: {
start: string; start: string;
@ -200,22 +199,32 @@ export interface DepGraphContext {
} }
export type DepGraphStateNodeConfig = StateNodeConfig< export type DepGraphStateNodeConfig = StateNodeConfig<
DepGraphContext, ProjectGraphContext,
{}, {},
DepGraphUIEvents, ProjectGraphEvents,
ActionObject<DepGraphContext, DepGraphUIEvents> ActionObject<ProjectGraphContext, ProjectGraphEvents>
>; >;
export type DepGraphSend = ( export type DepGraphSend = (
event: DepGraphUIEvents | DepGraphUIEvents[] event: ProjectGraphEvents | ProjectGraphEvents[]
) => void; ) => void;
export type DepGraphState = State< export type DepGraphState = State<
DepGraphContext, ProjectGraphContext,
DepGraphUIEvents, ProjectGraphEvents,
any, any,
{ {
value: any; value: any;
context: DepGraphContext; context: ProjectGraphContext;
}
>;
export type TaskGraphState = State<
TaskGraphContext,
TaskGraphEvents,
any,
{
value: any;
context: TaskGraphContext;
} }
>; >;

View File

@ -1,19 +1,19 @@
import { assign } from '@xstate/immer'; import { assign } from '@xstate/immer';
import { Machine, send, spawn } from 'xstate'; import { createMachine, Machine, send, spawn } from 'xstate';
import { customSelectedStateConfig } from './custom-selected.state'; import { customSelectedStateConfig } from './custom-selected.state';
import { focusedStateConfig } from './focused.state'; import { focusedStateConfig } from './focused.state';
import { graphActor } from './graph.actor'; import { graphActor } from './graph.actor';
import { import {
DepGraphContext, ProjectGraphContext,
DepGraphSchema, ProjectGraphSchema,
DepGraphUIEvents, ProjectGraphEvents,
} from './interfaces'; } from './interfaces';
import { createRouteMachine } from './route-setter.machine'; import { createRouteMachine } from './route-setter.machine';
import { textFilteredStateConfig } from './text-filtered.state'; import { textFilteredStateConfig } from './text-filtered.state';
import { tracingStateConfig } from './tracing.state'; import { tracingStateConfig } from './tracing.state';
import { unselectedStateConfig } from './unselected.state'; import { unselectedStateConfig } from './unselected.state';
export const initialContext: DepGraphContext = { export const initialContext: ProjectGraphContext = {
projects: [], projects: [],
dependencies: {}, dependencies: {},
affectedProjects: [], affectedProjects: [],
@ -44,12 +44,12 @@ export const initialContext: DepGraphContext = {
}, },
}; };
export const depGraphMachine = Machine< export const projectGraphMachine = createMachine<
DepGraphContext, ProjectGraphContext,
DepGraphSchema, ProjectGraphEvents
DepGraphUIEvents
>( >(
{ {
predictableActionArguments: true,
id: 'DepGraph', id: 'DepGraph',
initial: 'idle', initial: 'idle',
context: initialContext, context: initialContext,
@ -62,7 +62,7 @@ export const depGraphMachine = Machine<
tracing: tracingStateConfig, tracing: tracingStateConfig,
}, },
on: { on: {
initGraph: { notifyProjectGraphSetProjects: {
target: 'unselected', target: 'unselected',
actions: [ actions: [
'setGraph', 'setGraph',
@ -284,7 +284,11 @@ export const depGraphMachine = Machine<
ctx.includePath = event.includeProjectsByPath; ctx.includePath = event.includeProjectsByPath;
}), }),
setGraph: assign((ctx, event) => { setGraph: assign((ctx, event) => {
if (event.type !== 'initGraph' && event.type !== 'updateGraph') return; if (
event.type !== 'notifyProjectGraphSetProjects' &&
event.type !== 'updateGraph'
)
return;
ctx.projects = event.projects; ctx.projects = event.projects;
ctx.dependencies = event.dependencies; ctx.dependencies = event.dependencies;
@ -293,7 +297,7 @@ export const depGraphMachine = Machine<
name: 'route', name: 'route',
}); });
if (event.type === 'initGraph') { if (event.type === 'notifyProjectGraphSetProjects') {
ctx.workspaceLayout = event.workspaceLayout; ctx.workspaceLayout = event.workspaceLayout;
ctx.affectedProjects = event.affectedProjects; ctx.affectedProjects = event.affectedProjects;
} }

View File

@ -1,9 +1,9 @@
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
import { InvokeCallback } from 'xstate'; import { InvokeCallback } from 'xstate';
import { DepGraphUIEvents } from './interfaces'; import { ProjectGraphEvents } from './interfaces';
function parseSearchParamsToEvents(searchParams: string): DepGraphUIEvents[] { function parseSearchParamsToEvents(searchParams: string): ProjectGraphEvents[] {
const events: DepGraphUIEvents[] = []; const events: ProjectGraphEvents[] = [];
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
params.forEach((value, key) => { params.forEach((value, key) => {
@ -60,8 +60,8 @@ function parseSearchParamsToEvents(searchParams: string): DepGraphUIEvents[] {
} }
export const routeListener: InvokeCallback< export const routeListener: InvokeCallback<
DepGraphUIEvents, ProjectGraphEvents,
DepGraphUIEvents ProjectGraphEvents
> = (callback) => { > = (callback) => {
const history = createBrowserHistory(); const history = createBrowserHistory();

View File

@ -1,7 +1,11 @@
import { assign } from '@xstate/immer'; import { assign } from '@xstate/immer';
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
import { Machine } from 'xstate'; import { createMachine, Machine } from 'xstate';
import { RouteEvents } from './interfaces'; import {
ProjectGraphContext,
ProjectGraphEvents,
RouteEvents,
} from './interfaces';
type ParamKeys = type ParamKeys =
| 'focus' | 'focus'
@ -26,6 +30,11 @@ function reduceParamRecordToQueryString(params: ParamRecord): string {
return new URLSearchParams(newParams).toString(); return new URLSearchParams(newParams).toString();
} }
export interface RouteSetterContext {
currentParamString: string;
params: Record<ParamKeys, string | null>;
}
export const createRouteMachine = () => { export const createRouteMachine = () => {
const history = createBrowserHistory(); const history = createBrowserHistory();
@ -41,17 +50,14 @@ export const createRouteMachine = () => {
traceAlgorithm: params.get('traceAlgorithm'), traceAlgorithm: params.get('traceAlgorithm'),
}; };
const initialContext = { const initialContext: RouteSetterContext = {
currentParamString: reduceParamRecordToQueryString(paramRecord), currentParamString: reduceParamRecordToQueryString(paramRecord),
params: paramRecord, params: paramRecord,
}; };
return Machine< return createMachine<RouteSetterContext, RouteEvents>(
{ currentParamString: string; params: Record<ParamKeys, string | null> },
{},
RouteEvents
>(
{ {
predictableActionArguments: true,
id: 'route', id: 'route',
context: { context: {
currentParamString: '', currentParamString: '',

View File

@ -0,0 +1,75 @@
import { createMachine } from 'xstate';
// nx-ignore-next-line
import { ProjectGraphProjectNode } from 'nx/src/config/project-graph';
import { assign } from '@xstate/immer';
export type TaskGraphRecord = Record<
string,
Record<string, Record<string, any>>
>;
export interface TaskGraphContext {
selectedTaskId: string;
projects: ProjectGraphProjectNode[];
taskGraphs: TaskGraphRecord;
}
const initialContext: TaskGraphContext = {
selectedTaskId: null,
projects: [],
taskGraphs: {},
};
export type TaskGraphEvents =
| {
type: 'notifyTaskGraphSetProjects';
projects: ProjectGraphProjectNode[];
taskGraphs: TaskGraphRecord;
}
| {
type: 'selectTask';
taskId: string;
};
export const taskGraphMachine = createMachine<
TaskGraphContext,
TaskGraphEvents
>(
{
predictableActionArguments: true,
initial: 'idle',
context: initialContext,
states: {
idle: {
on: {
notifyTaskGraphSetProjects: {
actions: ['setProjects'],
target: 'initialized',
},
},
},
initialized: {
on: {
selectTask: {
actions: ['selectTask'],
},
},
},
},
},
{
actions: {
setProjects: assign((ctx, event) => {
if (event.type !== 'notifyTaskGraphSetProjects') return;
ctx.projects = event.projects;
ctx.taskGraphs = event.taskGraphs;
}),
selectTask: assign((ctx, event) => {
if (event.type !== 'selectTask') return;
ctx.selectedTaskId = event.taskId;
}),
},
}
);

View File

@ -4,11 +4,14 @@ import type {
ProjectGraphProjectNode, ProjectGraphProjectNode,
} from '@nrwl/devkit'; } from '@nrwl/devkit';
// nx-ignore-next-line // nx-ignore-next-line
import type { DepGraphClientResponse } from 'nx/src/command-line/dep-graph'; import type {
DepGraphClientResponse,
TaskGraphClientResponse,
} from 'nx/src/command-line/dep-graph';
import { ProjectGraphService } from '../app/interfaces'; import { ProjectGraphService } from '../app/interfaces';
export class MockProjectGraphService implements ProjectGraphService { export class MockProjectGraphService implements ProjectGraphService {
private response: DepGraphClientResponse = { private projectGraphsResponse: DepGraphClientResponse = {
hash: '79054025255fb1a26e4bc422aef54eb4', hash: '79054025255fb1a26e4bc422aef54eb4',
layout: { layout: {
appsDir: 'apps', appsDir: 'apps',
@ -56,21 +59,27 @@ export class MockProjectGraphService implements ProjectGraphService {
groupByFolder: false, groupByFolder: false,
}; };
private taskGraphsResponse: TaskGraphClientResponse = { dependencies: {} };
constructor(updateFrequency: number = 5000) { constructor(updateFrequency: number = 5000) {
setInterval(() => this.updateResponse(), updateFrequency); setInterval(() => this.updateResponse(), updateFrequency);
} }
async getHash(): Promise<string> { async getHash(): Promise<string> {
return new Promise((resolve) => resolve(this.response.hash)); return new Promise((resolve) => resolve(this.projectGraphsResponse.hash));
} }
getProjectGraph(url: string): Promise<DepGraphClientResponse> { getProjectGraph(url: string): Promise<DepGraphClientResponse> {
return new Promise((resolve) => resolve(this.response)); return new Promise((resolve) => resolve(this.projectGraphsResponse));
}
getTaskGraph(url: string): Promise<TaskGraphClientResponse> {
return new Promise((resolve) => resolve(this.taskGraphsResponse));
} }
private createNewProject(): ProjectGraphProjectNode { private createNewProject(): ProjectGraphProjectNode {
const type = Math.random() > 0.25 ? 'lib' : 'app'; const type = Math.random() > 0.25 ? 'lib' : 'app';
const name = `${type}-${this.response.projects.length + 1}`; const name = `${type}-${this.projectGraphsResponse.projects.length + 1}`;
return { return {
name, name,
@ -85,7 +94,7 @@ export class MockProjectGraphService implements ProjectGraphService {
private updateResponse() { private updateResponse() {
const newProject = this.createNewProject(); const newProject = this.createNewProject();
const libProjects = this.response.projects.filter( const libProjects = this.projectGraphsResponse.projects.filter(
(project) => project.type === 'lib' (project) => project.type === 'lib'
); );
@ -99,11 +108,11 @@ export class MockProjectGraphService implements ProjectGraphService {
}, },
]; ];
this.response = { this.projectGraphsResponse = {
...this.response, ...this.projectGraphsResponse,
projects: [...this.response.projects, newProject], projects: [...this.projectGraphsResponse.projects, newProject],
dependencies: { dependencies: {
...this.response.dependencies, ...this.projectGraphsResponse.dependencies,
[newProject.name]: newDependency, [newProject.name]: newDependency,
}, },
}; };

View File

@ -3,7 +3,7 @@ import { redirect } from 'react-router-dom';
import ProjectsSidebar from './feature-projects/projects-sidebar'; import ProjectsSidebar from './feature-projects/projects-sidebar';
import TasksSidebar from './feature-tasks/tasks-sidebar'; import TasksSidebar from './feature-tasks/tasks-sidebar';
import { getEnvironmentConfig } from './hooks/use-environment-config'; import { getEnvironmentConfig } from './hooks/use-environment-config';
import { getDepGraphService } from './machines/dep-graph.service'; import { getProjectGraphService } from './machines/get-services';
// nx-ignore-next-line // nx-ignore-next-line
import { DepGraphClientResponse } from 'nx/src/command-line/dep-graph'; import { DepGraphClientResponse } from 'nx/src/command-line/dep-graph';
import { getProjectGraphDataService } from './hooks/get-project-graph-data-service'; import { getProjectGraphDataService } from './hooks/get-project-graph-data-service';

View File

@ -1,6 +1,6 @@
import { import {
ArrowLeftCircleIcon,
ArrowDownTrayIcon, ArrowDownTrayIcon,
ArrowLeftCircleIcon,
InformationCircleIcon, InformationCircleIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import Tippy from '@tippyjs/react'; import Tippy from '@tippyjs/react';
@ -10,8 +10,7 @@ import type { DepGraphClientResponse } from 'nx/src/command-line/dep-graph';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { useSyncExternalStore } from 'use-sync-external-store/shim';
import DebuggerPanel from './debugger-panel'; import DebuggerPanel from './ui-components/debugger-panel';
import { useDepGraphService } from './hooks/use-dep-graph';
import { useDepGraphSelector } from './hooks/use-dep-graph-selector'; import { useDepGraphSelector } from './hooks/use-dep-graph-selector';
import { useEnvironmentConfig } from './hooks/use-environment-config'; import { useEnvironmentConfig } from './hooks/use-environment-config';
import { useIntervalWhen } from './hooks/use-interval-when'; import { useIntervalWhen } from './hooks/use-interval-when';
@ -21,27 +20,19 @@ import {
lastPerfReportSelector, lastPerfReportSelector,
projectIsSelectedSelector, projectIsSelectedSelector,
} from './machines/selectors'; } from './machines/selectors';
import ProjectsSidebar from './feature-projects/projects-sidebar';
import { selectValueByThemeStatic } from './theme-resolver'; import { selectValueByThemeStatic } from './theme-resolver';
import { getTooltipService } from './tooltip-service';
import ProjectNodeToolTip from './project-node-tooltip';
import EdgeNodeTooltip from './edge-tooltip';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import ThemePanel from './sidebar/theme-panel'; import ThemePanel from './feature-projects/panels/theme-panel';
import Dropdown from './ui-components/dropdown'; import Dropdown from './ui-components/dropdown';
import { useCurrentPath } from './hooks/use-current-path'; import { useCurrentPath } from './hooks/use-current-path';
import ExperimentalFeature from './experimental-feature'; import ExperimentalFeature from './experimental-feature';
import RankdirPanel from './sidebar/rankdir-panel'; import RankdirPanel from './feature-projects/panels/rankdir-panel';
import { getAppService, getProjectGraphService } from './machines/get-services';
const tooltipService = getTooltipService(); import TooltipDisplay from './ui-tooltips/graph-tooltip-display';
export function Shell(): JSX.Element { export function Shell(): JSX.Element {
const depGraphService = useDepGraphService(); const appService = getAppService();
const depGraphService = getProjectGraphService();
const currentTooltip = useSyncExternalStore(
(callback) => tooltipService.subscribe(callback),
() => tooltipService.currentTooltip
);
const projectGraphService = getProjectGraphDataService(); const projectGraphService = getProjectGraphDataService();
const environment = useEnvironmentConfig(); const environment = useEnvironmentConfig();
@ -50,7 +41,7 @@ export function Shell(): JSX.Element {
const environmentConfig = useEnvironmentConfig(); const environmentConfig = useEnvironmentConfig();
const [selectedProjectId, setSelectedProjectId] = useState<string>( const [selectedProjectId, setSelectedProjectId] = useState<string>(
environment.appConfig.defaultProjectGraph environment.appConfig.defaultProject
); );
const navigate = useNavigate(); const navigate = useNavigate();
@ -71,36 +62,54 @@ export function Shell(): JSX.Element {
useEffect(() => { useEffect(() => {
const { appConfig } = environment; const { appConfig } = environment;
const projectInfo = appConfig.projectGraphs.find( const projectInfo = appConfig.projects.find(
(graph) => graph.id === selectedProjectId (graph) => graph.id === selectedProjectId
); );
const fetchProjectGraph = async () => { const fetchProjectGraph = async () => {
const project: DepGraphClientResponse = const projectGraph: DepGraphClientResponse =
await projectGraphService.getProjectGraph(projectInfo.url); await projectGraphService.getProjectGraph(projectInfo.projectGraphUrl);
const workspaceLayout = project?.layout; const workspaceLayout = projectGraph?.layout;
depGraphService.send({ appService.send({
type: 'initGraph', type: 'setProjects',
projects: project.projects, projects: projectGraph.projects,
dependencies: project.dependencies, dependencies: projectGraph.dependencies,
affectedProjects: project.affected, affectedProjects: projectGraph.affected,
workspaceLayout: workspaceLayout, workspaceLayout: workspaceLayout,
}); });
}; };
const fetchTaskGraphs = async () => {
const taskGraphs = await projectGraphService.getTaskGraph(
projectInfo.taskGraphUrl
);
appService.send({
type: 'setTaskGraphs',
taskGraphs: taskGraphs.dependencies,
});
};
fetchProjectGraph(); fetchProjectGraph();
}, [selectedProjectId, environment, depGraphService, projectGraphService]);
if (currentRoute === '/tasks') {
fetchTaskGraphs();
}
}, [selectedProjectId, environment, appService, projectGraphService]);
useIntervalWhen( useIntervalWhen(
() => { () => {
const projectInfo = environment.appConfig.projectGraphs.find( const projectInfo = environment.appConfig.projects.find(
(graph) => graph.id === selectedProjectId (graph) => graph.id === selectedProjectId
); );
const fetchProjectGraph = async () => { const fetchProjectGraph = async () => {
const project: DepGraphClientResponse = const project: DepGraphClientResponse =
await projectGraphService.getProjectGraph(projectInfo.url); await projectGraphService.getProjectGraph(
projectInfo.projectGraphUrl
);
depGraphService.send({ depGraphService.send({
type: 'updateGraph', type: 'updateGraph',
@ -205,10 +214,10 @@ export function Shell(): JSX.Element {
> >
{environment.appConfig.showDebugger ? ( {environment.appConfig.showDebugger ? (
<DebuggerPanel <DebuggerPanel
projectGraphs={environment.appConfig.projectGraphs} projects={environment.appConfig.projects}
selectedProjectGraph={selectedProjectId} selectedProject={selectedProjectId}
lastPerfReport={lastPerfReport} lastPerfReport={lastPerfReport}
projectGraphChange={projectChange} selectedProjectChange={projectChange}
></DebuggerPanel> ></DebuggerPanel>
) : null} ) : null}
@ -223,25 +232,7 @@ export function Shell(): JSX.Element {
) : null} ) : null}
<div id="graph-container"> <div id="graph-container">
<div id="cytoscape-graph"></div> <div id="cytoscape-graph"></div>
{currentTooltip ? ( <TooltipDisplay></TooltipDisplay>
<Tippy
content={
currentTooltip.type === 'node' ? (
<ProjectNodeToolTip
{...currentTooltip.props}
></ProjectNodeToolTip>
) : (
<EdgeNodeTooltip {...currentTooltip.props}></EdgeNodeTooltip>
)
}
visible={true}
getReferenceClientRect={currentTooltip.ref.getBoundingClientRect}
theme={selectValueByThemeStatic('dark-nx', 'nx')}
interactive={true}
appendTo={document.body}
maxWidth="none"
></Tippy>
) : null}
<Tippy <Tippy
content="Download Graph as PNG" content="Download Graph as PNG"

View File

@ -1,14 +1,14 @@
import { createContext } from 'react'; import { createContext } from 'react';
import { InterpreterFrom } from 'xstate'; import { InterpreterFrom } from 'xstate';
import { depGraphMachine } from './machines/dep-graph.machine'; import { projectGraphMachine } from './machines/project-graph.machine';
import { getDepGraphService } from './machines/dep-graph.service'; import { getProjectGraphService } from './machines/get-services';
export const GlobalStateContext = createContext< export const GlobalStateContext = createContext<
InterpreterFrom<typeof depGraphMachine> InterpreterFrom<typeof projectGraphMachine>
>({} as InterpreterFrom<typeof depGraphMachine>); >({} as InterpreterFrom<typeof projectGraphMachine>);
export const GlobalStateProvider = (props) => { export const GlobalStateProvider = (props) => {
const depGraphService = getDepGraphService(); const depGraphService = getProjectGraphService();
return ( return (
<GlobalStateContext.Provider value={depGraphService as any}> <GlobalStateContext.Provider value={depGraphService as any}>

View File

@ -5,7 +5,7 @@ export default {
component: DebuggerPanel, component: DebuggerPanel,
title: 'Shell/DebuggerPanel', title: 'Shell/DebuggerPanel',
argTypes: { argTypes: {
projectGraphChange: { action: 'projectGraphChange' }, selectedProjectChange: { action: 'projectGraphChange' },
}, },
} as ComponentMeta<typeof DebuggerPanel>; } as ComponentMeta<typeof DebuggerPanel>;

View File

@ -1,19 +1,19 @@
import { memo } from 'react'; import { memo } from 'react';
import { ProjectGraphList } from './interfaces'; import { GraphListItem } from '../interfaces';
import { GraphPerfReport } from './machines/interfaces'; import { GraphPerfReport } from '../machines/interfaces';
import Dropdown from './ui-components/dropdown'; import Dropdown from './dropdown';
export interface DebuggerPanelProps { export interface DebuggerPanelProps {
projectGraphs: ProjectGraphList[]; projects: GraphListItem[];
selectedProjectGraph: string; selectedProject: string;
projectGraphChange: (projectName: string) => void; selectedProjectChange: (projectName: string) => void;
lastPerfReport: GraphPerfReport; lastPerfReport: GraphPerfReport;
} }
export const DebuggerPanel = memo(function ({ export const DebuggerPanel = memo(function ({
projectGraphs, projects,
selectedProjectGraph, selectedProject,
projectGraphChange, selectedProjectChange,
lastPerfReport, lastPerfReport,
}: DebuggerPanelProps) { }: DebuggerPanelProps) {
return ( return (
@ -26,14 +26,14 @@ export const DebuggerPanel = memo(function ({
</h4> </h4>
<Dropdown <Dropdown
data-cy="project-select" data-cy="project-select"
onChange={(event) => projectGraphChange(event.currentTarget.value)} onChange={(event) => selectedProjectChange(event.currentTarget.value)}
> >
{projectGraphs.map((projectGraph) => { {projects.map((projectGraph) => {
return ( return (
<option <option
key={projectGraph.id} key={projectGraph.id}
value={projectGraph.id} value={projectGraph.id}
selected={projectGraph.id === selectedProjectGraph} selected={projectGraph.id === selectedProject}
> >
{projectGraph.label} {projectGraph.label}
</option> </option>

View File

@ -1,7 +1,7 @@
import { ComponentStory, ComponentMeta } from '@storybook/react'; import { ComponentStory, ComponentMeta } from '@storybook/react';
import { EdgeNodeTooltip, EdgeNodeTooltipProps } from './edge-tooltip'; import { EdgeNodeTooltip, EdgeNodeTooltipProps } from './edge-tooltip';
import ProjectNodeToolTip from './project-node-tooltip'; import ProjectNodeToolTip from './project-node-tooltip';
import { selectValueByThemeStatic } from './theme-resolver'; import { selectValueByThemeStatic } from '../theme-resolver';
import Tippy from '@tippyjs/react'; import Tippy from '@tippyjs/react';
export default { export default {

View File

@ -1,4 +1,4 @@
import Tag from './ui-components/tag'; import Tag from '../ui-components/tag';
export interface EdgeNodeTooltipProps { export interface EdgeNodeTooltipProps {
type: string; type: string;

View File

@ -0,0 +1,35 @@
import Tippy from '@tippyjs/react';
import ProjectNodeToolTip from './project-node-tooltip';
import EdgeNodeTooltip from './edge-tooltip';
import { selectValueByThemeStatic } from '../theme-resolver';
import { useSyncExternalStore } from 'use-sync-external-store/shim';
import { getTooltipService } from './tooltip-service';
const tooltipService = getTooltipService();
export function TooltipDisplay() {
const currentTooltip = useSyncExternalStore(
(callback) => tooltipService.subscribe(callback),
() => tooltipService.currentTooltip
);
return currentTooltip ? (
<Tippy
content={
currentTooltip.type === 'node' ? (
<ProjectNodeToolTip {...currentTooltip.props}></ProjectNodeToolTip>
) : (
<EdgeNodeTooltip {...currentTooltip.props}></EdgeNodeTooltip>
)
}
visible={true}
getReferenceClientRect={currentTooltip.ref.getBoundingClientRect}
theme={selectValueByThemeStatic('dark-nx', 'nx')}
interactive={true}
appendTo={document.body}
maxWidth="none"
></Tippy>
) : null;
}
export default TooltipDisplay;

View File

@ -1,10 +1,10 @@
import { getDepGraphService } from './machines/dep-graph.service'; import { getProjectGraphService } from '../machines/get-services';
import { import {
DocumentMagnifyingGlassIcon, DocumentMagnifyingGlassIcon,
FlagIcon, FlagIcon,
MapPinIcon, MapPinIcon,
} from '@heroicons/react/24/solid'; } from '@heroicons/react/24/solid';
import Tag from './ui-components/tag'; import Tag from '../ui-components/tag';
export interface ProjectNodeToolTipProps { export interface ProjectNodeToolTipProps {
type: 'app' | 'lib' | 'e2e'; type: 'app' | 'lib' | 'e2e';
@ -17,7 +17,7 @@ export function ProjectNodeToolTip({
id, id,
tags, tags,
}: ProjectNodeToolTipProps) { }: ProjectNodeToolTipProps) {
const depGraphService = getDepGraphService(); const depGraphService = getProjectGraphService();
function onFocus() { function onFocus() {
depGraphService.send({ depGraphService.send({

View File

@ -1,4 +1,4 @@
import { getGraphService } from './machines/graph.service'; import { getGraphService } from '../machines/graph.service';
import { VirtualElement } from '@popperjs/core'; import { VirtualElement } from '@popperjs/core';
import { ProjectNodeToolTipProps } from './project-node-tooltip'; import { ProjectNodeToolTipProps } from './project-node-tooltip';

View File

@ -6,17 +6,19 @@ window.useXstateInspect = false;
window.appConfig = { window.appConfig = {
showDebugger: true, showDebugger: true,
showExperimentalFeatures: true, showExperimentalFeatures: true,
projectGraphs: [ projects: [
{ {
id: 'e2e', id: 'e2e',
label: 'e2e', label: 'e2e',
url: 'assets/graphs/e2e.json', projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
}, },
{ {
id: 'affected', id: 'affected',
label: 'affected', label: 'affected',
url: 'assets/graphs/affected.json', projectGraphUrl: 'assets/project-graphs/affected.json',
taskGraphUrl: 'assets/task-graphs/affected.json',
}, },
], ],
defaultProjectGraph: 'e2e', defaultProject: 'e2e',
}; };

View File

@ -6,12 +6,13 @@ window.useXstateInspect = false;
window.appConfig = { window.appConfig = {
showDebugger: false, showDebugger: false,
showExperimentalFeatures: false, showExperimentalFeatures: false,
projectGraphs: [ projects: [
{ {
id: 'local', id: 'local',
label: 'local', label: 'local',
url: 'assets/graphs/e2e.json', projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
}, },
], ],
defaultProjectGraph: 'local', defaultProject: 'local',
}; };

View File

@ -7,14 +7,15 @@ window.localMode = 'build';
window.appConfig = { window.appConfig = {
showDebugger: false, showDebugger: false,
showExperimentalFeatures: false, showExperimentalFeatures: false,
projectGraphs: [ projects: [
{ {
id: 'local', id: 'local',
label: 'local', label: 'local',
url: 'assets/graphs/e2e.json', projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
}, },
], ],
defaultProjectGraph: 'local', defaultProject: 'local',
}; };
window.projectGraphResponse = { window.projectGraphResponse = {

View File

@ -6,12 +6,13 @@ window.useXstateInspect = false;
window.appConfig = { window.appConfig = {
showDebugger: false, showDebugger: false,
showExperimentalFeatures: false, showExperimentalFeatures: false,
projectGraphs: [ projects: [
{ {
id: 'local', id: 'local',
label: 'local', label: 'local',
url: 'assets/graphs/e2e.json', projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
}, },
], ],
defaultProjectGraph: 'local', defaultProject: 'local',
}; };

View File

@ -6,12 +6,13 @@ window.useXstateInspect = false;
window.appConfig = { window.appConfig = {
showDebugger: false, showDebugger: false,
showExperimentalFeatures: true, showExperimentalFeatures: true,
projectGraphs: [ projects: [
{ {
id: 'local', id: 'local',
label: 'local', label: 'local',
url: 'project-graph.json', projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
}, },
], ],
defaultProjectGraph: 'local', defaultProject: 'local',
}; };

View File

@ -1,5 +1,8 @@
// nx-ignore-next-line // nx-ignore-next-line
import type { DepGraphClientResponse } from 'nx/src/command-line/dep-graph'; import type {
DepGraphClientResponse,
TaskGraphClientResponse,
} from 'nx/src/command-line/dep-graph';
import { AppConfig } from './app/interfaces'; import { AppConfig } from './app/interfaces';
import { ExternalApi } from './app/machines/externalApi'; import { ExternalApi } from './app/machines/externalApi';
@ -9,6 +12,7 @@ export declare global {
watch: boolean; watch: boolean;
localMode: 'serve' | 'build'; localMode: 'serve' | 'build';
projectGraphResponse?: DepGraphClientResponse; projectGraphResponse?: DepGraphClientResponse;
taskGraphResponse?: TaskGraphClientResponse;
environment: 'dev' | 'watch' | 'release' | 'nx-console'; environment: 'dev' | 'watch' | 'release' | 'nx-console';
appConfig: AppConfig; appConfig: AppConfig;
useXstateInspect: boolean; useXstateInspect: boolean;

View File

@ -1,94 +1,42 @@
// nx-ignore-next-line // nx-ignore-next-line
import type { import { CollectionReturnValue, use } from 'cytoscape';
ProjectGraphDependency,
ProjectGraphProjectNode,
} from '@nrwl/devkit';
import type { VirtualElement } from '@popperjs/core';
import cy from 'cytoscape';
import cytoscapeDagre from 'cytoscape-dagre'; import cytoscapeDagre from 'cytoscape-dagre';
import popper from 'cytoscape-popper'; import popper from 'cytoscape-popper';
import { edgeStyles, nodeStyles } from './styles-graph';
import {
CytoscapeDagreConfig,
ParentNode,
ProjectEdge,
ProjectNode,
} from './util-cytoscape';
import { GraphPerfReport, GraphRenderEvents } from './interfaces'; import { GraphPerfReport, GraphRenderEvents } from './interfaces';
import {
darkModeScratchKey,
switchValueByDarkMode,
} from './styles-graph/dark-mode';
import { GraphInteractionEvents } from './graph-interaction-events'; import { GraphInteractionEvents } from './graph-interaction-events';
import { RenderGraph } from './util-cytoscape/render-graph';
const cytoscapeDagreConfig = { import { ProjectTraversalGraph } from './util-cytoscape/project-traversal-graph';
name: 'dagre',
nodeDimensionsIncludeLabels: true,
rankSep: 75,
rankDir: 'TB',
edgeSep: 50,
ranker: 'network-simplex',
} as CytoscapeDagreConfig;
export class GraphService { export class GraphService {
private traversalGraph: cy.Core; private traversalGraph: ProjectTraversalGraph;
private renderGraph: cy.Core; private renderGraph: RenderGraph;
private collapseEdges = false;
private listeners = new Map< private listeners = new Map<
number, number,
(event: GraphInteractionEvents) => void (event: GraphInteractionEvents) => void
>(); >();
private _theme: 'light' | 'dark';
private _rankDir: 'TB' | 'LR' = 'TB';
constructor( constructor(
private container: string | HTMLElement, container: string | HTMLElement,
theme: 'light' | 'dark', theme: 'light' | 'dark',
private renderMode?: 'nx-console' | 'nx-docs', renderMode?: 'nx-console' | 'nx-docs',
rankDir: 'TB' | 'LR' = 'TB' rankDir: 'TB' | 'LR' = 'TB'
) { ) {
cy.use(cytoscapeDagre); use(cytoscapeDagre);
cy.use(popper); use(popper);
this._theme = theme; this.renderGraph = new RenderGraph(container, theme, renderMode, rankDir);
this._rankDir = rankDir;
}
get activeContainer() { this.renderGraph.listen((event) => this.broadcast(event));
return typeof this.container === 'string' this.traversalGraph = new ProjectTraversalGraph();
? document.getElementById(this.container)
: this.container;
} }
set theme(theme: 'light' | 'dark') { set theme(theme: 'light' | 'dark') {
this._theme = theme; this.renderGraph.theme = theme;
if (this.renderGraph) {
this.renderGraph.unmount();
const useDarkMode = theme === 'dark';
this.renderGraph.scratch(darkModeScratchKey, useDarkMode);
this.renderGraph.elements().scratch(darkModeScratchKey, useDarkMode);
this.renderGraph.mount(this.activeContainer);
}
} }
set rankDir(rankDir: 'TB' | 'LR') { set rankDir(rankDir: 'TB' | 'LR') {
this._rankDir = rankDir; this.renderGraph.rankDir = rankDir;
if (this.renderGraph) {
const elements = this.renderGraph.elements();
elements
.layout({
...cytoscapeDagreConfig,
...{ rankDir: rankDir },
} as CytoscapeDagreConfig)
.run();
}
} }
listen(callback: (event: GraphInteractionEvents) => void) { listen(callback: (event: GraphInteractionEvents) => void) {
@ -110,16 +58,19 @@ export class GraphService {
} { } {
const time = Date.now(); const time = Date.now();
if (this.renderGraph && event.type !== 'notifyGraphUpdateGraph') { if (event.type !== 'notifyGraphUpdateGraph') {
this.renderGraph.nodes('.focused').removeClass('focused'); this.renderGraph.clearFocussedElement();
this.renderGraph.unmount();
} }
this.broadcast({ type: 'GraphRegenerated' }); this.broadcast({ type: 'GraphRegenerated' });
let elementsToSendToRender: CollectionReturnValue;
switch (event.type) { switch (event.type) {
case 'notifyGraphInitGraph': case 'notifyGraphInitGraph':
this.initGraph( this.renderGraph.collapseEdges = event.collapseEdges;
this.broadcast({ type: 'GraphRegenerated' });
this.traversalGraph.initGraph(
event.projects, event.projects,
event.groupByFolder, event.groupByFolder,
event.workspaceLayout, event.workspaceLayout,
@ -130,7 +81,9 @@ export class GraphService {
break; break;
case 'notifyGraphUpdateGraph': case 'notifyGraphUpdateGraph':
this.initGraph( this.renderGraph.collapseEdges = event.collapseEdges;
this.broadcast({ type: 'GraphRegenerated' });
this.traversalGraph.initGraph(
event.projects, event.projects,
event.groupByFolder, event.groupByFolder,
event.workspaceLayout, event.workspaceLayout,
@ -138,19 +91,23 @@ export class GraphService {
event.affectedProjects, event.affectedProjects,
event.collapseEdges event.collapseEdges
); );
this.setShownProjects( elementsToSendToRender = this.traversalGraph.setShownProjects(
event.selectedProjects.length > 0 event.selectedProjects.length > 0
? event.selectedProjects ? event.selectedProjects
: this.renderGraph.nodes(':childless').map((node) => node.id()) : this.renderGraph.getCurrentlyShownProjectIds()
); );
break; break;
case 'notifyGraphFocusProject': case 'notifyGraphFocusProject':
this.focusProject(event.projectName, event.searchDepth); elementsToSendToRender = this.traversalGraph.focusProject(
event.projectName,
event.searchDepth
);
break; break;
case 'notifyGraphFilterProjectsByText': case 'notifyGraphFilterProjectsByText':
this.filterProjectsByText( elementsToSendToRender = this.traversalGraph.filterProjectsByText(
event.search, event.search,
event.includeProjectsByPath, event.includeProjectsByPath,
event.searchDepth event.searchDepth
@ -158,31 +115,43 @@ export class GraphService {
break; break;
case 'notifyGraphShowProjects': case 'notifyGraphShowProjects':
this.showProjects(event.projectNames); elementsToSendToRender = this.traversalGraph.showProjects(
event.projectNames,
this.renderGraph.getCurrentlyShownProjectIds()
);
break; break;
case 'notifyGraphHideProjects': case 'notifyGraphHideProjects':
this.hideProjects(event.projectNames); elementsToSendToRender = this.traversalGraph.hideProjects(
event.projectNames,
this.renderGraph.getCurrentlyShownProjectIds()
);
break; break;
case 'notifyGraphShowAllProjects': case 'notifyGraphShowAllProjects':
this.showAllProjects(); elementsToSendToRender = this.traversalGraph.showAllProjects();
break; break;
case 'notifyGraphHideAllProjects': case 'notifyGraphHideAllProjects':
this.hideAllProjects(); elementsToSendToRender = this.traversalGraph.hideAllProjects();
break; break;
case 'notifyGraphShowAffectedProjects': case 'notifyGraphShowAffectedProjects':
this.showAffectedProjects(); elementsToSendToRender = this.traversalGraph.showAffectedProjects();
break; break;
case 'notifyGraphTracing': case 'notifyGraphTracing':
if (event.start && event.end) { if (event.start && event.end) {
if (event.algorithm === 'shortest') { if (event.algorithm === 'shortest') {
this.traceProjects(event.start, event.end); elementsToSendToRender = this.traversalGraph.traceProjects(
event.start,
event.end
);
} else { } else {
this.traceAllProjects(event.start, event.end); elementsToSendToRender = this.traversalGraph.traceAllProjects(
event.start,
event.end
);
} }
} }
break; break;
@ -195,572 +164,32 @@ export class GraphService {
renderTime: 0, renderTime: 0,
}; };
if (this.renderGraph) { if (this.renderGraph && elementsToSendToRender) {
const elements = this.renderGraph.elements().sort((a, b) => { this.renderGraph.setElements(elementsToSendToRender);
return a.id().localeCompare(b.id());
});
elements if (event.type === 'notifyGraphFocusProject') {
.layout({ this.renderGraph.setFocussedElement(event.projectName);
...cytoscapeDagreConfig,
...{ rankDir: this._rankDir },
})
.run();
if (this.collapseEdges) {
this.renderGraph.remove(this.renderGraph.edges());
elements.edges().forEach((edge) => {
const sourceNode = edge.source();
const targetNode = edge.target();
if (
sourceNode.parent().first().id() ===
targetNode.parent().first().id()
) {
this.renderGraph.add(edge);
} else {
let sourceAncestors, targetAncestors;
const commonAncestors = edge.connectedNodes().commonAncestors();
if (commonAncestors.length > 0) {
sourceAncestors = sourceNode
.ancestors()
.filter((anc) => !commonAncestors.contains(anc));
targetAncestors = targetNode
.ancestors()
.filter((anc) => !commonAncestors.contains(anc));
} else {
sourceAncestors = sourceNode.ancestors();
targetAncestors = targetNode.ancestors();
} }
let sourceId, targetId; const { numEdges, numNodes } = this.renderGraph.render();
if (sourceAncestors.length > 0 && targetAncestors.length === 0) { selectedProjectNames = (
sourceId = sourceAncestors.last().id(); elementsToSendToRender.nodes('[type!="dir"]') ?? []
targetId = targetNode.id(); ).map((node) => node.id());
} else if (
targetAncestors.length > 0 &&
sourceAncestors.length === 0
) {
sourceId = sourceNode.id();
targetId = targetAncestors.last().id();
} else {
sourceId = sourceAncestors.last().id();
targetId = targetAncestors.last().id();
}
if (sourceId !== undefined && targetId !== undefined) {
const edgeId = `${sourceId}|${targetId}`;
if (this.renderGraph.$id(edgeId).length === 0) {
const ancestorEdge: cy.EdgeDefinition = {
group: 'edges',
data: {
id: edgeId,
source: sourceId,
target: targetId,
},
};
this.renderGraph.add(ancestorEdge);
}
} else {
console.log(`Couldn't figure out how to draw edge ${edge.id()}`);
console.log(
'source ancestors',
sourceAncestors.map((anc) => anc.id())
);
console.log(
'target ancestors',
targetAncestors.map((anc) => anc.id())
);
}
}
});
}
if (this.renderMode === 'nx-console') {
// when in the nx-console environment, adjust graph width and position to be to right of floating panel
// 175 is a magic number that represents the width of the floating panels divided in half plus some padding
this.renderGraph
.fit(this.renderGraph.elements(), 175)
.center()
.resize()
.panBy({ x: 150, y: 0 });
} else {
this.renderGraph.fit(this.renderGraph.elements(), 25).center().resize();
}
selectedProjectNames = this.renderGraph
.nodes('[type!="dir"]')
.map((node) => node.id());
this.renderGraph.scratch(darkModeScratchKey, this._theme === 'dark');
this.renderGraph
.elements()
.scratch(darkModeScratchKey, this._theme === 'dark');
this.renderGraph.mount(this.activeContainer);
const renderTime = Date.now() - time; const renderTime = Date.now() - time;
perfReport = { perfReport = {
renderTime, renderTime,
numNodes: this.renderGraph.nodes().length, numNodes,
numEdges: this.renderGraph.edges().length, numEdges,
}; };
} }
return { selectedProjectNames, perfReport }; return { selectedProjectNames, perfReport };
} }
setShownProjects(selectedProjectNames: string[]) {
let nodesToAdd = this.traversalGraph.collection();
selectedProjectNames.forEach((name) => {
nodesToAdd = nodesToAdd.union(this.traversalGraph.$id(name));
});
const ancestorsToAdd = nodesToAdd.ancestors();
const nodesToRender = nodesToAdd.union(ancestorsToAdd);
const edgesToRender = nodesToRender.edgesTo(nodesToRender);
this.transferToRenderGraph(nodesToRender.union(edgesToRender));
}
showProjects(selectedProjectNames: string[]) {
const currentNodes =
this.renderGraph?.nodes() ?? this.traversalGraph.collection();
let nodesToAdd = this.traversalGraph.collection();
selectedProjectNames.forEach((name) => {
nodesToAdd = nodesToAdd.union(this.traversalGraph.$id(name));
});
const ancestorsToAdd = nodesToAdd.ancestors();
const nodesToRender = currentNodes.union(nodesToAdd).union(ancestorsToAdd);
const edgesToRender = nodesToRender.edgesTo(nodesToRender);
this.transferToRenderGraph(nodesToRender.union(edgesToRender));
}
hideProjects(projectNames: string[]) {
const currentNodes =
this.renderGraph?.nodes() ?? this.traversalGraph.collection();
let nodesToHide = this.renderGraph.collection();
projectNames.forEach((projectName) => {
nodesToHide = nodesToHide.union(this.renderGraph.$id(projectName));
});
const nodesToAdd = currentNodes
.difference(nodesToHide)
.difference(nodesToHide.ancestors());
const ancestorsToAdd = nodesToAdd.ancestors();
let nodesToRender = nodesToAdd.union(ancestorsToAdd);
const edgesToRender = nodesToRender.edgesTo(nodesToRender);
this.transferToRenderGraph(nodesToRender.union(edgesToRender));
}
showAffectedProjects() {
const affectedProjects = this.traversalGraph.nodes('.affected');
const affectedAncestors = affectedProjects.ancestors();
const affectedNodes = affectedProjects.union(affectedAncestors);
const affectedEdges = affectedNodes.edgesTo(affectedNodes);
this.transferToRenderGraph(affectedNodes.union(affectedEdges));
}
focusProject(focusedProjectName: string, searchDepth: number = 1) {
const focusedProject = this.traversalGraph.$id(focusedProjectName);
const includedProjects = this.includeProjectsByDepth(
focusedProject,
searchDepth
);
const includedNodes = focusedProject.union(includedProjects);
const includedAncestors = includedNodes.ancestors();
const nodesToRender = includedNodes.union(includedAncestors);
const edgesToRender = nodesToRender.edgesTo(nodesToRender);
this.transferToRenderGraph(nodesToRender.union(edgesToRender));
this.renderGraph.$id(focusedProjectName).addClass('focused');
}
showAllProjects() {
this.transferToRenderGraph(this.traversalGraph.elements());
}
hideAllProjects() {
this.transferToRenderGraph(this.traversalGraph.collection());
}
filterProjectsByText(
search: string,
includePath: boolean,
searchDepth: number = -1
) {
if (search === '') {
this.transferToRenderGraph(this.traversalGraph.collection());
} else {
const split = search.split(',');
let filteredProjects = this.traversalGraph.nodes().filter((node) => {
return (
split.findIndex((splitItem) => node.id().includes(splitItem)) > -1
);
});
if (includePath) {
filteredProjects = filteredProjects.union(
this.includeProjectsByDepth(filteredProjects, searchDepth)
);
}
filteredProjects = filteredProjects.union(filteredProjects.ancestors());
const edgesToRender = filteredProjects.edgesTo(filteredProjects);
this.transferToRenderGraph(filteredProjects.union(edgesToRender));
}
}
traceProjects(start: string, end: string) {
const dijkstra = this.traversalGraph
.elements()
.dijkstra({ root: `[id = "${start}"]`, directed: true });
const path = dijkstra.pathTo(this.traversalGraph.$(`[id = "${end}"]`));
this.transferToRenderGraph(path.union(path.ancestors()));
}
traceAllProjects(start: string, end: string) {
const startNode = this.traversalGraph.$id(start).nodes().first();
const queue: cy.NodeSingular[][] = [[startNode]];
const paths: cy.NodeSingular[][] = [];
let iterations = 0;
while (queue.length > 0 && iterations <= 1000) {
const currentPath = queue.pop();
const nodeToTest = currentPath[currentPath.length - 1];
const outgoers = nodeToTest.outgoers('node');
if (outgoers.length > 0) {
outgoers.forEach((outgoer) => {
const newPath = [...currentPath, outgoer];
if (outgoer.id() === end) {
paths.push(newPath);
} else {
queue.push(newPath);
}
});
}
iterations++;
}
if (iterations >= 1000) {
console.log('failsafe triggered!');
}
let finalCollection = this.traversalGraph.collection();
paths.forEach((path) => {
for (let i = 0; i < path.length; i++) {
finalCollection = finalCollection.union(path[i]);
const nextIndex = i + 1;
if (nextIndex < path.length) {
finalCollection = finalCollection.union(
path[i].edgesTo(path[nextIndex])
);
}
}
});
finalCollection.union(finalCollection.ancestors());
this.transferToRenderGraph(
finalCollection.union(finalCollection.ancestors())
);
}
private transferToRenderGraph(elements: cy.Collection) {
let currentFocusedProjectName;
if (this.renderGraph) {
currentFocusedProjectName = this.renderGraph
.nodes('.focused')
.first()
.id();
this.renderGraph.destroy();
delete this.renderGraph;
}
this.renderGraph = cy({
headless: this.activeContainer === null,
container: this.activeContainer,
boxSelectionEnabled: false,
style: [...nodeStyles, ...edgeStyles],
panningEnabled: true,
userZoomingEnabled: this.renderMode !== 'nx-docs',
});
this.renderGraph.add(elements);
if (!!currentFocusedProjectName) {
this.renderGraph.$id(currentFocusedProjectName).addClass('focused');
}
this.renderGraph.on('zoom pan', () => {
this.broadcast({ type: 'GraphRegenerated' });
});
this.listenForProjectNodeClicks();
this.listenForEdgeNodeClicks();
this.listenForProjectNodeHovers();
}
private includeProjectsByDepth(
projects: cy.NodeCollection | cy.NodeSingular,
depth: number = -1
) {
let predecessors = this.traversalGraph.collection();
if (depth === -1) {
predecessors = projects.predecessors();
} else {
predecessors = projects.incomers();
for (let i = 1; i < depth; i++) {
predecessors = predecessors.union(predecessors.incomers());
}
}
let successors = this.traversalGraph.collection();
if (depth === -1) {
successors = projects.successors();
} else {
successors = projects.outgoers();
for (let i = 1; i < depth; i++) {
successors = successors.union(successors.outgoers());
}
}
return projects.union(predecessors).union(successors);
}
initGraph(
allProjects: ProjectGraphProjectNode[],
groupByFolder: boolean,
workspaceLayout,
dependencies: Record<string, ProjectGraphDependency[]>,
affectedProjectIds: string[],
collapseEdges: boolean
) {
this.collapseEdges = collapseEdges;
this.broadcast({ type: 'GraphRegenerated' });
this.generateCytoscapeLayout(
allProjects,
groupByFolder,
workspaceLayout,
dependencies,
affectedProjectIds
);
}
private generateCytoscapeLayout(
allProjects: ProjectGraphProjectNode[],
groupByFolder: boolean,
workspaceLayout,
dependencies: Record<string, ProjectGraphDependency[]>,
affectedProjectIds: string[]
) {
const elements = this.createElements(
allProjects,
groupByFolder,
workspaceLayout,
dependencies,
affectedProjectIds
);
this.traversalGraph = cy({
headless: true,
elements: [...elements],
boxSelectionEnabled: false,
style: [...nodeStyles, ...edgeStyles],
});
}
private createElements(
projects: ProjectGraphProjectNode[],
groupByFolder: boolean,
workspaceLayout: {
appsDir: string;
libsDir: string;
},
dependencies: Record<string, ProjectGraphDependency[]>,
affectedProjectIds: string[]
) {
let elements: cy.ElementDefinition[] = [];
const filteredProjectNames = projects.map((project) => project.name);
const projectNodes: ProjectNode[] = [];
const edgeNodes: ProjectEdge[] = [];
const parents: Record<
string,
{ id: string; parentId: string; label: string }
> = {};
projects.forEach((project) => {
const workspaceRoot =
project.type === 'app' || project.type === 'e2e'
? workspaceLayout.appsDir
: workspaceLayout.libsDir;
const projectNode = new ProjectNode(project, workspaceRoot);
projectNode.affected = affectedProjectIds.includes(project.name);
projectNodes.push(projectNode);
dependencies[project.name].forEach((dep) => {
if (filteredProjectNames.includes(dep.target)) {
const edge = new ProjectEdge(dep);
edgeNodes.push(edge);
}
});
if (groupByFolder) {
const ancestors = projectNode.getAncestors();
ancestors.forEach((ancestor) => (parents[ancestor.id] = ancestor));
}
});
const projectElements = projectNodes.map((projectNode) =>
projectNode.getCytoscapeNodeDef(groupByFolder)
);
const edgeElements = edgeNodes.map((edgeNode) =>
edgeNode.getCytosacpeNodeDef()
);
elements = projectElements.concat(edgeElements);
if (groupByFolder) {
const parentElements = Object.keys(parents).map((id) =>
new ParentNode(parents[id]).getCytoscapeNodeDef()
);
elements = parentElements.concat(elements);
}
return elements;
}
listenForProjectNodeClicks() {
this.renderGraph.$('node:childless').on('click', (event) => {
const node = event.target;
let ref: VirtualElement = node.popperRef(); // used only for positioning
this.broadcast({
type: 'NodeClick',
ref,
id: node.id(),
data: {
id: node.id(),
type: node.data('type'),
tags: node.data('tags'),
},
});
});
}
listenForEdgeNodeClicks() {
this.renderGraph.$('edge').on('click', (event) => {
const edge: cy.EdgeSingular = event.target;
let ref: VirtualElement = edge.popperRef(); // used only for positioning
this.broadcast({
type: 'EdgeClick',
ref,
id: edge.id(),
data: {
type: edge.data('type'),
source: edge.source().id(),
target: edge.target().id(),
fileDependencies: edge
.source()
.data('files')
.filter(
(file) => file.deps && file.deps.includes(edge.target().id())
)
.map((file) => {
return {
fileName: file.file.replace(
`${edge.source().data('root')}/`,
''
),
target: edge.target().id(),
};
}),
},
});
});
}
listenForProjectNodeHovers(): void {
this.renderGraph.on('mouseover', (event) => {
const node = event.target;
if (!node.isNode || !node.isNode() || node.isParent()) return;
this.renderGraph
.elements()
.difference(node.outgoers().union(node.incomers()))
.not(node)
.addClass('transparent');
node
.addClass('highlight')
.outgoers()
.union(node.incomers())
.addClass('highlight');
});
this.renderGraph.on('mouseout', (event) => {
const node = event.target;
if (!node.isNode || !node.isNode() || node.isParent()) return;
this.renderGraph.elements().removeClass('transparent');
node
.removeClass('highlight')
.outgoers()
.union(node.incomers())
.removeClass('highlight');
});
}
getImage() { getImage() {
const bg = switchValueByDarkMode(this.renderGraph, '#0F172A', '#FFFFFF'); return this.renderGraph.getImage();
return this.renderGraph.png({ bg, full: true });
} }
} }

View File

@ -0,0 +1,340 @@
import cytoscape, {
CollectionReturnValue,
Core,
ElementDefinition,
NodeCollection,
NodeSingular,
} from 'cytoscape';
// nx-ignore-next-line
import {
ProjectGraphDependency,
ProjectGraphProjectNode,
} from 'nx/src/config/project-graph';
import { edgeStyles, nodeStyles } from '../styles-graph';
import { ProjectNode } from './project-node';
import { ProjectEdge } from './edge';
import { ParentNode } from './parent-node';
export class ProjectTraversalGraph {
private cy?: Core;
setShownProjects(selectedProjectNames: string[]) {
let nodesToAdd = this.cy.collection();
selectedProjectNames.forEach((name) => {
nodesToAdd = nodesToAdd.union(this.cy.$id(name));
});
const ancestorsToAdd = nodesToAdd.ancestors();
const nodesToRender = nodesToAdd.union(ancestorsToAdd);
const edgesToRender = nodesToRender.edgesTo(nodesToRender);
return nodesToRender.union(edgesToRender);
}
showProjects(selectedProjectNames: string[], alreadyShownProjects: string[]) {
let nodesToAdd = this.cy.collection();
selectedProjectNames.forEach((name) => {
nodesToAdd = nodesToAdd.union(this.cy.$id(name));
});
alreadyShownProjects.forEach((name) => {
nodesToAdd = nodesToAdd.union(this.cy.$id(name));
});
const ancestorsToAdd = nodesToAdd.ancestors();
const nodesToRender = nodesToAdd.union(ancestorsToAdd);
const edgesToRender = nodesToRender.edgesTo(nodesToRender);
return nodesToRender.union(edgesToRender);
}
hideProjects(projectNames: string[], alreadyShownProjects: string[]) {
let currentNodes = this.cy.collection();
alreadyShownProjects.forEach((name) => {
currentNodes = currentNodes.union(this.cy.$id(name));
});
let nodesToHide = this.cy.collection();
projectNames.forEach((projectName) => {
nodesToHide = nodesToHide.union(this.cy.$id(projectName));
});
const nodesToAdd = currentNodes
.difference(nodesToHide)
.difference(nodesToHide.ancestors());
const ancestorsToAdd = nodesToAdd.ancestors();
let nodesToRender = nodesToAdd.union(ancestorsToAdd);
const edgesToRender = nodesToRender.edgesTo(nodesToRender);
return nodesToRender.union(edgesToRender);
}
showAffectedProjects() {
const affectedProjects = this.cy.nodes('.affected');
const affectedAncestors = affectedProjects.ancestors();
const affectedNodes = affectedProjects.union(affectedAncestors);
const affectedEdges = affectedNodes.edgesTo(affectedNodes);
return affectedNodes.union(affectedEdges);
}
focusProject(focusedProjectName: string, searchDepth: number = 1) {
const focusedProject = this.cy.$id(focusedProjectName);
const includedProjects = this.includeProjectsByDepth(
focusedProject,
searchDepth
);
const includedNodes = focusedProject.union(includedProjects);
const includedAncestors = includedNodes.ancestors();
const nodesToRender = includedNodes.union(includedAncestors);
const edgesToRender = nodesToRender.edgesTo(nodesToRender);
return nodesToRender.union(edgesToRender);
}
showAllProjects() {
return this.cy.elements();
}
hideAllProjects() {
return this.cy.collection();
}
filterProjectsByText(
search: string,
includePath: boolean,
searchDepth: number = -1
) {
if (search === '') {
return this.cy.collection();
} else {
const split = search.split(',');
let filteredProjects = this.cy.nodes().filter((node) => {
return (
split.findIndex((splitItem) => node.id().includes(splitItem)) > -1
);
});
if (includePath) {
filteredProjects = filteredProjects.union(
this.includeProjectsByDepth(filteredProjects, searchDepth)
);
}
filteredProjects = filteredProjects.union(filteredProjects.ancestors());
const edgesToRender = filteredProjects.edgesTo(filteredProjects);
return filteredProjects.union(edgesToRender);
}
}
traceProjects(start: string, end: string) {
const dijkstra = this.cy
.elements()
.dijkstra({ root: `[id = "${start}"]`, directed: true });
const path = dijkstra.pathTo(this.cy.$(`[id = "${end}"]`));
return path.union(path.ancestors());
}
traceAllProjects(start: string, end: string) {
const startNode = this.cy.$id(start).nodes().first();
const queue: NodeSingular[][] = [[startNode]];
const paths: NodeSingular[][] = [];
let iterations = 0;
while (queue.length > 0 && iterations <= 1000) {
const currentPath = queue.pop();
const nodeToTest = currentPath[currentPath.length - 1];
const outgoers = nodeToTest.outgoers('node');
if (outgoers.length > 0) {
outgoers.forEach((outgoer) => {
const newPath = [...currentPath, outgoer];
if (outgoer.id() === end) {
paths.push(newPath);
} else {
queue.push(newPath);
}
});
}
iterations++;
}
if (iterations >= 1000) {
console.log('failsafe triggered!');
}
let finalCollection = this.cy.collection();
paths.forEach((path) => {
for (let i = 0; i < path.length; i++) {
finalCollection = finalCollection.union(path[i]);
const nextIndex = i + 1;
if (nextIndex < path.length) {
finalCollection = finalCollection.union(
path[i].edgesTo(path[nextIndex])
);
}
}
});
return finalCollection.union(finalCollection.ancestors());
}
private includeProjectsByDepth(
projects: NodeCollection | NodeSingular,
depth: number = -1
) {
let predecessors: CollectionReturnValue;
if (depth === -1) {
predecessors = projects.predecessors();
} else {
predecessors = projects.incomers();
for (let i = 1; i < depth; i++) {
predecessors = predecessors.union(predecessors.incomers());
}
}
let successors: CollectionReturnValue;
if (depth === -1) {
successors = projects.successors();
} else {
successors = projects.outgoers();
for (let i = 1; i < depth; i++) {
successors = successors.union(successors.outgoers());
}
}
return projects.union(predecessors).union(successors);
}
initGraph(
allProjects: ProjectGraphProjectNode[],
groupByFolder: boolean,
workspaceLayout,
dependencies: Record<string, ProjectGraphDependency[]>,
affectedProjectIds: string[],
collapseEdges: boolean
) {
this.generateCytoscapeLayout(
allProjects,
groupByFolder,
workspaceLayout,
dependencies,
affectedProjectIds
);
}
private generateCytoscapeLayout(
allProjects: ProjectGraphProjectNode[],
groupByFolder: boolean,
workspaceLayout,
dependencies: Record<string, ProjectGraphDependency[]>,
affectedProjectIds: string[]
) {
const elements = this.createElements(
allProjects,
groupByFolder,
workspaceLayout,
dependencies,
affectedProjectIds
);
this.cy = cytoscape({
headless: true,
elements: [...elements],
boxSelectionEnabled: false,
style: [...nodeStyles, ...edgeStyles],
});
}
private createElements(
projects: ProjectGraphProjectNode[],
groupByFolder: boolean,
workspaceLayout: {
appsDir: string;
libsDir: string;
},
dependencies: Record<string, ProjectGraphDependency[]>,
affectedProjectIds: string[]
) {
let elements: ElementDefinition[] = [];
const filteredProjectNames = projects.map((project) => project.name);
const projectNodes: ProjectNode[] = [];
const edgeNodes: ProjectEdge[] = [];
const parents: Record<
string,
{ id: string; parentId: string; label: string }
> = {};
projects.forEach((project) => {
const workspaceRoot =
project.type === 'app' || project.type === 'e2e'
? workspaceLayout.appsDir
: workspaceLayout.libsDir;
const projectNode = new ProjectNode(project, workspaceRoot);
projectNode.affected = affectedProjectIds.includes(project.name);
projectNodes.push(projectNode);
dependencies[project.name].forEach((dep) => {
if (filteredProjectNames.includes(dep.target)) {
const edge = new ProjectEdge(dep);
edgeNodes.push(edge);
}
});
if (groupByFolder) {
const ancestors = projectNode.getAncestors();
ancestors.forEach((ancestor) => (parents[ancestor.id] = ancestor));
}
});
const projectElements = projectNodes.map((projectNode) =>
projectNode.getCytoscapeNodeDef(groupByFolder)
);
const edgeElements = edgeNodes.map((edgeNode) =>
edgeNode.getCytosacpeNodeDef()
);
elements = projectElements.concat(edgeElements);
if (groupByFolder) {
const parentElements = Object.keys(parents).map((id) =>
new ParentNode(parents[id]).getCytoscapeNodeDef()
);
elements = parentElements.concat(elements);
}
return elements;
}
}

View File

@ -0,0 +1,336 @@
import cytoscape, {
Collection,
Core,
EdgeDefinition,
EdgeSingular,
} from 'cytoscape';
import { edgeStyles, nodeStyles } from '../styles-graph';
import { GraphInteractionEvents } from '@nrwl/graph/ui-graph';
import { VirtualElement } from '@popperjs/core';
import {
darkModeScratchKey,
switchValueByDarkMode,
} from '../styles-graph/dark-mode';
import { CytoscapeDagreConfig } from './cytoscape.models';
const cytoscapeDagreConfig = {
name: 'dagre',
nodeDimensionsIncludeLabels: true,
rankSep: 75,
rankDir: 'TB',
edgeSep: 50,
ranker: 'network-simplex',
} as CytoscapeDagreConfig;
export class RenderGraph {
private cy?: Core;
collapseEdges = false;
private _theme: 'light' | 'dark';
private _rankDir: 'TB' | 'LR' = 'TB';
private listeners = new Map<
number,
(event: GraphInteractionEvents) => void
>();
constructor(
private container: string | HTMLElement,
theme: 'light' | 'dark',
private renderMode?: 'nx-console' | 'nx-docs',
rankDir: 'TB' | 'LR' = 'TB'
) {
this._theme = theme;
this._rankDir = rankDir;
}
set theme(theme: 'light' | 'dark') {
this._theme = theme;
if (this.cy) {
this.cy.unmount();
const useDarkMode = theme === 'dark';
this.cy.scratch(darkModeScratchKey, useDarkMode);
this.cy.elements().scratch(darkModeScratchKey, useDarkMode);
this.cy.mount(this.activeContainer);
}
}
set rankDir(rankDir: 'LR' | 'TB') {
this._rankDir = rankDir;
if (this.cy) {
const elements = this.cy.elements();
elements
.layout({
...cytoscapeDagreConfig,
...{ rankDir: rankDir },
} as CytoscapeDagreConfig)
.run();
}
}
get activeContainer() {
return typeof this.container === 'string'
? document.getElementById(this.container)
: this.container;
}
private broadcast(event: GraphInteractionEvents) {
this.listeners.forEach((callback) => callback(event));
}
listen(callback: (event: GraphInteractionEvents) => void) {
const listenerId = this.listeners.size + 1;
this.listeners.set(listenerId, callback);
return () => {
this.listeners.delete(listenerId);
};
}
setElements(elements: Collection) {
let currentFocusedProjectName;
if (this.cy) {
currentFocusedProjectName = this.cy.nodes('.focused').first().id();
this.cy.destroy();
delete this.cy;
}
this.cy = cytoscape({
headless: this.activeContainer === null,
container: this.activeContainer,
boxSelectionEnabled: false,
style: [...nodeStyles, ...edgeStyles],
panningEnabled: true,
userZoomingEnabled: this.renderMode !== 'nx-docs',
});
this.cy.add(elements);
if (!!currentFocusedProjectName) {
this.cy.$id(currentFocusedProjectName).addClass('focused');
}
this.cy.on('zoom pan', () => {
this.broadcast({ type: 'GraphRegenerated' });
});
this.listenForProjectNodeClicks();
this.listenForEdgeNodeClicks();
this.listenForProjectNodeHovers();
}
render(): { numEdges: number; numNodes: number } {
if (this.cy) {
const elements = this.cy.elements().sort((a, b) => {
return a.id().localeCompare(b.id());
});
elements
.layout({
...cytoscapeDagreConfig,
...{ rankDir: this._rankDir },
})
.run();
if (this.collapseEdges) {
this.cy.remove(this.cy.edges());
elements.edges().forEach((edge) => {
const sourceNode = edge.source();
const targetNode = edge.target();
if (
sourceNode.parent().first().id() ===
targetNode.parent().first().id()
) {
this.cy.add(edge);
} else {
let sourceAncestors, targetAncestors;
const commonAncestors = edge.connectedNodes().commonAncestors();
if (commonAncestors.length > 0) {
sourceAncestors = sourceNode
.ancestors()
.filter((anc) => !commonAncestors.contains(anc));
targetAncestors = targetNode
.ancestors()
.filter((anc) => !commonAncestors.contains(anc));
} else {
sourceAncestors = sourceNode.ancestors();
targetAncestors = targetNode.ancestors();
}
let sourceId, targetId;
if (sourceAncestors.length > 0 && targetAncestors.length === 0) {
sourceId = sourceAncestors.last().id();
targetId = targetNode.id();
} else if (
targetAncestors.length > 0 &&
sourceAncestors.length === 0
) {
sourceId = sourceNode.id();
targetId = targetAncestors.last().id();
} else {
sourceId = sourceAncestors.last().id();
targetId = targetAncestors.last().id();
}
if (sourceId !== undefined && targetId !== undefined) {
const edgeId = `${sourceId}|${targetId}`;
if (this.cy.$id(edgeId).length === 0) {
const ancestorEdge: EdgeDefinition = {
group: 'edges',
data: {
id: edgeId,
source: sourceId,
target: targetId,
},
};
this.cy.add(ancestorEdge);
}
} else {
console.log(`Couldn't figure out how to draw edge ${edge.id()}`);
console.log(
'source ancestors',
sourceAncestors.map((anc) => anc.id())
);
console.log(
'target ancestors',
targetAncestors.map((anc) => anc.id())
);
}
}
});
}
if (this.renderMode === 'nx-console') {
// when in the nx-console environment, adjust graph width and position to be to right of floating panel
// 175 is a magic number that represents the width of the floating panels divided in half plus some padding
this.cy
.fit(this.cy.elements(), 175)
.center()
.resize()
.panBy({ x: 150, y: 0 });
} else {
this.cy.fit(this.cy.elements(), 25).center().resize();
}
this.cy.scratch(darkModeScratchKey, this._theme === 'dark');
this.cy.elements().scratch(darkModeScratchKey, this._theme === 'dark');
this.cy.mount(this.activeContainer);
}
return {
numNodes: this.cy?.nodes().length ?? 0,
numEdges: this.cy?.edges().length ?? 0,
};
}
private listenForProjectNodeClicks() {
this.cy.$('node:childless').on('click', (event) => {
const node = event.target;
let ref: VirtualElement = node.popperRef(); // used only for positioning
this.broadcast({
type: 'NodeClick',
ref,
id: node.id(),
data: {
id: node.id(),
type: node.data('type'),
tags: node.data('tags'),
},
});
});
}
private listenForEdgeNodeClicks() {
this.cy.$('edge').on('click', (event) => {
const edge: EdgeSingular = event.target;
let ref: VirtualElement = edge.popperRef(); // used only for positioning
this.broadcast({
type: 'EdgeClick',
ref,
id: edge.id(),
data: {
type: edge.data('type'),
source: edge.source().id(),
target: edge.target().id(),
fileDependencies: edge
.source()
.data('files')
.filter(
(file) => file.deps && file.deps.includes(edge.target().id())
)
.map((file) => {
return {
fileName: file.file.replace(
`${edge.source().data('root')}/`,
''
),
target: edge.target().id(),
};
}),
},
});
});
}
private listenForProjectNodeHovers(): void {
this.cy.on('mouseover', (event) => {
const node = event.target;
if (!node.isNode || !node.isNode() || node.isParent()) return;
this.cy
.elements()
.difference(node.outgoers().union(node.incomers()))
.not(node)
.addClass('transparent');
node
.addClass('highlight')
.outgoers()
.union(node.incomers())
.addClass('highlight');
});
this.cy.on('mouseout', (event) => {
const node = event.target;
if (!node.isNode || !node.isNode() || node.isParent()) return;
this.cy.elements().removeClass('transparent');
node
.removeClass('highlight')
.outgoers()
.union(node.incomers())
.removeClass('highlight');
});
}
getImage() {
const bg = switchValueByDarkMode(this.cy, '#0F172A', '#FFFFFF');
return this.cy.png({ bg, full: true });
}
setFocussedElement(id: string) {
this.cy.$id(id).addClass('focused');
}
clearFocussedElement() {
this.cy?.nodes('.focused').removeClass('focused');
}
getCurrentlyShownProjectIds(): string[] {
return this.cy?.nodes().map((node) => node.data('id')) ?? [];
}
}

View File

@ -114,9 +114,9 @@
"@typescript-eslint/parser": "5.38.1", "@typescript-eslint/parser": "5.38.1",
"@typescript-eslint/type-utils": "^5.36.1", "@typescript-eslint/type-utils": "^5.36.1",
"@typescript-eslint/utils": "5.38.1", "@typescript-eslint/utils": "5.38.1",
"@xstate/immer": "^0.2.0", "@xstate/immer": "^0.3.1",
"@xstate/inspect": "^0.5.1", "@xstate/inspect": "^0.7.0",
"@xstate/react": "^1.6.3", "@xstate/react": "^3.0.1",
"ajv": "^8.11.0", "ajv": "^8.11.0",
"autoprefixer": "10.4.12", "autoprefixer": "10.4.12",
"babel-jest": "28.1.3", "babel-jest": "28.1.3",
@ -246,7 +246,7 @@
"webpack-node-externals": "^3.0.0", "webpack-node-externals": "^3.0.0",
"webpack-sources": "^3.2.3", "webpack-sources": "^3.2.3",
"webpack-subresource-integrity": "^5.1.0", "webpack-subresource-integrity": "^5.1.0",
"xstate": "^4.25.0", "xstate": "^4.34.0",
"yargs": "^17.6.2", "yargs": "^17.6.2",
"yargs-parser": "21.1.1" "yargs-parser": "21.1.1"
}, },

View File

@ -77,14 +77,15 @@ function buildEnvironmentJs(
window.appConfig = { window.appConfig = {
showDebugger: false, showDebugger: false,
showExperimentalFeatures: false, showExperimentalFeatures: false,
projectGraphs: [ projects: [
{ {
id: 'local', id: 'local',
label: 'local', label: 'local',
url: 'project-graph.json', projectGraphUrl: 'project-graph.json',
taskGraphUrl: 'task-graph.json'
} }
], ],
defaultProjectGraph: 'local', defaultProject: 'local',
}; };
`; `;

View File

@ -1,7 +1,6 @@
import * as yargs from 'yargs';
import { ensureDirSync } from 'fs-extra'; import { ensureDirSync } from 'fs-extra';
import { join } from 'path'; import { join } from 'path';
import { writeFileSync, readdirSync } from 'fs'; import { readdirSync, writeFileSync } from 'fs';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
function generateFileContent( function generateFileContent(
@ -16,10 +15,9 @@ window.useXstateInspect = false;
window.appConfig = { window.appConfig = {
showDebugger: true, showDebugger: true,
showExperimentalFeatures: true, showExperimentalFeatures: true,
projectGraphs: ${JSON.stringify(projects)}, projects: ${JSON.stringify(projects)},
defaultProjectGraph: '${projects[0].id}', defaultProject: '${projects[0].id}',
}; };
`; `;
} }
@ -27,13 +25,14 @@ function writeFile() {
let generatedGraphs; let generatedGraphs;
try { try {
generatedGraphs = readdirSync( generatedGraphs = readdirSync(
join(__dirname, '../graph/client/src/assets/generated-graphs') join(__dirname, '../graph/client/src/assets/generated-project-graphs')
).map((filename) => { ).map((filename) => {
const id = filename.substring(0, filename.length - 5); const id = filename.substring(0, filename.length - 5);
return { return {
id, id,
label: id, label: id,
url: join('assets/generated-graphs/', filename), projectGraphUrl: join('assets/generated-project-graphs/', filename),
taskGraphUrl: join('assets/generated-task-graphs/', filename),
}; };
}); });
} catch { } catch {
@ -43,13 +42,13 @@ function writeFile() {
let pregeneratedGraphs; let pregeneratedGraphs;
try { try {
pregeneratedGraphs = readdirSync( pregeneratedGraphs = readdirSync(
join(__dirname, '../graph/client/src/assets/graphs') join(__dirname, '../graph/client/src/assets/project-graphs')
).map((filename) => { ).map((filename) => {
const id = filename.substring(0, filename.length - 5); const id = filename.substring(0, filename.length - 5);
return { return {
id, id,
label: id, label: id,
url: join('assets/graphs/', filename), url: join('assets/project-graphs/', filename),
}; };
}); });
} catch { } catch {

View File

@ -28,18 +28,34 @@ async function generateGraph(directory: string, name: string) {
/window.projectGraphResponse = (.*?);/ /window.projectGraphResponse = (.*?);/
); );
const taskGraphResponse = environmentJs.match(
/window.taskGraphResponse = (.*?);/
);
ensureDirSync( ensureDirSync(
join(__dirname, '../graph/client/src/assets/generated-graphs/') join(__dirname, '../graph/client/src/assets/generated-project-graphs/')
);
ensureDirSync(
join(__dirname, '../graph/client/src/assets/generated-task-graphs/')
); );
writeFileSync( writeFileSync(
join( join(
__dirname, __dirname,
'../graph/client/src/assets/generated-graphs/', '../graph/client/src/assets/generated-project-graphs/',
`${name}.json` `${name}.json`
), ),
projectGraphResponse[1] projectGraphResponse[1]
); );
writeFileSync(
join(
__dirname,
'../graph/client/src/assets/generated-task-graphs/',
`${name}.json`
),
taskGraphResponse[1]
);
} }
(async () => { (async () => {
@ -49,8 +65,7 @@ async function generateGraph(directory: string, name: string) {
.option('name', { .option('name', {
type: 'string', type: 'string',
requiresArg: true, requiresArg: true,
description: description: 'The snake-case name of the file created',
'The version to publish. This does not need to be passed and can be inferred.',
}) })
.option('directory', { .option('directory', {
type: 'string', type: 'string',
@ -59,5 +74,5 @@ async function generateGraph(directory: string, name: string) {
}) })
.parseSync(); .parseSync();
generateGraph(parsedArgs.directory, parsedArgs.name); await generateGraph(parsedArgs.directory, parsedArgs.name);
})(); })();

View File

@ -6715,25 +6715,25 @@
"@webassemblyjs/wast-parser" "1.9.0" "@webassemblyjs/wast-parser" "1.9.0"
"@xtuc/long" "4.2.2" "@xtuc/long" "4.2.2"
"@xstate/immer@^0.2.0": "@xstate/immer@^0.3.1":
version "0.2.0" version "0.3.1"
resolved "https://registry.yarnpkg.com/@xstate/immer/-/immer-0.2.0.tgz#4f128947c3cbb3e68357b886485a36852d4e06b3" resolved "https://registry.yarnpkg.com/@xstate/immer/-/immer-0.3.1.tgz#73a7948f7f248e00dc287b55290a949cd8276b3d"
integrity sha512-ZKwAwS84kfmN108lEtVHw8jztKDiFeaQsTxkOlOghpK1Lr7+13G8HhZZXyN1/pVkplloUUOPMH5EXVtitZDr8w== integrity sha512-YE+KY08IjEEmXo6XKKpeSGW4j9LfcXw+5JVixLLUO3fWQ3M95joWJ40VtGzx0w0zQSzoCNk8NgfvwWBGSbIaTA==
"@xstate/inspect@^0.5.1": "@xstate/inspect@^0.7.0":
version "0.5.2" version "0.7.0"
resolved "https://registry.yarnpkg.com/@xstate/inspect/-/inspect-0.5.2.tgz#83d58c96f704ceaab6f7849e578d8e6ce212038c" resolved "https://registry.yarnpkg.com/@xstate/inspect/-/inspect-0.7.0.tgz#0e3011d0fb8eca6d68f06a7c384ab1390801e176"
integrity sha512-DdqUPiKaHW6VpnVZcm8YMD8LBeS3B9bB3+VT/6VEyilgvf2MgYzho2dKOOkeZM0iDEadSmzGdDpz0jh7DSpMXQ== integrity sha512-3wrTf8TfBYprH1gBFdxmOQUBDpBazlICWvGdFzr8IHFL4MbiexEZdAsL2QC/WAmW9BqNYTWTwgfbvKHKg+FrlA==
dependencies: dependencies:
fast-safe-stringify "^2.0.7" fast-safe-stringify "^2.1.1"
"@xstate/react@^1.6.3": "@xstate/react@^3.0.1":
version "1.6.3" version "3.0.1"
resolved "https://registry.yarnpkg.com/@xstate/react/-/react-1.6.3.tgz#706f3beb7bc5879a78088985c8fd43b9dab7f725" resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.1.tgz#937eeb5d5d61734ab756ca40146f84a6fe977095"
integrity sha512-NCUReRHPGvvCvj2yLZUTfR0qVp6+apc8G83oXSjN4rl89ZjyujiKrTff55bze/HrsvCsP/sUJASf2n0nzMF1KQ== integrity sha512-/tq/gg92P9ke8J+yDNDBv5/PAxBvXJf2cYyGDByzgtl5wKaxKxzDT82Gj3eWlCJXkrBg4J5/V47//gRJuVH2fA==
dependencies: dependencies:
use-isomorphic-layout-effect "^1.0.0" use-isomorphic-layout-effect "^1.0.0"
use-subscription "^1.3.0" use-sync-external-store "^1.0.0"
"@xtuc/ieee754@^1.2.0": "@xtuc/ieee754@^1.2.0":
version "1.2.0" version "1.2.0"
@ -11640,7 +11640,7 @@ fast-redact@^3.0.0:
resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.1.2.tgz#d58e69e9084ce9fa4c1a6fa98a3e1ecf5d7839aa" resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.1.2.tgz#d58e69e9084ce9fa4c1a6fa98a3e1ecf5d7839aa"
integrity sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw== integrity sha512-+0em+Iya9fKGfEQGcd62Yv6onjBmmhV1uh86XVfOU8VwAe6kaFdQCWI9s0/Nnugx5Vd9tdbZ7e6gE2tR9dzXdw==
fast-safe-stringify@2.1.1, fast-safe-stringify@^2.0.7, fast-safe-stringify@^2.0.8: fast-safe-stringify@2.1.1, fast-safe-stringify@^2.0.8, fast-safe-stringify@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884"
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==
@ -22179,14 +22179,7 @@ use-isomorphic-layout-effect@^1.0.0:
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
use-subscription@^1.3.0: use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0:
version "1.8.0"
resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.8.0.tgz#f118938c29d263c2bce12fc5585d3fe694d4dbce"
integrity sha512-LISuG0/TmmoDoCRmV5XAqYkd3UCBNM0ML3gGBndze65WITcsExCD3DTvXXTLyNcOC0heFQZzluW88bN/oC1DQQ==
dependencies:
use-sync-external-store "^1.2.0"
use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
@ -23088,10 +23081,10 @@ xmlhttprequest-ssl@~1.6.2:
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6" resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6"
integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q== integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==
xstate@^4.25.0: xstate@^4.34.0:
version "4.33.6" version "4.34.0"
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.33.6.tgz#9e23f78879af106f1de853aba7acb2bc3b1eb950" resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.34.0.tgz#401901c478f0b2a7f07576c020b6e6f750b5bd10"
integrity sha512-A5R4fsVKADWogK2a43ssu8Fz1AF077SfrKP1ZNyDBD8lNa/l4zfR//Luofp5GSWehOQr36Jp0k2z7b+sH2ivyg== integrity sha512-MFnYz7cJrWuXSZ8IPkcCyLB1a2T3C71kzMeShXKmNaEjBR/JQebKZPHTtxHKZpymESaWO31rA3IQ30TC6LW+sw==
xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1: xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1:
version "4.0.2" version "4.0.2"