chore(dep-graph): move project focus selection to cytoscape (#7780)

This commit is contained in:
Philip Fulcher 2021-12-02 00:10:54 -07:00 committed by GitHub
parent 764d69bdc4
commit 56aaeb7931
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 995 additions and 383 deletions

View File

@ -3,17 +3,15 @@ import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/de
import { fromEvent } from 'rxjs';
import { startWith } from 'rxjs/operators';
import { DebuggerPanel } from './debugger-panel';
import { GraphComponent } from './graph';
import { useGraphService } from './graph.service';
import { useDepGraphService } from './machines/dep-graph.service';
import { DepGraphSend } from './machines/interfaces';
import { DepGraphUIEvents, DepGraphSend } from './machines/interfaces';
import { AppConfig, DEFAULT_CONFIG, ProjectGraphService } from './models';
import { GraphTooltipService } from './tooltip-service';
import { SidebarComponent } from './ui-sidebar/sidebar';
export class AppComponent {
private sidebar = new SidebarComponent();
private tooltipService = new GraphTooltipService();
private graph = new GraphComponent(this.tooltipService);
private graph = useGraphService();
private debuggerPanel: DebuggerPanel;
private windowResize$ = fromEvent(window, 'resize').pipe(startWith({}));
@ -24,7 +22,16 @@ export class AppComponent {
private config: AppConfig = DEFAULT_CONFIG,
private projectGraphService: ProjectGraphService
) {
const [_, send] = useDepGraphService();
const [state$, send] = useDepGraphService();
state$.subscribe((state) => {
if (state.context.selectedProjects.length !== 0) {
document.getElementById('no-projects-chosen').style.display = 'none';
} else {
document.getElementById('no-projects-chosen').style.display = 'flex';
}
});
this.send = send;
this.loadProjectGraph(config.defaultProjectGraph);
@ -47,7 +54,6 @@ export class AppComponent {
await this.projectGraphService.getProjectGraph(projectInfo.url);
const workspaceLayout = project?.layout;
this.send({
type: 'initGraph',
projects: project.projects,

View File

@ -0,0 +1,15 @@
import { GraphService } from './graph';
import { GraphTooltipService } from './tooltip-service';
let graphService: GraphService;
export function useGraphService(): GraphService {
if (!graphService) {
graphService = new GraphService(
new GraphTooltipService(),
'graph-container'
);
}
return graphService;
}

View File

@ -1,15 +1,11 @@
import type {
ProjectGraph,
ProjectGraphDependency,
ProjectGraphNode,
} from '@nrwl/devkit';
import type { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
import type { VirtualElement } from '@popperjs/core';
import * as cy from 'cytoscape';
import cytoscapeDagre from 'cytoscape-dagre';
import popper from 'cytoscape-popper';
import * as cytoscapeDagre from 'cytoscape-dagre';
import * as popper from 'cytoscape-popper';
import { Subject } from 'rxjs';
import type { Instance } from 'tippy.js';
import { useDepGraphService } from './machines/dep-graph.service';
import { GraphRenderEvents } from './machines/interfaces';
import { ProjectNodeToolTip } from './project-node-tooltip';
import { edgeStyles, nodeStyles } from './styles-graph';
import { GraphTooltipService } from './tooltip-service';
@ -25,129 +21,350 @@ export interface GraphPerfReport {
numNodes: number;
numEdges: number;
}
export class GraphComponent {
private graph: cy.Core;
export class GraphService {
private traversalGraph: cy.Core;
private renderGraph: cy.Core;
private openTooltip: Instance = null;
private renderTimesSubject = new Subject<GraphPerfReport>();
renderTimes$ = this.renderTimesSubject.asObservable();
private send;
constructor(private tooltipService: GraphTooltipService) {
constructor(
private tooltipService: GraphTooltipService,
private containerId: string
) {
cy.use(cytoscapeDagre);
cy.use(popper);
const [state$, send] = useDepGraphService();
this.send = send;
state$.subscribe((state) => {
const projects = state.context.selectedProjects.map((projectName) =>
state.context.projects.find((project) => project.name === projectName)
);
this.render(
projects,
state.context.groupByFolder,
state.context.workspaceLayout,
state.context.focusedProject,
state.context.affectedProjects,
state.context.dependencies
);
});
}
render(
selectedProjects: ProjectGraphNode[],
groupByFolder: boolean,
workspaceLayout,
focusedProject: string,
affectedProjects: string[],
dependencies: Record<string, ProjectGraphDependency[]>
) {
handleEvent(event: GraphRenderEvents): string[] {
const time = Date.now();
if (selectedProjects.length === 0) {
document.getElementById('no-projects-chosen').style.display = 'flex';
} else {
document.getElementById('no-projects-chosen').style.display = 'none';
if (
this.renderGraph &&
event.type !== 'notifyGraphFocusProject' &&
event.type !== 'notifyGraphUpdateGraph'
) {
this.renderGraph.nodes('.focused').removeClass('focused');
}
this.tooltipService.hideAll();
this.generateCytoscapeLayout(
selectedProjects,
groupByFolder,
workspaceLayout,
focusedProject,
affectedProjects,
dependencies
);
this.listenForProjectNodeClicks();
this.listenForProjectNodeHovers();
switch (event.type) {
case 'notifyGraphInitGraph':
this.initGraph(
event.projects,
event.groupByFolder,
event.workspaceLayout,
event.dependencies,
event.affectedProjects
);
break;
const renderTime = Date.now() - time;
case 'notifyGraphUpdateGraph':
this.initGraph(
event.projects,
event.groupByFolder,
event.workspaceLayout,
event.dependencies,
event.affectedProjects
);
this.setShownProjects(event.selectedProjects);
break;
const report: GraphPerfReport = {
renderTime,
numNodes: this.graph.nodes().length,
numEdges: this.graph.edges().length,
};
case 'notifyGraphFocusProject':
this.focusProject(event.projectName, event.searchDepth);
break;
this.renderTimesSubject.next(report);
case 'notifyGraphFilterProjectsByText':
this.filterProjectsByText(
event.search,
event.includeProjectsByPath,
event.searchDepth
);
break;
case 'notifyGraphShowProject':
this.showProjects([event.projectName]);
break;
case 'notifyGraphHideProject':
this.hideProject(event.projectName);
break;
case 'notifyGraphShowAllProjects':
this.showAllProjects();
break;
case 'notifyGraphHideAllProjects':
this.hideAllProjects();
break;
case 'notifyGraphShowAffectedProjects':
this.showAffectedProjects();
break;
}
let visibleProjects: string[] = [];
if (this.renderGraph) {
this.renderGraph
.elements()
.sort((a, b) => a.id().localeCompare(b.id()))
.layout(<CytoscapeDagreConfig>{
name: 'dagre',
nodeDimensionsIncludeLabels: true,
rankSep: 75,
rankDir: 'TB',
edgeSep: 50,
ranker: 'network-simplex',
})
.run();
this.renderGraph.fit().center().resize();
visibleProjects = this.renderGraph
.nodes('[type!="dir"]')
.map((node) => node.id());
const renderTime = Date.now() - time;
const report: GraphPerfReport = {
renderTime,
numNodes: this.renderGraph.nodes().length,
numEdges: this.renderGraph.edges().length,
};
this.renderTimesSubject.next(report);
}
return visibleProjects;
}
private generateCytoscapeLayout(
selectedProjects: ProjectGraphNode[],
groupByFolder: boolean,
workspaceLayout,
focusedProject: string,
affectedProjects: string[],
dependencies: Record<string, ProjectGraphDependency[]>
) {
const elements = this.createElements(
selectedProjects,
groupByFolder,
workspaceLayout,
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));
}
hideProject(projectName: string) {
const currentNodes =
this.renderGraph?.nodes() ?? this.traversalGraph.collection();
const nodeToHide = this.renderGraph.$id(projectName);
const nodesToAdd = currentNodes.difference(nodeToHide);
const ancestorsToAdd = nodesToAdd.ancestors();
const 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,
affectedProjects,
dependencies
searchDepth
);
this.graph = cy({
container: document.getElementById('graph-container'),
elements: [...elements],
layout: <CytoscapeDagreConfig>{
name: 'dagre',
nodeDimensionsIncludeLabels: true,
rankSep: 75,
edgeSep: 50,
ranker: 'network-simplex',
},
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
) {
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));
}
private transferToRenderGraph(elements: cy.Collection) {
let currentFocusedProjectName;
if (this.renderGraph) {
currentFocusedProjectName = this.renderGraph
.nodes('.focused')
.first()
.id();
this.renderGraph.destroy();
delete this.renderGraph;
}
const container = document.getElementById(this.containerId);
this.renderGraph = cy({
container: container,
headless: !container,
boxSelectionEnabled: false,
style: [...nodeStyles, ...edgeStyles],
});
this.graph.on('zoom', () => {
this.renderGraph.add(elements);
if (!!currentFocusedProjectName) {
this.renderGraph.$id(currentFocusedProjectName).addClass('focused');
}
this.renderGraph.on('zoom', () => {
if (this.openTooltip) {
this.openTooltip.hide();
this.openTooltip = null;
}
});
this.listenForProjectNodeClicks();
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: ProjectGraphNode[],
groupByFolder: boolean,
workspaceLayout,
dependencies: Record<string, ProjectGraphDependency[]>,
affectedProjectIds: string[]
) {
this.tooltipService.hideAll();
this.generateCytoscapeLayout(
allProjects,
groupByFolder,
workspaceLayout,
dependencies,
affectedProjectIds
);
}
private generateCytoscapeLayout(
allProjects: ProjectGraphNode[],
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(
selectedProjects: ProjectGraphNode[],
projects: ProjectGraphNode[],
groupByFolder: boolean,
workspaceLayout: {
appsDir: string;
libsDir: string;
},
focusedProject: string,
affectedProjects: string[],
dependencies: Record<string, ProjectGraphDependency[]>
dependencies: Record<string, ProjectGraphDependency[]>,
affectedProjectIds: string[]
) {
let elements: cy.ElementDefinition[] = [];
const filteredProjectNames = selectedProjects.map(
(project) => project.name
);
const filteredProjectNames = projects.map((project) => project.name);
const projectNodes: ProjectNode[] = [];
const edgeNodes: ProjectEdge[] = [];
@ -156,24 +373,21 @@ export class GraphComponent {
{ id: string; parentId: string; label: string }
> = {};
selectedProjects.forEach((project) => {
projects.forEach((project) => {
const workspaceRoot =
project.type === 'app' || project.type === 'e2e'
? workspaceLayout.appsDir
: workspaceLayout.libsDir;
const projectNode = new ProjectNode(project, workspaceRoot);
projectNode.focused = project.name === focusedProject;
projectNode.affected = affectedProjects.includes(project.name);
projectNode.affected = affectedProjectIds.includes(project.name);
projectNodes.push(projectNode);
dependencies[project.name].forEach((dep) => {
if (filteredProjectNames.includes(dep.target)) {
const edge = new ProjectEdge(dep);
edge.affected =
affectedProjects.includes(dep.source) &&
affectedProjects.includes(dep.target);
edgeNodes.push(edge);
}
});
@ -205,7 +419,7 @@ export class GraphComponent {
}
listenForProjectNodeClicks() {
this.graph.$('node:childless').on('click', (event) => {
this.renderGraph.$('node:childless').on('click', (event) => {
const node = event.target;
let ref: VirtualElement = node.popperRef(); // used only for positioning
@ -217,11 +431,11 @@ export class GraphComponent {
}
listenForProjectNodeHovers(): void {
this.graph.on('mouseover', (event) => {
this.renderGraph.on('mouseover', (event) => {
const node = event.target;
if (!node.isNode || !node.isNode() || node.isParent()) return;
this.graph
this.renderGraph
.elements()
.difference(node.outgoers().union(node.incomers()))
.not(node)
@ -232,11 +446,12 @@ export class GraphComponent {
.union(node.incomers())
.addClass('highlight');
});
this.graph.on('mouseout', (event) => {
this.renderGraph.on('mouseout', (event) => {
const node = event.target;
if (!node.isNode || !node.isNode() || node.isParent()) return;
this.graph.elements().removeClass('transparent');
this.renderGraph.elements().removeClass('transparent');
node
.removeClass('highlight')
.outgoers()

View File

@ -1,23 +1,40 @@
import { assign } from '@xstate/immer';
import { send } from 'xstate';
import { DepGraphStateNodeConfig } from './interfaces';
export const customSelectedStateConfig: DepGraphStateNodeConfig = {
on: {
updateGraph: {
target: 'customSelected',
actions: [
assign((ctx, event) => {
const existingProjectNames = ctx.projects.map(
(project) => project.name
);
const newProjectNames = event.projects.map((project) => project.name);
const selectedProjects = newProjectNames.filter(
const newSelectedProjects = newProjectNames.filter(
(projectName) => !existingProjectNames.includes(projectName)
);
ctx.projects = event.projects;
ctx.dependencies = event.dependencies;
ctx.selectedProjects = [...ctx.selectedProjects, ...selectedProjects];
ctx.selectedProjects = [
...ctx.selectedProjects,
...newSelectedProjects,
];
}),
'setGraph',
send(
(ctx, event) => ({
type: 'notifyGraphUpdateGraph',
projects: ctx.projects,
dependencies: ctx.dependencies,
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
groupByFolder: ctx.groupByFolder,
selectedProjects: ctx.selectedProjects,
}),
{
to: (context) => context.graph,
}
),
],
},
},

View File

@ -1,8 +1,13 @@
import { assign } from '@xstate/immer';
import { Machine } from 'xstate';
import { Machine, send, spawn } from 'xstate';
import { useGraphService } from '../graph.service';
import { customSelectedStateConfig } from './custom-selected.state';
import { focusedStateConfig } from './focused.state';
import { DepGraphContext, DepGraphEvents, DepGraphSchema } from './interfaces';
import {
DepGraphContext,
DepGraphUIEvents,
DepGraphSchema,
} from './interfaces';
import { textFilteredStateConfig } from './text-filtered.state';
import { unselectedStateConfig } from './unselected.state';
@ -21,12 +26,25 @@ export const initialContext: DepGraphContext = {
libsDir: '',
appsDir: '',
},
graph: null,
};
const graphActor = (callback, receive) => {
const graphService = useGraphService();
receive((e) => {
const selectedProjectNames = graphService.handleEvent(e);
callback({
type: 'setSelectedProjectsFromGraph',
selectedProjectNames,
});
});
};
export const depGraphMachine = Machine<
DepGraphContext,
DepGraphSchema,
DepGraphEvents
DepGraphUIEvents
>(
{
id: 'DepGraph',
@ -42,37 +60,39 @@ export const depGraphMachine = Machine<
on: {
initGraph: {
target: 'unselected',
actions: [
'setGraph',
send(
(ctx, event) => ({
type: 'notifyGraphInitGraph',
projects: ctx.projects,
dependencies: ctx.dependencies,
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
groupByFolder: ctx.groupByFolder,
}),
{
to: (context) => context.graph,
}
),
],
},
setSelectedProjectsFromGraph: {
actions: assign((ctx, event) => {
ctx.projects = event.projects;
ctx.affectedProjects = event.affectedProjects;
ctx.dependencies = event.dependencies;
ctx.workspaceLayout = event.workspaceLayout;
ctx.selectedProjects = event.selectedProjectNames;
}),
},
selectProject: {
target: 'customSelected',
actions: [
assign((ctx, event) => {
ctx.selectedProjects.push(event.projectName);
}),
],
actions: ['notifyGraphShowProject'],
},
selectAll: {
target: 'customSelected',
actions: [
assign((ctx) => {
ctx.selectedProjects = ctx.projects.map((project) => project.name);
}),
],
actions: ['notifyGraphShowAllProjects'],
},
selectAffected: {
target: 'customSelected',
actions: [
assign((ctx) => {
ctx.selectedProjects = ctx.affectedProjects;
}),
],
actions: ['notifyGraphShowAffectedProjects'],
},
deselectProject: [
{
@ -81,15 +101,7 @@ export const depGraphMachine = Machine<
},
{
target: 'customSelected',
actions: [
assign((ctx, event) => {
const index = ctx.selectedProjects.findIndex(
(project) => project === event.projectName
);
ctx.selectedProjects.splice(index, 1);
}),
],
actions: ['notifyGraphHideProject'],
},
],
deselectAll: {
@ -100,9 +112,21 @@ export const depGraphMachine = Machine<
},
setGroupByFolder: {
actions: [
assign((ctx, event: any) => {
ctx.groupByFolder = event.groupByFolder;
}),
'setGroupByFolder',
send(
(ctx, event) => ({
type: 'notifyGraphUpdateGraph',
projects: ctx.projects,
dependencies: ctx.dependencies,
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
groupByFolder: ctx.groupByFolder,
selectedProjects: ctx.selectedProjects,
}),
{
to: (context) => context.graph,
}
),
],
},
setIncludeProjectsByPath: {
@ -113,25 +137,13 @@ export const depGraphMachine = Machine<
],
},
incrementSearchDepth: {
actions: [
assign((ctx) => {
ctx.searchDepth = ctx.searchDepth + 1;
}),
],
actions: ['incrementSearchDepth'],
},
decrementSearchDepth: {
actions: [
assign((ctx) => {
ctx.searchDepth = ctx.searchDepth > 1 ? ctx.searchDepth - 1 : 1;
}),
],
actions: ['decrementSearchDepth'],
},
setSearchDepthEnabled: {
actions: [
assign((ctx, event) => {
ctx.searchDepthEnabled = event.searchDepthEnabled;
}),
],
actions: ['setSearchDepthEnabled'],
},
filterByText: {
target: 'textFiltered',
@ -144,5 +156,111 @@ export const depGraphMachine = Machine<
return ctx.selectedProjects.length <= 1;
},
},
actions: {
setGroupByFolder: assign((ctx, event) => {
if (event.type !== 'setGroupByFolder') return;
ctx.groupByFolder = event.groupByFolder;
}),
incrementSearchDepth: assign((ctx) => {
ctx.searchDepth = ctx.searchDepth + 1;
}),
decrementSearchDepth: assign((ctx) => {
ctx.searchDepth = ctx.searchDepth > 1 ? ctx.searchDepth - 1 : 1;
}),
setSearchDepthEnabled: assign((ctx, event) => {
if (event.type !== 'setSearchDepthEnabled') return;
ctx.searchDepthEnabled = event.searchDepthEnabled;
}),
setIncludeProjectsByPath: assign((ctx, event) => {
if (event.type !== 'setIncludeProjectsByPath') return;
ctx.includePath = event.includeProjectsByPath;
}),
setGraph: assign((ctx, event) => {
if (event.type !== 'initGraph' && event.type !== 'updateGraph') return;
ctx.projects = event.projects;
ctx.dependencies = event.dependencies;
ctx.graph = spawn(graphActor, 'graphActor');
if (event.type === 'initGraph') {
ctx.workspaceLayout = event.workspaceLayout;
ctx.affectedProjects = event.affectedProjects;
}
}),
notifyGraphShowProject: send(
(context, event) => {
if (event.type !== 'selectProject') return;
return {
type: 'notifyGraphShowProject',
projectName: event.projectName,
};
},
{
to: (context) => context.graph,
}
),
notifyGraphHideProject: send(
(context, event) => {
if (event.type !== 'deselectProject') return;
return {
type: 'notifyGraphHideProject',
projectName: event.projectName,
};
},
{
to: (context) => context.graph,
}
),
notifyGraphShowAllProjects: send(
(context, event) => ({
type: 'notifyGraphShowAllProjects',
}),
{
to: (context) => context.graph,
}
),
notifyGraphHideAllProjects: send(
(context, event) => ({
type: 'notifyGraphHideAllProjects',
}),
{
to: (context) => context.graph,
}
),
notifyGraphShowAffectedProjects: send(
{
type: 'notifyGraphShowAffectedProjects',
},
{
to: (ctx) => ctx.graph,
}
),
notifyGraphFocusProject: send(
(context, event) => ({
type: 'notifyGraphFocusProject',
projectName: context.focusedProject,
searchDepth: context.searchDepthEnabled ? context.searchDepth : -1,
}),
{
to: (context) => context.graph,
}
),
notifyGraphFilterProjectsByText: send(
(context, event) => ({
type: 'notifyGraphFilterProjectsByText',
search: context.textFilter,
includeProjectsByPath: context.includePath,
searchDepth: context.searchDepthEnabled ? context.searchDepth : -1,
}),
{
to: (context) => context.graph,
}
),
},
}
);

View File

@ -4,15 +4,16 @@ import { interpret, Interpreter, Typestate } from 'xstate';
import { depGraphMachine } from './dep-graph.machine';
import {
DepGraphContext,
DepGraphEvents,
DepGraphUIEvents,
DepGraphSend,
DepGraphStateObservable,
DepGraphSchema,
} from './interfaces';
let depGraphService: Interpreter<
DepGraphContext,
any,
DepGraphEvents,
DepGraphSchema,
DepGraphUIEvents,
Typestate<DepGraphContext>
>;

View File

@ -1,5 +1,6 @@
import { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
import { depGraphMachine } from './dep-graph.machine';
import { interpret } from 'xstate';
export const mockProjects: ProjectGraphNode[] = [
{
@ -74,6 +75,7 @@ export const mockDependencies: Record<string, ProjectGraphDependency[]> = {
},
],
'ui-lib': [],
'auth-lib': [],
};
describe('dep-graph machine', () => {
@ -109,8 +111,20 @@ describe('dep-graph machine', () => {
});
describe('selecting projects', () => {
it('should select projects', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
it('should select projects', (done) => {
let service = interpret(depGraphMachine).onTransition((state) => {
if (
state.matches('customSelected') &&
state.context.selectedProjects.includes('app1') &&
state.context.selectedProjects.includes('app2')
) {
done();
}
});
service.start();
service.send({
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
@ -118,26 +132,33 @@ describe('dep-graph machine', () => {
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
result = depGraphMachine.transition(result, {
service.send({
type: 'selectProject',
projectName: 'app1',
});
expect(result.value).toEqual('customSelected');
expect(result.context.selectedProjects).toEqual(['app1']);
result = depGraphMachine.transition(result, {
service.send({
type: 'selectProject',
projectName: 'app2',
});
expect(result.context.selectedProjects).toEqual(['app1', 'app2']);
});
});
describe('deselecting projects', () => {
it('should deselect projects', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
it('should deselect projects', (done) => {
let service = interpret(depGraphMachine).onTransition((state) => {
if (
state.matches('customSelected') &&
!state.context.selectedProjects.includes('app1') &&
state.context.selectedProjects.includes('app2')
) {
done();
}
});
service.start();
service.send({
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
@ -145,23 +166,20 @@ describe('dep-graph machine', () => {
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
result = depGraphMachine.transition(result, {
service.send({
type: 'selectProject',
projectName: 'app1',
});
result = depGraphMachine.transition(result, {
service.send({
type: 'selectProject',
projectName: 'app2',
});
result = depGraphMachine.transition(result, {
service.send({
type: 'deselectProject',
projectName: 'app1',
});
expect(result.value).toEqual('customSelected');
expect(result.context.selectedProjects).toEqual(['app2']);
});
it('should go to unselected when last project is deselected', () => {
@ -217,8 +235,22 @@ describe('dep-graph machine', () => {
expect(result.context.focusedProject).toEqual('app1');
});
it('should select the projects by the focused project', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
it('should select the projects by the focused project', (done) => {
let service = interpret(depGraphMachine).onTransition((state) => {
if (
state.matches('focused') &&
state.context.selectedProjects.includes('app1') &&
state.context.selectedProjects.includes('ui-lib') &&
state.context.selectedProjects.includes('feature-lib1') &&
state.context.selectedProjects.includes('auth-lib')
) {
done();
}
});
service.start();
service.send({
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
@ -226,17 +258,7 @@ describe('dep-graph machine', () => {
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
result = depGraphMachine.transition(result, {
type: 'focusProject',
projectName: 'app1',
});
expect(result.context.selectedProjects).toEqual([
'app1',
'ui-lib',
'feature-lib1',
'auth-lib',
]);
service.send({ type: 'focusProject', projectName: 'app1' });
});
it('should select no projects on unfocus', () => {

View File

@ -1,19 +1,16 @@
import { assign } from '@xstate/immer';
import { send } from 'xstate';
import { selectProjectsForFocusedProject } from '../util';
import { DepGraphStateNodeConfig } from './interfaces';
export const focusedStateConfig: DepGraphStateNodeConfig = {
entry: [
assign((ctx, event: any) => {
ctx.selectedProjects = selectProjectsForFocusedProject(
ctx.projects,
ctx.dependencies,
event.projectName,
ctx.searchDepthEnabled ? ctx.searchDepth : -1
);
assign((ctx, event) => {
if (event.type !== 'focusProject') return;
ctx.focusedProject = event.projectName;
}),
'notifyGraphFocusProject',
],
exit: [
assign((ctx) => {
@ -22,69 +19,35 @@ export const focusedStateConfig: DepGraphStateNodeConfig = {
],
on: {
incrementSearchDepth: {
actions: [
assign((ctx) => {
const searchDepth = ctx.searchDepth + 1;
const selectedProjects = selectProjectsForFocusedProject(
ctx.projects,
ctx.dependencies,
ctx.focusedProject,
ctx.searchDepthEnabled ? searchDepth : -1
);
ctx.selectedProjects = selectedProjects;
ctx.searchDepth = searchDepth;
}),
],
actions: ['incrementSearchDepth', 'notifyGraphFocusProject'],
},
decrementSearchDepth: {
actions: [
assign((ctx) => {
const searchDepth = ctx.searchDepth > 1 ? ctx.searchDepth - 1 : 1;
const selectedProjects = selectProjectsForFocusedProject(
ctx.projects,
ctx.dependencies,
ctx.focusedProject,
ctx.searchDepthEnabled ? searchDepth : -1
);
ctx.selectedProjects = selectedProjects;
ctx.searchDepth = searchDepth;
}),
],
actions: ['decrementSearchDepth', 'notifyGraphFocusProject'],
},
setSearchDepthEnabled: {
actions: [
assign((ctx, event) => {
const selectedProjects = selectProjectsForFocusedProject(
ctx.projects,
ctx.dependencies,
ctx.focusedProject,
event.searchDepthEnabled ? ctx.searchDepth : -1
);
(ctx.searchDepthEnabled = event.searchDepthEnabled),
(ctx.selectedProjects = selectedProjects);
}),
],
actions: ['setSearchDepthEnabled', 'notifyGraphFocusProject'],
},
unfocusProject: {
target: 'unselected',
},
updateGraph: {
actions: [
assign((ctx, event) => {
const selectedProjects = selectProjectsForFocusedProject(
event.projects,
event.dependencies,
ctx.focusedProject,
ctx.searchDepthEnabled ? ctx.searchDepth : -1
);
ctx.projects = event.projects;
ctx.dependencies = event.dependencies;
ctx.selectedProjects = selectedProjects;
}),
'setGraph',
send(
(ctx, event) => ({
type: 'notifyGraphUpdateGraph',
projects: ctx.projects,
dependencies: ctx.dependencies,
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
groupByFolder: ctx.groupByFolder,
selectedProjects: ctx.selectedProjects,
}),
{
to: (context) => context.graph,
}
),
'notifyGraphFocusProject',
],
},
},

View File

@ -1,6 +1,7 @@
import { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
import { Observable } from 'rxjs';
import { ActionObject, StateNodeConfig, StateValue } from 'xstate';
import { ActionObject, ActorRef, StateNodeConfig, StateValue } from 'xstate';
import { GraphService } from '../graph';
// The hierarchical (recursive) schema for the states
export interface DepGraphSchema {
@ -14,7 +15,9 @@ export interface DepGraphSchema {
}
// The events that the machine handles
export type DepGraphEvents =
export type DepGraphUIEvents =
| { type: 'setSelectedProjectsFromGraph'; selectedProjectNames: string[] }
| { type: 'selectProject'; projectName: string }
| { type: 'deselectProject'; projectName: string }
| { type: 'selectAll' }
@ -45,6 +48,63 @@ export type DepGraphEvents =
dependencies: Record<string, ProjectGraphDependency[]>;
};
// The events that the graph actor handles
export type GraphRenderEvents =
| {
type: 'notifyGraphInitGraph';
projects: ProjectGraphNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
groupByFolder: boolean;
}
| {
type: 'notifyGraphUpdateGraph';
projects: ProjectGraphNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
groupByFolder: boolean;
selectedProjects: string[];
}
| {
type: 'notifyGraphFocusProject';
projectName: string;
searchDepth: number;
}
| {
type: 'notifyGraphShowProject';
projectName: string;
}
| {
type: 'notifyGraphHideProject';
projectName: string;
}
| {
type: 'notifyGraphShowAllProjects';
}
| {
type: 'notifyGraphHideAllProjects';
}
| {
type: 'notifyGraphShowAffectedProjects';
}
| {
type: 'notifyGraphFilterProjectsByText';
search: string;
includeProjectsByPath: boolean;
searchDepth: number;
};
export type AllEvents = DepGraphUIEvents | GraphRenderEvents;
// The context (extended state) of the machine
export interface DepGraphContext {
projects: ProjectGraphNode[];
@ -61,16 +121,19 @@ export interface DepGraphContext {
libsDir: string;
appsDir: string;
};
graph: ActorRef<GraphRenderEvents>;
}
export type DepGraphStateNodeConfig = StateNodeConfig<
DepGraphContext,
{},
DepGraphEvents,
ActionObject<DepGraphContext, DepGraphEvents>
DepGraphUIEvents,
ActionObject<DepGraphContext, DepGraphUIEvents>
>;
export type DepGraphSend = (event: DepGraphEvents | DepGraphEvents[]) => void;
export type DepGraphSend = (
event: DepGraphUIEvents | DepGraphUIEvents[]
) => void;
export type DepGraphStateObservable = Observable<{
value: StateValue;
context: DepGraphContext;

View File

@ -1,19 +1,15 @@
import { assign } from '@xstate/immer';
import { filterProjectsByText } from '../util';
import { send } from 'xstate';
import { DepGraphStateNodeConfig } from './interfaces';
export const textFilteredStateConfig: DepGraphStateNodeConfig = {
entry: [
assign((ctx, event: any) => {
assign((ctx, event) => {
if (event.type !== 'filterByText') return;
ctx.textFilter = event.search;
ctx.selectedProjects = filterProjectsByText(
event.search,
ctx.includePath,
ctx.searchDepthEnabled ? ctx.searchDepth : -1,
ctx.projects,
ctx.dependencies
);
}),
'notifyGraphFilterProjectsByText',
],
on: {
clearTextFilter: {
@ -24,79 +20,35 @@ export const textFilteredStateConfig: DepGraphStateNodeConfig = {
}),
},
setIncludeProjectsByPath: {
actions: [
assign((ctx, event) => {
ctx.includePath = event.includeProjectsByPath;
ctx.selectedProjects = filterProjectsByText(
ctx.textFilter,
event.includeProjectsByPath,
ctx.searchDepthEnabled ? ctx.searchDepth : -1,
ctx.projects,
ctx.dependencies
);
}),
],
actions: ['setIncludeProjectsByPath', 'notifyGraphFilterProjectsByText'],
},
incrementSearchDepth: {
actions: [
assign((ctx) => {
const searchDepth = ctx.searchDepth + 1;
ctx.selectedProjects = filterProjectsByText(
ctx.textFilter,
ctx.includePath,
ctx.searchDepthEnabled ? searchDepth : -1,
ctx.projects,
ctx.dependencies
);
ctx.searchDepth = searchDepth;
}),
],
actions: ['incrementSearchDepth', 'notifyGraphFilterProjectsByText'],
},
decrementSearchDepth: {
actions: [
assign((ctx) => {
const searchDepth = ctx.searchDepth > 1 ? ctx.searchDepth - 1 : 1;
ctx.selectedProjects = filterProjectsByText(
ctx.textFilter,
ctx.includePath,
ctx.searchDepthEnabled ? searchDepth : -1,
ctx.projects,
ctx.dependencies
);
ctx.searchDepth = searchDepth;
}),
],
actions: ['decrementSearchDepth', 'notifyGraphFilterProjectsByText'],
},
setSearchDepthEnabled: {
actions: [
assign((ctx, event) => {
ctx.searchDepthEnabled = event.searchDepthEnabled;
ctx.selectedProjects = filterProjectsByText(
ctx.textFilter,
ctx.includePath,
event.searchDepthEnabled ? ctx.searchDepth : -1,
ctx.projects,
ctx.dependencies
);
}),
],
actions: ['setSearchDepthEnabled', 'notifyGraphFilterProjectsByText'],
},
updateGraph: {
actions: [
assign((ctx, event) => {
ctx.selectedProjects = filterProjectsByText(
ctx.textFilter,
ctx.includePath,
ctx.searchDepthEnabled ? ctx.searchDepth : -1,
event.projects,
event.dependencies
);
ctx.projects = event.projects;
ctx.dependencies = event.dependencies;
}),
'setGraph',
send(
(ctx, event) => ({
type: 'notifyGraphUpdateGraph',
projects: ctx.projects,
dependencies: ctx.dependencies,
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
groupByFolder: ctx.groupByFolder,
selectedProjects: ctx.selectedProjects,
}),
{
to: (context) => context.graph,
}
),
'notifyGraphFilterProjectsByText',
],
},
},

View File

@ -1,28 +1,43 @@
import { assign } from '@xstate/immer';
import { send } from 'xstate';
import { useGraphService } from '../graph.service';
import { DepGraphStateNodeConfig } from './interfaces';
export const unselectedStateConfig: DepGraphStateNodeConfig = {
entry: [
assign((ctx) => {
ctx.selectedProjects = [];
}),
],
entry: ['notifyGraphHideAllProjects'],
on: {
updateGraph: {
target: 'customSelected',
actions: [
assign((ctx, event) => {
const existingProjectNames = ctx.projects.map(
(project) => project.name
);
const newProjectNames = event.projects.map((project) => project.name);
const selectedProjects = newProjectNames.filter(
const newSelectedProjects = newProjectNames.filter(
(projectName) => !existingProjectNames.includes(projectName)
);
ctx.projects = event.projects;
ctx.dependencies = event.dependencies;
ctx.selectedProjects = [...ctx.selectedProjects, ...selectedProjects];
ctx.selectedProjects = [
...ctx.selectedProjects,
...newSelectedProjects,
];
}),
'setGraph',
send(
(ctx, event) => ({
type: 'notifyGraphUpdateGraph',
projects: ctx.projects,
dependencies: ctx.dependencies,
affectedProjects: ctx.affectedProjects,
workspaceLayout: ctx.workspaceLayout,
groupByFolder: ctx.groupByFolder,
selectedProjects: ctx.selectedProjects,
}),
{
to: (context) => context.graph,
}
),
],
},
},

View File

@ -15,11 +15,16 @@ export class DisplayOptionsPanel {
this.render();
state$.subscribe((state) => {
if (state.context.affectedProjects.length > 0) {
if (
state.context.affectedProjects.length > 0 &&
this.affectedButtonElement.classList.contains('hidden')
) {
this.affectedButtonElement.classList.remove('hidden');
this.affectedButtonElement.addEventListener('click', () =>
this.send({ type: 'selectAffected' })
);
} else if (
state.context.affectedProjects.length === 0 &&
!this.affectedButtonElement.classList.contains('hidden')
) {
this.affectedButtonElement.classList.add('hidden');
}
this.searchDepthDisplay.innerText = state.context.searchDepth.toString();
@ -111,6 +116,10 @@ export class DisplayOptionsPanel {
'[data-cy="affectedButton"]'
);
this.affectedButtonElement.addEventListener('click', () =>
this.send({ type: 'selectAffected' })
);
const selectAllButtonElement: HTMLElement = element.querySelector(
'[data-cy="selectAllButton"]'
);

View File

@ -12,6 +12,7 @@ export class ParentNode {
id: this.config.id,
parent: this.config.parentId,
label: this.config.label,
type: 'dir',
},
selectable: false,
grabbable: false,

View File

@ -48,10 +48,6 @@ export class ProjectNode {
private getClasses(): string {
let classes = this.project.type ?? '';
if (this.focused) {
classes += ' focused';
}
if (this.affected) {
classes += ' affected';
}

View File

@ -33,6 +33,16 @@ window.appConfig = {
label: 'Storybook',
url: 'assets/graphs/storybook.json',
},
{
id: 'focus-testing',
label: 'Focus',
url: 'assets/graphs/focus-testing.json',
},
{
id: 'affected',
label: 'Affected',
url: 'assets/graphs/affected.json',
},
],
defaultProjectGraph: 'nx',
};

View File

@ -0,0 +1,119 @@
{
"hash": "1c2b69586aa096dc5e42eb252d0b5bfb94f20dc969a1e7b6f381a3b13add6928",
"layout": {
"appsDir": "apps",
"libsDir": "libs"
},
"projects": [
{
"name": "app1",
"type": "app",
"data": {
"tags": [],
"root": "apps/app1"
}
},
{
"name": "app2",
"type": "app",
"data": {
"tags": [],
"root": "apps/app2"
}
},
{
"name": "lib1",
"type": "lib",
"data": {
"tags": [],
"root": "libs/lib1"
}
},
{
"name": "lib2",
"type": "lib",
"data": {
"tags": [],
"root": "libs/lib2"
}
},
{
"name": "lib3",
"type": "lib",
"data": {
"tags": [],
"root": "libs/lib3"
}
},
{
"name": "lib4",
"type": "lib",
"data": {
"tags": [],
"root": "libs/lib4"
}
},
{
"name": "lib5",
"type": "lib",
"data": {
"tags": [],
"root": "libs/lib5"
}
}
],
"dependencies": {
"app1": [
{
"type": "static",
"source": "app1",
"target": "lib1"
}
],
"app2": [
{
"type": "static",
"source": "app2",
"target": "lib2"
},
{
"type": "static",
"source": "app2",
"target": "lib5"
}
],
"lib1": [
{
"type": "static",
"source": "lib1",
"target": "lib3"
}
],
"lib2": [
{
"type": "static",
"source": "lib2",
"target": "lib3"
}
],
"lib3": [
{
"type": "static",
"source": "lib3",
"target": "lib4"
}
],
"lib4": [],
"lib5": [
{
"type": "static",
"source": "lib5",
"target": "lib4"
}
]
},
"affected": ["lib3", "lib1", "lib2", "app1", "app2"],
"changes": {
"added": []
}
}

View File

@ -0,0 +1,119 @@
{
"hash": "1c2b69586aa096dc5e42eb252d0b5bfb94f20dc969a1e7b6f381a3b13add6928",
"layout": {
"appsDir": "apps",
"libsDir": "libs"
},
"projects": [
{
"name": "app1",
"type": "app",
"data": {
"tags": [],
"root": "apps/app1"
}
},
{
"name": "app2",
"type": "app",
"data": {
"tags": [],
"root": "apps/app2"
}
},
{
"name": "lib1",
"type": "app",
"data": {
"tags": [],
"root": "libs/lib1"
}
},
{
"name": "lib2",
"type": "app",
"data": {
"tags": [],
"root": "libs/lib2"
}
},
{
"name": "lib3",
"type": "app",
"data": {
"tags": [],
"root": "libs/lib3"
}
},
{
"name": "lib4",
"type": "app",
"data": {
"tags": [],
"root": "libs/lib4"
}
},
{
"name": "lib5",
"type": "app",
"data": {
"tags": [],
"root": "libs/lib5"
}
}
],
"dependencies": {
"app1": [
{
"type": "static",
"source": "app1",
"target": "lib1"
}
],
"app2": [
{
"type": "static",
"source": "app2",
"target": "lib2"
},
{
"type": "static",
"source": "app2",
"target": "lib5"
}
],
"lib1": [
{
"type": "static",
"source": "lib1",
"target": "lib3"
}
],
"lib2": [
{
"type": "static",
"source": "lib2",
"target": "lib3"
}
],
"lib3": [
{
"type": "static",
"source": "lib3",
"target": "lib4"
}
],
"lib4": [],
"lib5": [
{
"type": "static",
"source": "lib5",
"target": "lib4"
}
]
},
"affected": [],
"changes": {
"added": []
}
}

View File

@ -1,29 +0,0 @@
import { ProjectGraphList } from '../app/models';
export const projectGraphs: ProjectGraphList[] = [
{
id: 'nx',
label: 'Nx',
url: 'assets/graphs/nx.json',
},
{
id: 'ocean',
label: 'Ocean',
url: 'assets/graphs/ocean.json',
},
{
id: 'nx-examples',
label: 'Nx Examples',
url: 'assets/graphs/nx-examples.json',
},
{
id: 'sub-apps',
label: 'Sub Apps',
url: 'assets/graphs/sub-apps.json',
},
{
id: 'storybook',
label: 'Storybook',
url: 'assets/graphs/storybook.json',
},
];

View File

@ -95,8 +95,8 @@
"@testing-library/react": "11.2.6",
"@testing-library/react-hooks": "7.0.1",
"@types/css-minimizer-webpack-plugin": "^3.0.2",
"@types/cytoscape": "^3.14.12",
"@types/eslint": "^8.2.0",
"@types/cytoscape": "^3.18.2",
"@types/express": "4.17.0",
"@types/find-parent-dir": "^0.3.0",
"@types/flat": "^5.0.1",

View File

@ -4569,7 +4569,7 @@
dependencies:
postcss "5 - 7"
"@types/cytoscape@^3.14.12":
"@types/cytoscape@^3.18.2":
version "3.19.0"
resolved "https://registry.yarnpkg.com/@types/cytoscape/-/cytoscape-3.19.0.tgz#1acd8df260af0eb088191f84df8ca055353f6196"
integrity sha512-EbUrbDqequXtSkpPvtpX1Xf7nDFh+eB/2h/Sv1SiQ9IjCJENSwi9Wfv/vRkH9YTcgNoCot20eyIyMbxHfh0YDg==