chore(graph): consume task graph data in client (#13092)
This commit is contained in:
parent
f08a3c3c44
commit
2be9a01272
3
.gitignore
vendored
3
.gitignore
vendored
@ -15,7 +15,8 @@ jest.debug.config.js
|
||||
/.verdaccio/build/local-registry
|
||||
/graph/client/src/assets/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/images/open-graph
|
||||
# Lerna creates this
|
||||
|
||||
@ -23,9 +23,9 @@ import * as nxExamplesJson from '../fixtures/nx-examples.json';
|
||||
|
||||
describe('graph-client', () => {
|
||||
before(() => {
|
||||
cy.intercept('/assets/graphs/e2e.json', { fixture: 'nx-examples.json' }).as(
|
||||
'getGraph'
|
||||
);
|
||||
cy.intercept('/assets/project-graphs/e2e.json', {
|
||||
fixture: 'nx-examples.json',
|
||||
}).as('getGraph');
|
||||
cy.visit('/');
|
||||
|
||||
// wait for initial graph to finish loading
|
||||
@ -140,7 +140,7 @@ describe('graph-client', () => {
|
||||
});
|
||||
|
||||
it('should check all affected project items', () => {
|
||||
cy.intercept('/assets/graphs/affected.json', {
|
||||
cy.intercept('/assets/project-graphs/affected.json', {
|
||||
fixture: 'affected.json',
|
||||
}).as('getAffectedGraph');
|
||||
|
||||
@ -155,7 +155,7 @@ describe('graph-client', () => {
|
||||
);
|
||||
|
||||
// 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',
|
||||
}).as('getGraph');
|
||||
cy.get('[data-cy=project-select]').select('e2e', { force: true });
|
||||
@ -308,9 +308,9 @@ describe('graph-client', () => {
|
||||
|
||||
describe('loading graph client with url params', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('/assets/graphs/*', { fixture: 'nx-examples.json' }).as(
|
||||
'getGraph'
|
||||
);
|
||||
cy.intercept('/assets/project-graphs/*', {
|
||||
fixture: 'nx-examples.json',
|
||||
}).as('getGraph');
|
||||
});
|
||||
|
||||
// check that params work from old base url of /
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
describe('graph-client release', () => {
|
||||
beforeEach(() => {
|
||||
cy.intercept('/assets/graphs/*').as('getGraph');
|
||||
cy.intercept('/assets/project-graphs/*').as('getGraph');
|
||||
|
||||
cy.visit('/');
|
||||
|
||||
|
||||
@ -62,8 +62,9 @@
|
||||
"dev": {
|
||||
"assets": [
|
||||
"graph/client/src/favicon.ico",
|
||||
"graph/client/src/assets/graphs/",
|
||||
"graph/client/src/assets/generated-graphs/",
|
||||
"graph/client/src/assets/project-graphs/",
|
||||
"graph/client/src/assets/generated-project-graphs/",
|
||||
"graph/client/src/assets/generated-task-graphs/",
|
||||
{
|
||||
"input": "graph/client/src/assets/dev",
|
||||
"output": "/",
|
||||
@ -74,7 +75,7 @@
|
||||
"dev-e2e": {
|
||||
"assets": [
|
||||
"graph/client/src/favicon.ico",
|
||||
"graph/client/src/assets/graphs/",
|
||||
"graph/client/src/assets/project-graphs/",
|
||||
{
|
||||
"input": "graph/client/src/assets/dev-e2e",
|
||||
"output": "/",
|
||||
@ -86,8 +87,8 @@
|
||||
"assets": [
|
||||
"graph/client/src/favicon.ico",
|
||||
{
|
||||
"input": "graph/client/src/assets/graphs",
|
||||
"output": "/assets/graphs",
|
||||
"input": "graph/client/src/assets/project-graphs",
|
||||
"output": "/assets/project-graphs",
|
||||
"glob": "e2e.json"
|
||||
},
|
||||
{
|
||||
@ -101,8 +102,8 @@
|
||||
"assets": [
|
||||
"graph/client/src/favicon.ico",
|
||||
{
|
||||
"input": "graph/client/src/assets/graphs",
|
||||
"output": "/assets/graphs",
|
||||
"input": "graph/client/src/assets/project-graphs",
|
||||
"output": "/assets/project-graphs",
|
||||
"glob": "e2e.json"
|
||||
},
|
||||
{
|
||||
@ -129,7 +130,7 @@
|
||||
"assets": [
|
||||
"graph/client/src/favicon.ico",
|
||||
{
|
||||
"input": "graph/client/src/assets/graphs",
|
||||
"input": "graph/client/src/assets/project-graphs",
|
||||
"output": "/assets/graphs",
|
||||
"glob": "e2e.json"
|
||||
},
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
localStorageRankDirKey,
|
||||
RankDir,
|
||||
rankDirResolver,
|
||||
} from '../rankdir-resolver';
|
||||
} from '../../rankdir-resolver';
|
||||
|
||||
export default function RankdirPanel(): JSX.Element {
|
||||
const [rankDir, setRankDir] = useState(
|
||||
@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } 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 DebouncedTextInput from '../ui-components/debounced-text-input';
|
||||
import DebouncedTextInput from '../../ui-components/debounced-text-input';
|
||||
|
||||
export interface TextFilterPanelProps {
|
||||
textFilter: string;
|
||||
@ -6,7 +6,11 @@ import {
|
||||
} from '@heroicons/react/24/outline';
|
||||
import classNames from 'classnames';
|
||||
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 {
|
||||
const [theme, setTheme] = useState(
|
||||
@ -5,7 +5,7 @@ import {
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { memo } from 'react';
|
||||
import { TracingAlgorithmType } from '../machines/interfaces';
|
||||
import { TracingAlgorithmType } from '../../machines/interfaces';
|
||||
|
||||
export interface TracingPanelProps {
|
||||
start: string;
|
||||
@ -13,14 +13,14 @@ import {
|
||||
searchDepthSelector,
|
||||
textFilterSelector,
|
||||
} from '../machines/selectors';
|
||||
import CollapseEdgesPanel from '../sidebar/collapse-edges-panel';
|
||||
import FocusedProjectPanel from '../sidebar/focused-project-panel';
|
||||
import GroupByFolderPanel from '../sidebar/group-by-folder-panel';
|
||||
import ProjectList from '../sidebar/project-list';
|
||||
import SearchDepth from '../sidebar/search-depth';
|
||||
import ShowHideProjects from '../sidebar/show-hide-projects';
|
||||
import TextFilterPanel from '../sidebar/text-filter-panel';
|
||||
import TracingPanel from '../sidebar/tracing-panel';
|
||||
import CollapseEdgesPanel from './panels/collapse-edges-panel';
|
||||
import FocusedProjectPanel from './panels/focused-project-panel';
|
||||
import GroupByFolderPanel from './panels/group-by-folder-panel';
|
||||
import ProjectList from './project-list';
|
||||
import SearchDepth from './panels/search-depth';
|
||||
import ShowHideProjects from './panels/show-hide-projects';
|
||||
import TextFilterPanel from './panels/text-filter-panel';
|
||||
import TracingPanel from './panels/tracing-panel';
|
||||
import { TracingAlgorithmType } from '../machines/interfaces';
|
||||
import { useEnvironmentConfig } from '../hooks/use-environment-config';
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { DocumentMagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
||||
// nx-ignore-next-line
|
||||
import type { ProjectGraphNode, Task } from '@nrwl/devkit';
|
||||
import type { ProjectGraphNode } from '@nrwl/devkit';
|
||||
import { parseParentDirectoriesFromFilePath } from '../util';
|
||||
import { WorkspaceLayout } from '../interfaces';
|
||||
import Tag from '../ui-components/tag';
|
||||
@ -55,6 +55,7 @@ function groupProjectsByDirectory(
|
||||
function ProjectListItem({
|
||||
project,
|
||||
selectTask,
|
||||
selectedTaskId,
|
||||
}: {
|
||||
project: SidebarProjectWithTargets;
|
||||
selectTask: (
|
||||
@ -62,6 +63,7 @@ function ProjectListItem({
|
||||
targetName: string,
|
||||
configurationName: string
|
||||
) => void;
|
||||
selectedTaskId: string;
|
||||
}) {
|
||||
return (
|
||||
<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 />
|
||||
{target.configurations.map((configuration) => (
|
||||
<div className="flex items-center">
|
||||
{selectedTaskId ===
|
||||
`${project.projectGraphNode.name}:${target.targetName}:${configuration.name}` ? (
|
||||
<span>selected</span>
|
||||
) : null}
|
||||
<button
|
||||
data-cy={`focus-button-${configuration.name}`}
|
||||
type="button"
|
||||
@ -111,6 +117,7 @@ function SubProjectList({
|
||||
headerText = '',
|
||||
projects,
|
||||
selectTask,
|
||||
selectedTaskId,
|
||||
}: {
|
||||
headerText: string;
|
||||
projects: SidebarProjectWithTargets[];
|
||||
@ -119,6 +126,7 @@ function SubProjectList({
|
||||
targetName: string,
|
||||
configurationName: string
|
||||
) => void;
|
||||
selectedTaskId: string;
|
||||
}) {
|
||||
let sortedProjects = [...projects];
|
||||
sortedProjects.sort((a, b) => {
|
||||
@ -139,6 +147,7 @@ function SubProjectList({
|
||||
key={project.projectGraphNode.name}
|
||||
project={project}
|
||||
selectTask={selectTask}
|
||||
selectedTaskId={selectedTaskId}
|
||||
></ProjectListItem>
|
||||
);
|
||||
})}
|
||||
@ -232,6 +241,7 @@ export function TaskList({
|
||||
mapToSidebarProjectWithTasks(project, selectedTask)
|
||||
)}
|
||||
selectTask={selectTask}
|
||||
selectedTaskId={selectedTask}
|
||||
></SubProjectList>
|
||||
);
|
||||
})}
|
||||
@ -249,6 +259,7 @@ export function TaskList({
|
||||
mapToSidebarProjectWithTasks(project, selectedTask)
|
||||
)}
|
||||
selectTask={selectTask}
|
||||
selectedTaskId={selectedTask}
|
||||
></SubProjectList>
|
||||
);
|
||||
})}
|
||||
@ -266,6 +277,7 @@ export function TaskList({
|
||||
mapToSidebarProjectWithTasks(project, selectedTask)
|
||||
)}
|
||||
selectTask={selectTask}
|
||||
selectedTaskId={selectedTask}
|
||||
></SubProjectList>
|
||||
);
|
||||
})}
|
||||
@ -1,64 +1,41 @@
|
||||
import TaskList from '../sidebar/task-list';
|
||||
import TaskList from './task-list';
|
||||
/* nx-ignore-next-line */
|
||||
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 */
|
||||
export interface TasksSidebarProps {}
|
||||
|
||||
export function TasksSidebar(props: TasksSidebarProps) {
|
||||
const mockProjects: ProjectGraphNode[] = [
|
||||
{
|
||||
name: 'app1',
|
||||
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 projects = useDepGraphSelector(allProjectsSelector);
|
||||
const workspaceLayout = useDepGraphSelector(workspaceLayoutSelector);
|
||||
const taskGraph = getTaskGraphService();
|
||||
|
||||
const mockWorkspaceLayout = {
|
||||
appsDir: 'apps',
|
||||
libsDir: 'libs',
|
||||
};
|
||||
|
||||
const mockSelectedTask = 'app1:build:production';
|
||||
const selectedTask = useTaskGraphSelector(
|
||||
(state) => state.context.selectedTaskId
|
||||
);
|
||||
function selectTask(
|
||||
projectName: string,
|
||||
targetName: string,
|
||||
configurationName: string
|
||||
) {
|
||||
const taskId = `${projectName}:${targetName}:${configurationName}`;
|
||||
taskGraph.send({ type: 'selectTask', taskId });
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskList
|
||||
projects={mockProjects}
|
||||
workspaceLayout={mockWorkspaceLayout}
|
||||
selectedTask={mockSelectedTask}
|
||||
selectTask={console.log}
|
||||
projects={projects}
|
||||
workspaceLayout={workspaceLayout}
|
||||
selectedTask={selectedTask}
|
||||
selectTask={selectTask}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
// 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';
|
||||
|
||||
export class FetchProjectGraphService implements ProjectGraphService {
|
||||
@ -18,4 +21,12 @@ export class FetchProjectGraphService implements ProjectGraphService {
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
15
graph/client/src/app/hooks/use-task-graph-selector.ts
Normal file
15
graph/client/src/app/hooks/use-task-graph-selector.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,14 @@
|
||||
// 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;
|
||||
label: string;
|
||||
url: string;
|
||||
projectGraphUrl: string;
|
||||
taskGraphUrl: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceLayout {
|
||||
@ -15,6 +19,7 @@ export interface WorkspaceLayout {
|
||||
export interface ProjectGraphService {
|
||||
getHash: () => Promise<string>;
|
||||
getProjectGraph: (url: string) => Promise<DepGraphClientResponse>;
|
||||
getTaskGraph: (url: string) => Promise<TaskGraphClientResponse>;
|
||||
}
|
||||
export interface Environment {
|
||||
environment: 'dev' | 'watch' | 'release';
|
||||
@ -23,6 +28,6 @@ export interface Environment {
|
||||
export interface AppConfig {
|
||||
showDebugger: boolean;
|
||||
showExperimentalFeatures: boolean;
|
||||
projectGraphs: ProjectGraphList[];
|
||||
defaultProjectGraph: string;
|
||||
projects: GraphListItem[];
|
||||
defaultProject: string;
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
// 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';
|
||||
|
||||
export class LocalProjectGraphService implements ProjectGraphService {
|
||||
@ -10,4 +13,8 @@ export class LocalProjectGraphService implements ProjectGraphService {
|
||||
async getProjectGraph(url: string): Promise<DepGraphClientResponse> {
|
||||
return new Promise((resolve) => resolve(window.projectGraphResponse));
|
||||
}
|
||||
|
||||
async getTaskGraph(url: string): Promise<TaskGraphClientResponse> {
|
||||
return new Promise((resolve) => resolve(window.taskGraphResponse));
|
||||
}
|
||||
}
|
||||
|
||||
155
graph/client/src/app/machines/app.machine.ts
Normal file
155
graph/client/src/app/machines/app.machine.ts
Normal 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;
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -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;
|
||||
}
|
||||
@ -1,9 +1,12 @@
|
||||
// nx-ignore-next-line
|
||||
import type { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
|
||||
import type {
|
||||
ProjectGraphDependency,
|
||||
ProjectGraphProjectNode,
|
||||
} from '@nrwl/devkit';
|
||||
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',
|
||||
type: 'app',
|
||||
@ -94,13 +97,16 @@ export const mockDependencies: Record<string, ProjectGraphDependency[]> = {
|
||||
describe('dep-graph machine', () => {
|
||||
describe('initGraph', () => {
|
||||
it('should set projects, dependencies, and workspaceLayout', () => {
|
||||
const result = depGraphMachine.transition(depGraphMachine.initialState, {
|
||||
type: 'initGraph',
|
||||
const result = projectGraphMachine.transition(
|
||||
projectGraphMachine.initialState,
|
||||
{
|
||||
type: 'notifyProjectGraphSetProjects',
|
||||
projects: mockProjects,
|
||||
dependencies: mockDependencies,
|
||||
affectedProjects: [],
|
||||
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
|
||||
});
|
||||
}
|
||||
);
|
||||
expect(result.context.projects).toEqual(mockProjects);
|
||||
expect(result.context.dependencies).toEqual(mockDependencies);
|
||||
expect(result.context.workspaceLayout).toEqual({
|
||||
@ -110,13 +116,16 @@ describe('dep-graph machine', () => {
|
||||
});
|
||||
|
||||
it('should start with no projects selected', () => {
|
||||
const result = depGraphMachine.transition(depGraphMachine.initialState, {
|
||||
type: 'initGraph',
|
||||
const result = projectGraphMachine.transition(
|
||||
projectGraphMachine.initialState,
|
||||
{
|
||||
type: 'notifyProjectGraphSetProjects',
|
||||
projects: mockProjects,
|
||||
dependencies: mockDependencies,
|
||||
affectedProjects: [],
|
||||
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.value).toEqual('unselected');
|
||||
expect(result.context.selectedProjects).toEqual([]);
|
||||
@ -125,7 +134,7 @@ describe('dep-graph machine', () => {
|
||||
|
||||
describe('selecting projects', () => {
|
||||
it('should select projects', (done) => {
|
||||
let service = interpret(depGraphMachine).onTransition((state) => {
|
||||
let service = interpret(projectGraphMachine).onTransition((state) => {
|
||||
if (
|
||||
state.matches('customSelected') &&
|
||||
state.context.selectedProjects.includes('app1') &&
|
||||
@ -138,7 +147,7 @@ describe('dep-graph machine', () => {
|
||||
service.start();
|
||||
|
||||
service.send({
|
||||
type: 'initGraph',
|
||||
type: 'notifyProjectGraphSetProjects',
|
||||
projects: mockProjects,
|
||||
dependencies: mockDependencies,
|
||||
affectedProjects: [],
|
||||
@ -159,7 +168,7 @@ describe('dep-graph machine', () => {
|
||||
|
||||
describe('deselecting projects', () => {
|
||||
it('should deselect projects', (done) => {
|
||||
let service = interpret(depGraphMachine).onTransition((state) => {
|
||||
let service = interpret(projectGraphMachine).onTransition((state) => {
|
||||
if (
|
||||
state.matches('customSelected') &&
|
||||
!state.context.selectedProjects.includes('app1') &&
|
||||
@ -172,7 +181,7 @@ describe('dep-graph machine', () => {
|
||||
service.start();
|
||||
|
||||
service.send({
|
||||
type: 'initGraph',
|
||||
type: 'notifyProjectGraphSetProjects',
|
||||
projects: mockProjects,
|
||||
dependencies: mockDependencies,
|
||||
affectedProjects: [],
|
||||
@ -196,30 +205,33 @@ describe('dep-graph machine', () => {
|
||||
});
|
||||
|
||||
it('should go to unselected when last project is deselected', () => {
|
||||
let result = depGraphMachine.transition(depGraphMachine.initialState, {
|
||||
type: 'initGraph',
|
||||
let result = projectGraphMachine.transition(
|
||||
projectGraphMachine.initialState,
|
||||
{
|
||||
type: 'notifyProjectGraphSetProjects',
|
||||
projects: mockProjects,
|
||||
dependencies: mockDependencies,
|
||||
affectedProjects: [],
|
||||
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'selectProject',
|
||||
projectName: 'app1',
|
||||
});
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'selectProject',
|
||||
projectName: 'app2',
|
||||
});
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'deselectProject',
|
||||
projectName: 'app1',
|
||||
});
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'deselectProject',
|
||||
projectName: 'app2',
|
||||
});
|
||||
@ -231,15 +243,18 @@ describe('dep-graph machine', () => {
|
||||
|
||||
describe('focusing projects', () => {
|
||||
it('should set the focused project', () => {
|
||||
let result = depGraphMachine.transition(depGraphMachine.initialState, {
|
||||
type: 'initGraph',
|
||||
let result = projectGraphMachine.transition(
|
||||
projectGraphMachine.initialState,
|
||||
{
|
||||
type: 'notifyProjectGraphSetProjects',
|
||||
projects: mockProjects,
|
||||
dependencies: mockDependencies,
|
||||
affectedProjects: [],
|
||||
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'focusProject',
|
||||
projectName: 'app1',
|
||||
});
|
||||
@ -249,7 +264,7 @@ describe('dep-graph machine', () => {
|
||||
});
|
||||
|
||||
it('should select the projects by the focused project', (done) => {
|
||||
let service = interpret(depGraphMachine).onTransition((state) => {
|
||||
let service = interpret(projectGraphMachine).onTransition((state) => {
|
||||
if (
|
||||
state.matches('focused') &&
|
||||
state.context.selectedProjects.includes('app1') &&
|
||||
@ -264,7 +279,7 @@ describe('dep-graph machine', () => {
|
||||
service.start();
|
||||
|
||||
service.send({
|
||||
type: 'initGraph',
|
||||
type: 'notifyProjectGraphSetProjects',
|
||||
projects: mockProjects,
|
||||
dependencies: mockDependencies,
|
||||
affectedProjects: [],
|
||||
@ -278,20 +293,23 @@ describe('dep-graph machine', () => {
|
||||
});
|
||||
|
||||
it('should select no projects on unfocus', () => {
|
||||
let result = depGraphMachine.transition(depGraphMachine.initialState, {
|
||||
type: 'initGraph',
|
||||
let result = projectGraphMachine.transition(
|
||||
projectGraphMachine.initialState,
|
||||
{
|
||||
type: 'notifyProjectGraphSetProjects',
|
||||
projects: mockProjects,
|
||||
dependencies: mockDependencies,
|
||||
affectedProjects: [],
|
||||
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'focusProject',
|
||||
projectName: 'app1',
|
||||
});
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'unfocusProject',
|
||||
});
|
||||
|
||||
@ -302,60 +320,63 @@ describe('dep-graph machine', () => {
|
||||
|
||||
describe('search depth', () => {
|
||||
it('should not decrement search depth below 1', () => {
|
||||
let result = depGraphMachine.transition(depGraphMachine.initialState, {
|
||||
type: 'initGraph',
|
||||
let result = projectGraphMachine.transition(
|
||||
projectGraphMachine.initialState,
|
||||
{
|
||||
type: 'notifyProjectGraphSetProjects',
|
||||
projects: mockProjects,
|
||||
dependencies: mockDependencies,
|
||||
affectedProjects: [],
|
||||
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'filterByText',
|
||||
search: 'app1',
|
||||
});
|
||||
|
||||
expect(result.context.searchDepth).toEqual(1);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'incrementSearchDepth',
|
||||
});
|
||||
|
||||
expect(result.context.searchDepth).toEqual(2);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'incrementSearchDepth',
|
||||
});
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'incrementSearchDepth',
|
||||
});
|
||||
|
||||
expect(result.context.searchDepth).toEqual(4);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'decrementSearchDepth',
|
||||
});
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'decrementSearchDepth',
|
||||
});
|
||||
|
||||
expect(result.context.searchDepth).toEqual(2);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'decrementSearchDepth',
|
||||
});
|
||||
|
||||
expect(result.context.searchDepth).toEqual(1);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'decrementSearchDepth',
|
||||
});
|
||||
|
||||
expect(result.context.searchDepth).toEqual(1);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'decrementSearchDepth',
|
||||
});
|
||||
|
||||
@ -363,35 +384,38 @@ describe('dep-graph machine', () => {
|
||||
});
|
||||
|
||||
it('should activate search depth if incremented or decremented', () => {
|
||||
let result = depGraphMachine.transition(depGraphMachine.initialState, {
|
||||
type: 'initGraph',
|
||||
let result = projectGraphMachine.transition(
|
||||
projectGraphMachine.initialState,
|
||||
{
|
||||
type: 'notifyProjectGraphSetProjects',
|
||||
projects: mockProjects,
|
||||
dependencies: mockDependencies,
|
||||
affectedProjects: [],
|
||||
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'setSearchDepthEnabled',
|
||||
searchDepthEnabled: false,
|
||||
});
|
||||
|
||||
expect(result.context.searchDepthEnabled).toBe(false);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'incrementSearchDepth',
|
||||
});
|
||||
|
||||
expect(result.context.searchDepthEnabled).toBe(true);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'setSearchDepthEnabled',
|
||||
searchDepthEnabled: false,
|
||||
});
|
||||
|
||||
expect(result.context.searchDepthEnabled).toBe(false);
|
||||
|
||||
result = depGraphMachine.transition(result, {
|
||||
result = projectGraphMachine.transition(result, {
|
||||
type: 'decrementSearchDepth',
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { getDepGraphService } from './dep-graph.service';
|
||||
import { getProjectGraphService } from './get-services';
|
||||
|
||||
export class ExternalApi {
|
||||
depGraphService = getDepGraphService();
|
||||
depGraphService = getProjectGraphService();
|
||||
|
||||
focusProject(projectName: string) {
|
||||
this.depGraphService.send({ type: 'focusProject', projectName });
|
||||
|
||||
25
graph/client/src/app/machines/get-services.ts
Normal file
25
graph/client/src/app/machines/get-services.ts
Normal 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;
|
||||
}
|
||||
@ -4,9 +4,10 @@ import type {
|
||||
ProjectGraphProjectNode,
|
||||
} from '@nrwl/devkit';
|
||||
import { ActionObject, ActorRef, State, StateNodeConfig } from 'xstate';
|
||||
import { TaskGraphContext, TaskGraphEvents } from './task-graph.machine';
|
||||
|
||||
// The hierarchical (recursive) schema for the states
|
||||
export interface DepGraphSchema {
|
||||
export interface ProjectGraphSchema {
|
||||
states: {
|
||||
idle: {};
|
||||
unselected: {};
|
||||
@ -26,7 +27,7 @@ export interface GraphPerfReport {
|
||||
export type TracingAlgorithmType = 'shortest' | 'all';
|
||||
// The events that the machine handles
|
||||
|
||||
export type DepGraphUIEvents =
|
||||
export type ProjectGraphEvents =
|
||||
| {
|
||||
type: 'setSelectedProjectsFromGraph';
|
||||
selectedProjectNames: string[];
|
||||
@ -56,7 +57,7 @@ export type DepGraphUIEvents =
|
||||
| { type: 'filterByText'; search: string }
|
||||
| { type: 'clearTextFilter' }
|
||||
| {
|
||||
type: 'initGraph';
|
||||
type: 'notifyProjectGraphSetProjects';
|
||||
projects: ProjectGraphProjectNode[];
|
||||
dependencies: Record<string, ProjectGraphDependency[]>;
|
||||
affectedProjects: string[];
|
||||
@ -169,10 +170,8 @@ export type RouteEvents =
|
||||
algorithm: TracingAlgorithmType;
|
||||
};
|
||||
|
||||
export type AllEvents = DepGraphUIEvents | GraphRenderEvents | RouteEvents;
|
||||
|
||||
// The context (extended state) of the machine
|
||||
export interface DepGraphContext {
|
||||
export interface ProjectGraphContext {
|
||||
projects: ProjectGraphProjectNode[];
|
||||
dependencies: Record<string, ProjectGraphDependency[]>;
|
||||
affectedProjects: string[];
|
||||
@ -190,7 +189,7 @@ export interface DepGraphContext {
|
||||
};
|
||||
graphActor: ActorRef<GraphRenderEvents>;
|
||||
routeSetterActor: ActorRef<RouteEvents>;
|
||||
routeListenerActor: ActorRef<DepGraphUIEvents>;
|
||||
routeListenerActor: ActorRef<ProjectGraphEvents>;
|
||||
lastPerfReport: GraphPerfReport;
|
||||
tracing: {
|
||||
start: string;
|
||||
@ -200,22 +199,32 @@ export interface DepGraphContext {
|
||||
}
|
||||
|
||||
export type DepGraphStateNodeConfig = StateNodeConfig<
|
||||
DepGraphContext,
|
||||
ProjectGraphContext,
|
||||
{},
|
||||
DepGraphUIEvents,
|
||||
ActionObject<DepGraphContext, DepGraphUIEvents>
|
||||
ProjectGraphEvents,
|
||||
ActionObject<ProjectGraphContext, ProjectGraphEvents>
|
||||
>;
|
||||
|
||||
export type DepGraphSend = (
|
||||
event: DepGraphUIEvents | DepGraphUIEvents[]
|
||||
event: ProjectGraphEvents | ProjectGraphEvents[]
|
||||
) => void;
|
||||
|
||||
export type DepGraphState = State<
|
||||
DepGraphContext,
|
||||
DepGraphUIEvents,
|
||||
ProjectGraphContext,
|
||||
ProjectGraphEvents,
|
||||
any,
|
||||
{
|
||||
value: any;
|
||||
context: DepGraphContext;
|
||||
context: ProjectGraphContext;
|
||||
}
|
||||
>;
|
||||
|
||||
export type TaskGraphState = State<
|
||||
TaskGraphContext,
|
||||
TaskGraphEvents,
|
||||
any,
|
||||
{
|
||||
value: any;
|
||||
context: TaskGraphContext;
|
||||
}
|
||||
>;
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
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 { focusedStateConfig } from './focused.state';
|
||||
import { graphActor } from './graph.actor';
|
||||
import {
|
||||
DepGraphContext,
|
||||
DepGraphSchema,
|
||||
DepGraphUIEvents,
|
||||
ProjectGraphContext,
|
||||
ProjectGraphSchema,
|
||||
ProjectGraphEvents,
|
||||
} from './interfaces';
|
||||
import { createRouteMachine } from './route-setter.machine';
|
||||
import { textFilteredStateConfig } from './text-filtered.state';
|
||||
import { tracingStateConfig } from './tracing.state';
|
||||
import { unselectedStateConfig } from './unselected.state';
|
||||
|
||||
export const initialContext: DepGraphContext = {
|
||||
export const initialContext: ProjectGraphContext = {
|
||||
projects: [],
|
||||
dependencies: {},
|
||||
affectedProjects: [],
|
||||
@ -44,12 +44,12 @@ export const initialContext: DepGraphContext = {
|
||||
},
|
||||
};
|
||||
|
||||
export const depGraphMachine = Machine<
|
||||
DepGraphContext,
|
||||
DepGraphSchema,
|
||||
DepGraphUIEvents
|
||||
export const projectGraphMachine = createMachine<
|
||||
ProjectGraphContext,
|
||||
ProjectGraphEvents
|
||||
>(
|
||||
{
|
||||
predictableActionArguments: true,
|
||||
id: 'DepGraph',
|
||||
initial: 'idle',
|
||||
context: initialContext,
|
||||
@ -62,7 +62,7 @@ export const depGraphMachine = Machine<
|
||||
tracing: tracingStateConfig,
|
||||
},
|
||||
on: {
|
||||
initGraph: {
|
||||
notifyProjectGraphSetProjects: {
|
||||
target: 'unselected',
|
||||
actions: [
|
||||
'setGraph',
|
||||
@ -284,7 +284,11 @@ export const depGraphMachine = Machine<
|
||||
ctx.includePath = event.includeProjectsByPath;
|
||||
}),
|
||||
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.dependencies = event.dependencies;
|
||||
@ -293,7 +297,7 @@ export const depGraphMachine = Machine<
|
||||
name: 'route',
|
||||
});
|
||||
|
||||
if (event.type === 'initGraph') {
|
||||
if (event.type === 'notifyProjectGraphSetProjects') {
|
||||
ctx.workspaceLayout = event.workspaceLayout;
|
||||
ctx.affectedProjects = event.affectedProjects;
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { InvokeCallback } from 'xstate';
|
||||
import { DepGraphUIEvents } from './interfaces';
|
||||
import { ProjectGraphEvents } from './interfaces';
|
||||
|
||||
function parseSearchParamsToEvents(searchParams: string): DepGraphUIEvents[] {
|
||||
const events: DepGraphUIEvents[] = [];
|
||||
function parseSearchParamsToEvents(searchParams: string): ProjectGraphEvents[] {
|
||||
const events: ProjectGraphEvents[] = [];
|
||||
const params = new URLSearchParams(searchParams);
|
||||
|
||||
params.forEach((value, key) => {
|
||||
@ -60,8 +60,8 @@ function parseSearchParamsToEvents(searchParams: string): DepGraphUIEvents[] {
|
||||
}
|
||||
|
||||
export const routeListener: InvokeCallback<
|
||||
DepGraphUIEvents,
|
||||
DepGraphUIEvents
|
||||
ProjectGraphEvents,
|
||||
ProjectGraphEvents
|
||||
> = (callback) => {
|
||||
const history = createBrowserHistory();
|
||||
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { assign } from '@xstate/immer';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import { Machine } from 'xstate';
|
||||
import { RouteEvents } from './interfaces';
|
||||
import { createMachine, Machine } from 'xstate';
|
||||
import {
|
||||
ProjectGraphContext,
|
||||
ProjectGraphEvents,
|
||||
RouteEvents,
|
||||
} from './interfaces';
|
||||
|
||||
type ParamKeys =
|
||||
| 'focus'
|
||||
@ -26,6 +30,11 @@ function reduceParamRecordToQueryString(params: ParamRecord): string {
|
||||
return new URLSearchParams(newParams).toString();
|
||||
}
|
||||
|
||||
export interface RouteSetterContext {
|
||||
currentParamString: string;
|
||||
params: Record<ParamKeys, string | null>;
|
||||
}
|
||||
|
||||
export const createRouteMachine = () => {
|
||||
const history = createBrowserHistory();
|
||||
|
||||
@ -41,17 +50,14 @@ export const createRouteMachine = () => {
|
||||
traceAlgorithm: params.get('traceAlgorithm'),
|
||||
};
|
||||
|
||||
const initialContext = {
|
||||
const initialContext: RouteSetterContext = {
|
||||
currentParamString: reduceParamRecordToQueryString(paramRecord),
|
||||
params: paramRecord,
|
||||
};
|
||||
|
||||
return Machine<
|
||||
{ currentParamString: string; params: Record<ParamKeys, string | null> },
|
||||
{},
|
||||
RouteEvents
|
||||
>(
|
||||
return createMachine<RouteSetterContext, RouteEvents>(
|
||||
{
|
||||
predictableActionArguments: true,
|
||||
id: 'route',
|
||||
context: {
|
||||
currentParamString: '',
|
||||
|
||||
75
graph/client/src/app/machines/task-graph.machine.ts
Normal file
75
graph/client/src/app/machines/task-graph.machine.ts
Normal 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;
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
@ -4,11 +4,14 @@ import type {
|
||||
ProjectGraphProjectNode,
|
||||
} from '@nrwl/devkit';
|
||||
// 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';
|
||||
|
||||
export class MockProjectGraphService implements ProjectGraphService {
|
||||
private response: DepGraphClientResponse = {
|
||||
private projectGraphsResponse: DepGraphClientResponse = {
|
||||
hash: '79054025255fb1a26e4bc422aef54eb4',
|
||||
layout: {
|
||||
appsDir: 'apps',
|
||||
@ -56,21 +59,27 @@ export class MockProjectGraphService implements ProjectGraphService {
|
||||
groupByFolder: false,
|
||||
};
|
||||
|
||||
private taskGraphsResponse: TaskGraphClientResponse = { dependencies: {} };
|
||||
|
||||
constructor(updateFrequency: number = 5000) {
|
||||
setInterval(() => this.updateResponse(), updateFrequency);
|
||||
}
|
||||
|
||||
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> {
|
||||
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 {
|
||||
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 {
|
||||
name,
|
||||
@ -85,7 +94,7 @@ export class MockProjectGraphService implements ProjectGraphService {
|
||||
|
||||
private updateResponse() {
|
||||
const newProject = this.createNewProject();
|
||||
const libProjects = this.response.projects.filter(
|
||||
const libProjects = this.projectGraphsResponse.projects.filter(
|
||||
(project) => project.type === 'lib'
|
||||
);
|
||||
|
||||
@ -99,11 +108,11 @@ export class MockProjectGraphService implements ProjectGraphService {
|
||||
},
|
||||
];
|
||||
|
||||
this.response = {
|
||||
...this.response,
|
||||
projects: [...this.response.projects, newProject],
|
||||
this.projectGraphsResponse = {
|
||||
...this.projectGraphsResponse,
|
||||
projects: [...this.projectGraphsResponse.projects, newProject],
|
||||
dependencies: {
|
||||
...this.response.dependencies,
|
||||
...this.projectGraphsResponse.dependencies,
|
||||
[newProject.name]: newDependency,
|
||||
},
|
||||
};
|
||||
|
||||
@ -3,7 +3,7 @@ import { redirect } from 'react-router-dom';
|
||||
import ProjectsSidebar from './feature-projects/projects-sidebar';
|
||||
import TasksSidebar from './feature-tasks/tasks-sidebar';
|
||||
import { getEnvironmentConfig } from './hooks/use-environment-config';
|
||||
import { getDepGraphService } from './machines/dep-graph.service';
|
||||
import { getProjectGraphService } from './machines/get-services';
|
||||
// nx-ignore-next-line
|
||||
import { DepGraphClientResponse } from 'nx/src/command-line/dep-graph';
|
||||
import { getProjectGraphDataService } from './hooks/get-project-graph-data-service';
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import {
|
||||
ArrowLeftCircleIcon,
|
||||
ArrowDownTrayIcon,
|
||||
ArrowLeftCircleIcon,
|
||||
InformationCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
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 { useSyncExternalStore } from 'use-sync-external-store/shim';
|
||||
|
||||
import DebuggerPanel from './debugger-panel';
|
||||
import { useDepGraphService } from './hooks/use-dep-graph';
|
||||
import DebuggerPanel from './ui-components/debugger-panel';
|
||||
import { useDepGraphSelector } from './hooks/use-dep-graph-selector';
|
||||
import { useEnvironmentConfig } from './hooks/use-environment-config';
|
||||
import { useIntervalWhen } from './hooks/use-interval-when';
|
||||
@ -21,27 +20,19 @@ import {
|
||||
lastPerfReportSelector,
|
||||
projectIsSelectedSelector,
|
||||
} from './machines/selectors';
|
||||
import ProjectsSidebar from './feature-projects/projects-sidebar';
|
||||
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 ThemePanel from './sidebar/theme-panel';
|
||||
import ThemePanel from './feature-projects/panels/theme-panel';
|
||||
import Dropdown from './ui-components/dropdown';
|
||||
import { useCurrentPath } from './hooks/use-current-path';
|
||||
import ExperimentalFeature from './experimental-feature';
|
||||
import RankdirPanel from './sidebar/rankdir-panel';
|
||||
|
||||
const tooltipService = getTooltipService();
|
||||
import RankdirPanel from './feature-projects/panels/rankdir-panel';
|
||||
import { getAppService, getProjectGraphService } from './machines/get-services';
|
||||
import TooltipDisplay from './ui-tooltips/graph-tooltip-display';
|
||||
|
||||
export function Shell(): JSX.Element {
|
||||
const depGraphService = useDepGraphService();
|
||||
|
||||
const currentTooltip = useSyncExternalStore(
|
||||
(callback) => tooltipService.subscribe(callback),
|
||||
() => tooltipService.currentTooltip
|
||||
);
|
||||
const appService = getAppService();
|
||||
const depGraphService = getProjectGraphService();
|
||||
|
||||
const projectGraphService = getProjectGraphDataService();
|
||||
const environment = useEnvironmentConfig();
|
||||
@ -50,7 +41,7 @@ export function Shell(): JSX.Element {
|
||||
const environmentConfig = useEnvironmentConfig();
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>(
|
||||
environment.appConfig.defaultProjectGraph
|
||||
environment.appConfig.defaultProject
|
||||
);
|
||||
|
||||
const navigate = useNavigate();
|
||||
@ -71,36 +62,54 @@ export function Shell(): JSX.Element {
|
||||
useEffect(() => {
|
||||
const { appConfig } = environment;
|
||||
|
||||
const projectInfo = appConfig.projectGraphs.find(
|
||||
const projectInfo = appConfig.projects.find(
|
||||
(graph) => graph.id === selectedProjectId
|
||||
);
|
||||
|
||||
const fetchProjectGraph = async () => {
|
||||
const project: DepGraphClientResponse =
|
||||
await projectGraphService.getProjectGraph(projectInfo.url);
|
||||
const projectGraph: DepGraphClientResponse =
|
||||
await projectGraphService.getProjectGraph(projectInfo.projectGraphUrl);
|
||||
|
||||
const workspaceLayout = project?.layout;
|
||||
const workspaceLayout = projectGraph?.layout;
|
||||
|
||||
depGraphService.send({
|
||||
type: 'initGraph',
|
||||
projects: project.projects,
|
||||
dependencies: project.dependencies,
|
||||
affectedProjects: project.affected,
|
||||
appService.send({
|
||||
type: 'setProjects',
|
||||
projects: projectGraph.projects,
|
||||
dependencies: projectGraph.dependencies,
|
||||
affectedProjects: projectGraph.affected,
|
||||
workspaceLayout: workspaceLayout,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchTaskGraphs = async () => {
|
||||
const taskGraphs = await projectGraphService.getTaskGraph(
|
||||
projectInfo.taskGraphUrl
|
||||
);
|
||||
|
||||
appService.send({
|
||||
type: 'setTaskGraphs',
|
||||
taskGraphs: taskGraphs.dependencies,
|
||||
});
|
||||
};
|
||||
|
||||
fetchProjectGraph();
|
||||
}, [selectedProjectId, environment, depGraphService, projectGraphService]);
|
||||
|
||||
if (currentRoute === '/tasks') {
|
||||
fetchTaskGraphs();
|
||||
}
|
||||
}, [selectedProjectId, environment, appService, projectGraphService]);
|
||||
|
||||
useIntervalWhen(
|
||||
() => {
|
||||
const projectInfo = environment.appConfig.projectGraphs.find(
|
||||
const projectInfo = environment.appConfig.projects.find(
|
||||
(graph) => graph.id === selectedProjectId
|
||||
);
|
||||
|
||||
const fetchProjectGraph = async () => {
|
||||
const project: DepGraphClientResponse =
|
||||
await projectGraphService.getProjectGraph(projectInfo.url);
|
||||
await projectGraphService.getProjectGraph(
|
||||
projectInfo.projectGraphUrl
|
||||
);
|
||||
|
||||
depGraphService.send({
|
||||
type: 'updateGraph',
|
||||
@ -205,10 +214,10 @@ export function Shell(): JSX.Element {
|
||||
>
|
||||
{environment.appConfig.showDebugger ? (
|
||||
<DebuggerPanel
|
||||
projectGraphs={environment.appConfig.projectGraphs}
|
||||
selectedProjectGraph={selectedProjectId}
|
||||
projects={environment.appConfig.projects}
|
||||
selectedProject={selectedProjectId}
|
||||
lastPerfReport={lastPerfReport}
|
||||
projectGraphChange={projectChange}
|
||||
selectedProjectChange={projectChange}
|
||||
></DebuggerPanel>
|
||||
) : null}
|
||||
|
||||
@ -223,25 +232,7 @@ export function Shell(): JSX.Element {
|
||||
) : null}
|
||||
<div id="graph-container">
|
||||
<div id="cytoscape-graph"></div>
|
||||
{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}
|
||||
<TooltipDisplay></TooltipDisplay>
|
||||
|
||||
<Tippy
|
||||
content="Download Graph as PNG"
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { createContext } from 'react';
|
||||
import { InterpreterFrom } from 'xstate';
|
||||
import { depGraphMachine } from './machines/dep-graph.machine';
|
||||
import { getDepGraphService } from './machines/dep-graph.service';
|
||||
import { projectGraphMachine } from './machines/project-graph.machine';
|
||||
import { getProjectGraphService } from './machines/get-services';
|
||||
|
||||
export const GlobalStateContext = createContext<
|
||||
InterpreterFrom<typeof depGraphMachine>
|
||||
>({} as InterpreterFrom<typeof depGraphMachine>);
|
||||
InterpreterFrom<typeof projectGraphMachine>
|
||||
>({} as InterpreterFrom<typeof projectGraphMachine>);
|
||||
|
||||
export const GlobalStateProvider = (props) => {
|
||||
const depGraphService = getDepGraphService();
|
||||
const depGraphService = getProjectGraphService();
|
||||
|
||||
return (
|
||||
<GlobalStateContext.Provider value={depGraphService as any}>
|
||||
|
||||
@ -5,7 +5,7 @@ export default {
|
||||
component: DebuggerPanel,
|
||||
title: 'Shell/DebuggerPanel',
|
||||
argTypes: {
|
||||
projectGraphChange: { action: 'projectGraphChange' },
|
||||
selectedProjectChange: { action: 'projectGraphChange' },
|
||||
},
|
||||
} as ComponentMeta<typeof DebuggerPanel>;
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
import { memo } from 'react';
|
||||
import { ProjectGraphList } from './interfaces';
|
||||
import { GraphPerfReport } from './machines/interfaces';
|
||||
import Dropdown from './ui-components/dropdown';
|
||||
import { GraphListItem } from '../interfaces';
|
||||
import { GraphPerfReport } from '../machines/interfaces';
|
||||
import Dropdown from './dropdown';
|
||||
|
||||
export interface DebuggerPanelProps {
|
||||
projectGraphs: ProjectGraphList[];
|
||||
selectedProjectGraph: string;
|
||||
projectGraphChange: (projectName: string) => void;
|
||||
projects: GraphListItem[];
|
||||
selectedProject: string;
|
||||
selectedProjectChange: (projectName: string) => void;
|
||||
lastPerfReport: GraphPerfReport;
|
||||
}
|
||||
|
||||
export const DebuggerPanel = memo(function ({
|
||||
projectGraphs,
|
||||
selectedProjectGraph,
|
||||
projectGraphChange,
|
||||
projects,
|
||||
selectedProject,
|
||||
selectedProjectChange,
|
||||
lastPerfReport,
|
||||
}: DebuggerPanelProps) {
|
||||
return (
|
||||
@ -26,14 +26,14 @@ export const DebuggerPanel = memo(function ({
|
||||
</h4>
|
||||
<Dropdown
|
||||
data-cy="project-select"
|
||||
onChange={(event) => projectGraphChange(event.currentTarget.value)}
|
||||
onChange={(event) => selectedProjectChange(event.currentTarget.value)}
|
||||
>
|
||||
{projectGraphs.map((projectGraph) => {
|
||||
{projects.map((projectGraph) => {
|
||||
return (
|
||||
<option
|
||||
key={projectGraph.id}
|
||||
value={projectGraph.id}
|
||||
selected={projectGraph.id === selectedProjectGraph}
|
||||
selected={projectGraph.id === selectedProject}
|
||||
>
|
||||
{projectGraph.label}
|
||||
</option>
|
||||
@ -1,7 +1,7 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import { EdgeNodeTooltip, EdgeNodeTooltipProps } from './edge-tooltip';
|
||||
import ProjectNodeToolTip from './project-node-tooltip';
|
||||
import { selectValueByThemeStatic } from './theme-resolver';
|
||||
import { selectValueByThemeStatic } from '../theme-resolver';
|
||||
import Tippy from '@tippyjs/react';
|
||||
|
||||
export default {
|
||||
@ -1,4 +1,4 @@
|
||||
import Tag from './ui-components/tag';
|
||||
import Tag from '../ui-components/tag';
|
||||
|
||||
export interface EdgeNodeTooltipProps {
|
||||
type: string;
|
||||
35
graph/client/src/app/ui-tooltips/graph-tooltip-display.tsx
Normal file
35
graph/client/src/app/ui-tooltips/graph-tooltip-display.tsx
Normal 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;
|
||||
@ -1,10 +1,10 @@
|
||||
import { getDepGraphService } from './machines/dep-graph.service';
|
||||
import { getProjectGraphService } from '../machines/get-services';
|
||||
import {
|
||||
DocumentMagnifyingGlassIcon,
|
||||
FlagIcon,
|
||||
MapPinIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import Tag from './ui-components/tag';
|
||||
import Tag from '../ui-components/tag';
|
||||
|
||||
export interface ProjectNodeToolTipProps {
|
||||
type: 'app' | 'lib' | 'e2e';
|
||||
@ -17,7 +17,7 @@ export function ProjectNodeToolTip({
|
||||
id,
|
||||
tags,
|
||||
}: ProjectNodeToolTipProps) {
|
||||
const depGraphService = getDepGraphService();
|
||||
const depGraphService = getProjectGraphService();
|
||||
|
||||
function onFocus() {
|
||||
depGraphService.send({
|
||||
@ -1,4 +1,4 @@
|
||||
import { getGraphService } from './machines/graph.service';
|
||||
import { getGraphService } from '../machines/graph.service';
|
||||
|
||||
import { VirtualElement } from '@popperjs/core';
|
||||
import { ProjectNodeToolTipProps } from './project-node-tooltip';
|
||||
@ -6,17 +6,19 @@ window.useXstateInspect = false;
|
||||
window.appConfig = {
|
||||
showDebugger: true,
|
||||
showExperimentalFeatures: true,
|
||||
projectGraphs: [
|
||||
projects: [
|
||||
{
|
||||
id: 'e2e',
|
||||
label: 'e2e',
|
||||
url: 'assets/graphs/e2e.json',
|
||||
projectGraphUrl: 'assets/project-graphs/e2e.json',
|
||||
taskGraphUrl: 'assets/task-graphs/e2e.json',
|
||||
},
|
||||
{
|
||||
id: 'affected',
|
||||
label: 'affected',
|
||||
url: 'assets/graphs/affected.json',
|
||||
projectGraphUrl: 'assets/project-graphs/affected.json',
|
||||
taskGraphUrl: 'assets/task-graphs/affected.json',
|
||||
},
|
||||
],
|
||||
defaultProjectGraph: 'e2e',
|
||||
defaultProject: 'e2e',
|
||||
};
|
||||
|
||||
@ -6,12 +6,13 @@ window.useXstateInspect = false;
|
||||
window.appConfig = {
|
||||
showDebugger: false,
|
||||
showExperimentalFeatures: false,
|
||||
projectGraphs: [
|
||||
projects: [
|
||||
{
|
||||
id: 'local',
|
||||
label: 'local',
|
||||
url: 'assets/graphs/e2e.json',
|
||||
projectGraphUrl: 'assets/project-graphs/e2e.json',
|
||||
taskGraphUrl: 'assets/task-graphs/e2e.json',
|
||||
},
|
||||
],
|
||||
defaultProjectGraph: 'local',
|
||||
defaultProject: 'local',
|
||||
};
|
||||
|
||||
@ -7,14 +7,15 @@ window.localMode = 'build';
|
||||
window.appConfig = {
|
||||
showDebugger: false,
|
||||
showExperimentalFeatures: false,
|
||||
projectGraphs: [
|
||||
projects: [
|
||||
{
|
||||
id: '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 = {
|
||||
|
||||
@ -6,12 +6,13 @@ window.useXstateInspect = false;
|
||||
window.appConfig = {
|
||||
showDebugger: false,
|
||||
showExperimentalFeatures: false,
|
||||
projectGraphs: [
|
||||
projects: [
|
||||
{
|
||||
id: 'local',
|
||||
label: 'local',
|
||||
url: 'assets/graphs/e2e.json',
|
||||
projectGraphUrl: 'assets/project-graphs/e2e.json',
|
||||
taskGraphUrl: 'assets/task-graphs/e2e.json',
|
||||
},
|
||||
],
|
||||
defaultProjectGraph: 'local',
|
||||
defaultProject: 'local',
|
||||
};
|
||||
|
||||
@ -6,12 +6,13 @@ window.useXstateInspect = false;
|
||||
window.appConfig = {
|
||||
showDebugger: false,
|
||||
showExperimentalFeatures: true,
|
||||
projectGraphs: [
|
||||
projects: [
|
||||
{
|
||||
id: 'local',
|
||||
label: 'local',
|
||||
url: 'project-graph.json',
|
||||
projectGraphUrl: 'assets/project-graphs/e2e.json',
|
||||
taskGraphUrl: 'assets/task-graphs/e2e.json',
|
||||
},
|
||||
],
|
||||
defaultProjectGraph: 'local',
|
||||
defaultProject: 'local',
|
||||
};
|
||||
|
||||
6
graph/client/src/globals.d.ts
vendored
6
graph/client/src/globals.d.ts
vendored
@ -1,5 +1,8 @@
|
||||
// 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 { ExternalApi } from './app/machines/externalApi';
|
||||
|
||||
@ -9,6 +12,7 @@ export declare global {
|
||||
watch: boolean;
|
||||
localMode: 'serve' | 'build';
|
||||
projectGraphResponse?: DepGraphClientResponse;
|
||||
taskGraphResponse?: TaskGraphClientResponse;
|
||||
environment: 'dev' | 'watch' | 'release' | 'nx-console';
|
||||
appConfig: AppConfig;
|
||||
useXstateInspect: boolean;
|
||||
|
||||
@ -1,94 +1,42 @@
|
||||
// nx-ignore-next-line
|
||||
import type {
|
||||
ProjectGraphDependency,
|
||||
ProjectGraphProjectNode,
|
||||
} from '@nrwl/devkit';
|
||||
import type { VirtualElement } from '@popperjs/core';
|
||||
import cy from 'cytoscape';
|
||||
import { CollectionReturnValue, use } from 'cytoscape';
|
||||
import cytoscapeDagre from 'cytoscape-dagre';
|
||||
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 {
|
||||
darkModeScratchKey,
|
||||
switchValueByDarkMode,
|
||||
} from './styles-graph/dark-mode';
|
||||
import { GraphInteractionEvents } from './graph-interaction-events';
|
||||
|
||||
const cytoscapeDagreConfig = {
|
||||
name: 'dagre',
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
rankSep: 75,
|
||||
rankDir: 'TB',
|
||||
edgeSep: 50,
|
||||
ranker: 'network-simplex',
|
||||
} as CytoscapeDagreConfig;
|
||||
import { RenderGraph } from './util-cytoscape/render-graph';
|
||||
import { ProjectTraversalGraph } from './util-cytoscape/project-traversal-graph';
|
||||
|
||||
export class GraphService {
|
||||
private traversalGraph: cy.Core;
|
||||
private renderGraph: cy.Core;
|
||||
|
||||
private collapseEdges = false;
|
||||
private traversalGraph: ProjectTraversalGraph;
|
||||
private renderGraph: RenderGraph;
|
||||
|
||||
private listeners = new Map<
|
||||
number,
|
||||
(event: GraphInteractionEvents) => void
|
||||
>();
|
||||
|
||||
private _theme: 'light' | 'dark';
|
||||
private _rankDir: 'TB' | 'LR' = 'TB';
|
||||
|
||||
constructor(
|
||||
private container: string | HTMLElement,
|
||||
|
||||
container: string | HTMLElement,
|
||||
theme: 'light' | 'dark',
|
||||
private renderMode?: 'nx-console' | 'nx-docs',
|
||||
renderMode?: 'nx-console' | 'nx-docs',
|
||||
rankDir: 'TB' | 'LR' = 'TB'
|
||||
) {
|
||||
cy.use(cytoscapeDagre);
|
||||
cy.use(popper);
|
||||
use(cytoscapeDagre);
|
||||
use(popper);
|
||||
|
||||
this._theme = theme;
|
||||
this._rankDir = rankDir;
|
||||
}
|
||||
this.renderGraph = new RenderGraph(container, theme, renderMode, rankDir);
|
||||
|
||||
get activeContainer() {
|
||||
return typeof this.container === 'string'
|
||||
? document.getElementById(this.container)
|
||||
: this.container;
|
||||
this.renderGraph.listen((event) => this.broadcast(event));
|
||||
this.traversalGraph = new ProjectTraversalGraph();
|
||||
}
|
||||
|
||||
set theme(theme: 'light' | 'dark') {
|
||||
this._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);
|
||||
}
|
||||
this.renderGraph.theme = theme;
|
||||
}
|
||||
|
||||
set rankDir(rankDir: 'TB' | 'LR') {
|
||||
this._rankDir = rankDir;
|
||||
if (this.renderGraph) {
|
||||
const elements = this.renderGraph.elements();
|
||||
elements
|
||||
.layout({
|
||||
...cytoscapeDagreConfig,
|
||||
...{ rankDir: rankDir },
|
||||
} as CytoscapeDagreConfig)
|
||||
.run();
|
||||
}
|
||||
this.renderGraph.rankDir = rankDir;
|
||||
}
|
||||
|
||||
listen(callback: (event: GraphInteractionEvents) => void) {
|
||||
@ -110,16 +58,19 @@ export class GraphService {
|
||||
} {
|
||||
const time = Date.now();
|
||||
|
||||
if (this.renderGraph && event.type !== 'notifyGraphUpdateGraph') {
|
||||
this.renderGraph.nodes('.focused').removeClass('focused');
|
||||
this.renderGraph.unmount();
|
||||
if (event.type !== 'notifyGraphUpdateGraph') {
|
||||
this.renderGraph.clearFocussedElement();
|
||||
}
|
||||
|
||||
this.broadcast({ type: 'GraphRegenerated' });
|
||||
|
||||
let elementsToSendToRender: CollectionReturnValue;
|
||||
|
||||
switch (event.type) {
|
||||
case 'notifyGraphInitGraph':
|
||||
this.initGraph(
|
||||
this.renderGraph.collapseEdges = event.collapseEdges;
|
||||
this.broadcast({ type: 'GraphRegenerated' });
|
||||
this.traversalGraph.initGraph(
|
||||
event.projects,
|
||||
event.groupByFolder,
|
||||
event.workspaceLayout,
|
||||
@ -130,7 +81,9 @@ export class GraphService {
|
||||
break;
|
||||
|
||||
case 'notifyGraphUpdateGraph':
|
||||
this.initGraph(
|
||||
this.renderGraph.collapseEdges = event.collapseEdges;
|
||||
this.broadcast({ type: 'GraphRegenerated' });
|
||||
this.traversalGraph.initGraph(
|
||||
event.projects,
|
||||
event.groupByFolder,
|
||||
event.workspaceLayout,
|
||||
@ -138,19 +91,23 @@ export class GraphService {
|
||||
event.affectedProjects,
|
||||
event.collapseEdges
|
||||
);
|
||||
this.setShownProjects(
|
||||
elementsToSendToRender = this.traversalGraph.setShownProjects(
|
||||
event.selectedProjects.length > 0
|
||||
? event.selectedProjects
|
||||
: this.renderGraph.nodes(':childless').map((node) => node.id())
|
||||
: this.renderGraph.getCurrentlyShownProjectIds()
|
||||
);
|
||||
break;
|
||||
|
||||
case 'notifyGraphFocusProject':
|
||||
this.focusProject(event.projectName, event.searchDepth);
|
||||
elementsToSendToRender = this.traversalGraph.focusProject(
|
||||
event.projectName,
|
||||
event.searchDepth
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
case 'notifyGraphFilterProjectsByText':
|
||||
this.filterProjectsByText(
|
||||
elementsToSendToRender = this.traversalGraph.filterProjectsByText(
|
||||
event.search,
|
||||
event.includeProjectsByPath,
|
||||
event.searchDepth
|
||||
@ -158,31 +115,43 @@ export class GraphService {
|
||||
break;
|
||||
|
||||
case 'notifyGraphShowProjects':
|
||||
this.showProjects(event.projectNames);
|
||||
elementsToSendToRender = this.traversalGraph.showProjects(
|
||||
event.projectNames,
|
||||
this.renderGraph.getCurrentlyShownProjectIds()
|
||||
);
|
||||
break;
|
||||
|
||||
case 'notifyGraphHideProjects':
|
||||
this.hideProjects(event.projectNames);
|
||||
elementsToSendToRender = this.traversalGraph.hideProjects(
|
||||
event.projectNames,
|
||||
this.renderGraph.getCurrentlyShownProjectIds()
|
||||
);
|
||||
break;
|
||||
|
||||
case 'notifyGraphShowAllProjects':
|
||||
this.showAllProjects();
|
||||
elementsToSendToRender = this.traversalGraph.showAllProjects();
|
||||
break;
|
||||
|
||||
case 'notifyGraphHideAllProjects':
|
||||
this.hideAllProjects();
|
||||
elementsToSendToRender = this.traversalGraph.hideAllProjects();
|
||||
break;
|
||||
|
||||
case 'notifyGraphShowAffectedProjects':
|
||||
this.showAffectedProjects();
|
||||
elementsToSendToRender = this.traversalGraph.showAffectedProjects();
|
||||
break;
|
||||
|
||||
case 'notifyGraphTracing':
|
||||
if (event.start && event.end) {
|
||||
if (event.algorithm === 'shortest') {
|
||||
this.traceProjects(event.start, event.end);
|
||||
elementsToSendToRender = this.traversalGraph.traceProjects(
|
||||
event.start,
|
||||
event.end
|
||||
);
|
||||
} else {
|
||||
this.traceAllProjects(event.start, event.end);
|
||||
elementsToSendToRender = this.traversalGraph.traceAllProjects(
|
||||
event.start,
|
||||
event.end
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@ -195,572 +164,32 @@ export class GraphService {
|
||||
renderTime: 0,
|
||||
};
|
||||
|
||||
if (this.renderGraph) {
|
||||
const elements = this.renderGraph.elements().sort((a, b) => {
|
||||
return a.id().localeCompare(b.id());
|
||||
});
|
||||
if (this.renderGraph && elementsToSendToRender) {
|
||||
this.renderGraph.setElements(elementsToSendToRender);
|
||||
|
||||
elements
|
||||
.layout({
|
||||
...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();
|
||||
if (event.type === 'notifyGraphFocusProject') {
|
||||
this.renderGraph.setFocussedElement(event.projectName);
|
||||
}
|
||||
|
||||
let sourceId, targetId;
|
||||
const { numEdges, numNodes } = this.renderGraph.render();
|
||||
|
||||
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.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);
|
||||
selectedProjectNames = (
|
||||
elementsToSendToRender.nodes('[type!="dir"]') ?? []
|
||||
).map((node) => node.id());
|
||||
|
||||
const renderTime = Date.now() - time;
|
||||
|
||||
perfReport = {
|
||||
renderTime,
|
||||
numNodes: this.renderGraph.nodes().length,
|
||||
numEdges: this.renderGraph.edges().length,
|
||||
numNodes,
|
||||
numEdges,
|
||||
};
|
||||
}
|
||||
|
||||
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() {
|
||||
const bg = switchValueByDarkMode(this.renderGraph, '#0F172A', '#FFFFFF');
|
||||
return this.renderGraph.png({ bg, full: true });
|
||||
return this.renderGraph.getImage();
|
||||
}
|
||||
}
|
||||
|
||||
340
graph/ui-graph/src/lib/util-cytoscape/project-traversal-graph.ts
Normal file
340
graph/ui-graph/src/lib/util-cytoscape/project-traversal-graph.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
336
graph/ui-graph/src/lib/util-cytoscape/render-graph.ts
Normal file
336
graph/ui-graph/src/lib/util-cytoscape/render-graph.ts
Normal 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')) ?? [];
|
||||
}
|
||||
}
|
||||
@ -114,9 +114,9 @@
|
||||
"@typescript-eslint/parser": "5.38.1",
|
||||
"@typescript-eslint/type-utils": "^5.36.1",
|
||||
"@typescript-eslint/utils": "5.38.1",
|
||||
"@xstate/immer": "^0.2.0",
|
||||
"@xstate/inspect": "^0.5.1",
|
||||
"@xstate/react": "^1.6.3",
|
||||
"@xstate/immer": "^0.3.1",
|
||||
"@xstate/inspect": "^0.7.0",
|
||||
"@xstate/react": "^3.0.1",
|
||||
"ajv": "^8.11.0",
|
||||
"autoprefixer": "10.4.12",
|
||||
"babel-jest": "28.1.3",
|
||||
@ -246,7 +246,7 @@
|
||||
"webpack-node-externals": "^3.0.0",
|
||||
"webpack-sources": "^3.2.3",
|
||||
"webpack-subresource-integrity": "^5.1.0",
|
||||
"xstate": "^4.25.0",
|
||||
"xstate": "^4.34.0",
|
||||
"yargs": "^17.6.2",
|
||||
"yargs-parser": "21.1.1"
|
||||
},
|
||||
|
||||
@ -77,14 +77,15 @@ function buildEnvironmentJs(
|
||||
window.appConfig = {
|
||||
showDebugger: false,
|
||||
showExperimentalFeatures: false,
|
||||
projectGraphs: [
|
||||
projects: [
|
||||
{
|
||||
id: 'local',
|
||||
label: 'local',
|
||||
url: 'project-graph.json',
|
||||
projectGraphUrl: 'project-graph.json',
|
||||
taskGraphUrl: 'task-graph.json'
|
||||
}
|
||||
],
|
||||
defaultProjectGraph: 'local',
|
||||
defaultProject: 'local',
|
||||
};
|
||||
`;
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import * as yargs from 'yargs';
|
||||
import { ensureDirSync } from 'fs-extra';
|
||||
import { join } from 'path';
|
||||
import { writeFileSync, readdirSync } from 'fs';
|
||||
import { readdirSync, writeFileSync } from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
function generateFileContent(
|
||||
@ -9,17 +8,16 @@ function generateFileContent(
|
||||
) {
|
||||
return `
|
||||
window.exclude = [];
|
||||
window.watch = false;
|
||||
window.environment = 'dev';
|
||||
window.useXstateInspect = false;
|
||||
window.watch = false;
|
||||
window.environment = 'dev';
|
||||
window.useXstateInspect = false;
|
||||
|
||||
window.appConfig = {
|
||||
window.appConfig = {
|
||||
showDebugger: true,
|
||||
showExperimentalFeatures: true,
|
||||
projectGraphs: ${JSON.stringify(projects)},
|
||||
defaultProjectGraph: '${projects[0].id}',
|
||||
};
|
||||
|
||||
projects: ${JSON.stringify(projects)},
|
||||
defaultProject: '${projects[0].id}',
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
@ -27,13 +25,14 @@ function writeFile() {
|
||||
let generatedGraphs;
|
||||
try {
|
||||
generatedGraphs = readdirSync(
|
||||
join(__dirname, '../graph/client/src/assets/generated-graphs')
|
||||
join(__dirname, '../graph/client/src/assets/generated-project-graphs')
|
||||
).map((filename) => {
|
||||
const id = filename.substring(0, filename.length - 5);
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
url: join('assets/generated-graphs/', filename),
|
||||
projectGraphUrl: join('assets/generated-project-graphs/', filename),
|
||||
taskGraphUrl: join('assets/generated-task-graphs/', filename),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
@ -43,13 +42,13 @@ function writeFile() {
|
||||
let pregeneratedGraphs;
|
||||
try {
|
||||
pregeneratedGraphs = readdirSync(
|
||||
join(__dirname, '../graph/client/src/assets/graphs')
|
||||
join(__dirname, '../graph/client/src/assets/project-graphs')
|
||||
).map((filename) => {
|
||||
const id = filename.substring(0, filename.length - 5);
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
url: join('assets/graphs/', filename),
|
||||
url: join('assets/project-graphs/', filename),
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
|
||||
@ -28,18 +28,34 @@ async function generateGraph(directory: string, name: string) {
|
||||
/window.projectGraphResponse = (.*?);/
|
||||
);
|
||||
|
||||
const taskGraphResponse = environmentJs.match(
|
||||
/window.taskGraphResponse = (.*?);/
|
||||
);
|
||||
|
||||
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(
|
||||
join(
|
||||
__dirname,
|
||||
'../graph/client/src/assets/generated-graphs/',
|
||||
'../graph/client/src/assets/generated-project-graphs/',
|
||||
`${name}.json`
|
||||
),
|
||||
projectGraphResponse[1]
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(
|
||||
__dirname,
|
||||
'../graph/client/src/assets/generated-task-graphs/',
|
||||
`${name}.json`
|
||||
),
|
||||
taskGraphResponse[1]
|
||||
);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
@ -49,8 +65,7 @@ async function generateGraph(directory: string, name: string) {
|
||||
.option('name', {
|
||||
type: 'string',
|
||||
requiresArg: true,
|
||||
description:
|
||||
'The version to publish. This does not need to be passed and can be inferred.',
|
||||
description: 'The snake-case name of the file created',
|
||||
})
|
||||
.option('directory', {
|
||||
type: 'string',
|
||||
@ -59,5 +74,5 @@ async function generateGraph(directory: string, name: string) {
|
||||
})
|
||||
.parseSync();
|
||||
|
||||
generateGraph(parsedArgs.directory, parsedArgs.name);
|
||||
await generateGraph(parsedArgs.directory, parsedArgs.name);
|
||||
})();
|
||||
|
||||
47
yarn.lock
47
yarn.lock
@ -6715,25 +6715,25 @@
|
||||
"@webassemblyjs/wast-parser" "1.9.0"
|
||||
"@xtuc/long" "4.2.2"
|
||||
|
||||
"@xstate/immer@^0.2.0":
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@xstate/immer/-/immer-0.2.0.tgz#4f128947c3cbb3e68357b886485a36852d4e06b3"
|
||||
integrity sha512-ZKwAwS84kfmN108lEtVHw8jztKDiFeaQsTxkOlOghpK1Lr7+13G8HhZZXyN1/pVkplloUUOPMH5EXVtitZDr8w==
|
||||
"@xstate/immer@^0.3.1":
|
||||
version "0.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@xstate/immer/-/immer-0.3.1.tgz#73a7948f7f248e00dc287b55290a949cd8276b3d"
|
||||
integrity sha512-YE+KY08IjEEmXo6XKKpeSGW4j9LfcXw+5JVixLLUO3fWQ3M95joWJ40VtGzx0w0zQSzoCNk8NgfvwWBGSbIaTA==
|
||||
|
||||
"@xstate/inspect@^0.5.1":
|
||||
version "0.5.2"
|
||||
resolved "https://registry.yarnpkg.com/@xstate/inspect/-/inspect-0.5.2.tgz#83d58c96f704ceaab6f7849e578d8e6ce212038c"
|
||||
integrity sha512-DdqUPiKaHW6VpnVZcm8YMD8LBeS3B9bB3+VT/6VEyilgvf2MgYzho2dKOOkeZM0iDEadSmzGdDpz0jh7DSpMXQ==
|
||||
"@xstate/inspect@^0.7.0":
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@xstate/inspect/-/inspect-0.7.0.tgz#0e3011d0fb8eca6d68f06a7c384ab1390801e176"
|
||||
integrity sha512-3wrTf8TfBYprH1gBFdxmOQUBDpBazlICWvGdFzr8IHFL4MbiexEZdAsL2QC/WAmW9BqNYTWTwgfbvKHKg+FrlA==
|
||||
dependencies:
|
||||
fast-safe-stringify "^2.0.7"
|
||||
fast-safe-stringify "^2.1.1"
|
||||
|
||||
"@xstate/react@^1.6.3":
|
||||
version "1.6.3"
|
||||
resolved "https://registry.yarnpkg.com/@xstate/react/-/react-1.6.3.tgz#706f3beb7bc5879a78088985c8fd43b9dab7f725"
|
||||
integrity sha512-NCUReRHPGvvCvj2yLZUTfR0qVp6+apc8G83oXSjN4rl89ZjyujiKrTff55bze/HrsvCsP/sUJASf2n0nzMF1KQ==
|
||||
"@xstate/react@^3.0.1":
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.1.tgz#937eeb5d5d61734ab756ca40146f84a6fe977095"
|
||||
integrity sha512-/tq/gg92P9ke8J+yDNDBv5/PAxBvXJf2cYyGDByzgtl5wKaxKxzDT82Gj3eWlCJXkrBg4J5/V47//gRJuVH2fA==
|
||||
dependencies:
|
||||
use-isomorphic-layout-effect "^1.0.0"
|
||||
use-subscription "^1.3.0"
|
||||
use-sync-external-store "^1.0.0"
|
||||
|
||||
"@xtuc/ieee754@^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"
|
||||
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"
|
||||
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884"
|
||||
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"
|
||||
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
|
||||
|
||||
use-subscription@^1.3.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:
|
||||
use-sync-external-store@1.2.0, use-sync-external-store@^1.0.0:
|
||||
version "1.2.0"
|
||||
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==
|
||||
@ -23088,10 +23081,10 @@ xmlhttprequest-ssl@~1.6.2:
|
||||
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6"
|
||||
integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==
|
||||
|
||||
xstate@^4.25.0:
|
||||
version "4.33.6"
|
||||
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.33.6.tgz#9e23f78879af106f1de853aba7acb2bc3b1eb950"
|
||||
integrity sha512-A5R4fsVKADWogK2a43ssu8Fz1AF077SfrKP1ZNyDBD8lNa/l4zfR//Luofp5GSWehOQr36Jp0k2z7b+sH2ivyg==
|
||||
xstate@^4.34.0:
|
||||
version "4.34.0"
|
||||
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.34.0.tgz#401901c478f0b2a7f07576c020b6e6f750b5bd10"
|
||||
integrity sha512-MFnYz7cJrWuXSZ8IPkcCyLB1a2T3C71kzMeShXKmNaEjBR/JQebKZPHTtxHKZpymESaWO31rA3IQ30TC6LW+sw==
|
||||
|
||||
xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1:
|
||||
version "4.0.2"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user