feat(dep-graph): use xstate for state management (#7634)

This commit is contained in:
Philip Fulcher 2021-11-08 21:00:45 -07:00 committed by GitHub
parent 07c256b4dc
commit 5f9279a3ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1409 additions and 778 deletions

1
.gitignore vendored
View File

@ -11,3 +11,4 @@ tmp
jest.debug.config.js jest.debug.config.js
.tool-versions .tool-versions
/.verdaccio/build/local-registry /.verdaccio/build/local-registry
dep-graph/dep-graph/src/assets/environment.js

View File

@ -8,7 +8,8 @@
"options": { "options": {
"cypressConfig": "dep-graph/dep-graph-e2e/cypress.json", "cypressConfig": "dep-graph/dep-graph-e2e/cypress.json",
"tsConfig": "dep-graph/dep-graph-e2e/tsconfig.e2e.json", "tsConfig": "dep-graph/dep-graph-e2e/tsconfig.e2e.json",
"devServerTarget": "dep-graph-dep-graph:serve" "devServerTarget": "dep-graph-dep-graph:serve-for-e2e",
"baseUrl": "http://localhost:4200"
} }
}, },
"e2e-watch-disabled": { "e2e-watch-disabled": {
@ -16,7 +17,8 @@
"options": { "options": {
"cypressConfig": "dep-graph/dep-graph-e2e/cypress-watch-mode.json", "cypressConfig": "dep-graph/dep-graph-e2e/cypress-watch-mode.json",
"tsConfig": "dep-graph/dep-graph-e2e/tsconfig.e2e.json", "tsConfig": "dep-graph/dep-graph-e2e/tsconfig.e2e.json",
"devServerTarget": "dep-graph-dep-graph:serve:watch" "devServerTarget": "dep-graph-dep-graph:serve-for-e2e:watch",
"baseUrl": "http://localhost:4200"
} }
}, },
"lint": { "lint": {

View File

@ -12,7 +12,12 @@ import {
describe('dep-graph-client', () => { describe('dep-graph-client', () => {
beforeEach(() => { beforeEach(() => {
cy.intercept('/assets/graphs/*').as('getGraph');
cy.visit('/'); cy.visit('/');
// wait for first graph to finish loading
cy.wait('@getGraph');
}); });
it('should display message to select projects', () => { it('should display message to select projects', () => {
@ -51,12 +56,12 @@ describe('dep-graph-client', () => {
describe('selecting projects', () => { describe('selecting projects', () => {
it('should select a project by clicking on the project name', () => { it('should select a project by clicking on the project name', () => {
// cy.get('[data-project="nx-dev"]').should('have.data', 'active', false); cy.get('[data-project="nx-dev"]').should('have.data', 'active', false);
cy.get('[data-project="nx-dev"]') cy.get('[data-project="nx-dev"]').click({
.click({ force: true,
force: true, });
})
.should('have.data', 'active', true); cy.get('[data-project="nx-dev"]').should('have.data', 'active', true);
}); });
it('should deselect a project by clicking on the project name again', () => { it('should deselect a project by clicking on the project name again', () => {

View File

@ -32,39 +32,16 @@
}, },
"configurations": { "configurations": {
"dev": { "dev": {
"fileReplacements": [ "fileReplacements": [],
{
"replace": "dep-graph/dep-graph/src/environments/environment.ts",
"with": "dep-graph/dep-graph/src/environments/environment.dev.ts"
}
],
"assets": [ "assets": [
"dep-graph/dep-graph/src/favicon.ico", "dep-graph/dep-graph/src/favicon.ico",
"dep-graph/dep-graph/src/assets" "dep-graph/dep-graph/src/assets/graphs/",
],
"optimization": false,
"outputHashing": "none",
"sourceMap": true,
"extractCss": true,
"namedChunks": false,
"extractLicenses": false,
"vendorChunk": true,
"budgets": [
{ {
"type": "initial", "input": "dep-graph/dep-graph/src/assets",
"maximumWarning": "2mb", "output": "/",
"maximumError": "5mb" "glob": "environment.js"
}
]
},
"watch": {
"fileReplacements": [
{
"replace": "dep-graph/dep-graph/src/environments/environment.ts",
"with": "dep-graph/dep-graph/src/environments/environment.watch.ts"
} }
], ],
"assets": [],
"optimization": false, "optimization": false,
"outputHashing": "none", "outputHashing": "none",
"sourceMap": true, "sourceMap": true,
@ -83,15 +60,10 @@
}, },
"outputs": ["{options.outputPath}"] "outputs": ["{options.outputPath}"]
}, },
"serve": { "serve-base": {
"executor": "@nrwl/web:dev-server", "executor": "@nrwl/web:dev-server",
"options": { "options": {
"buildTarget": "dep-graph-dep-graph:build-base:dev" "buildTarget": "dep-graph-dep-graph:build-base:dev"
},
"configurations": {
"watch": {
"buildTarget": "dep-graph-dep-graph:build-base:watch"
}
} }
}, },
"lint": { "lint": {
@ -112,6 +84,44 @@
"jestConfig": "dep-graph/dep-graph/jest.config.js", "jestConfig": "dep-graph/dep-graph/jest.config.js",
"passWithNoTests": true "passWithNoTests": true
} }
},
"serve": {
"executor": "@nrwl/workspace:run-commands",
"outputs": [],
"options": {
"commands": [
"npx ts-node -P ./scripts/tsconfig.scripts.json ./scripts/copy-dep-graph-environment.ts dev",
"nx serve-base dep-graph-dep-graph"
]
},
"configurations": {
"watch": {
"commands": [
"npx ts-node -P ./scripts/tsconfig.scripts.json ./scripts/copy-dep-graph-environment.ts watch",
"nx serve-base dep-graph-dep-graph"
]
}
}
},
"serve-for-e2e": {
"executor": "@nrwl/workspace:run-commands",
"outputs": [],
"options": {
"commands": [
"npx ts-node -P ./scripts/tsconfig.scripts.json ./scripts/copy-dep-graph-environment.ts dev",
"nx serve-base dep-graph-dep-graph"
],
"readyWhen": "No issues found."
},
"configurations": {
"watch": {
"commands": [
"npx ts-node -P ./scripts/tsconfig.scripts.json ./scripts/copy-dep-graph-environment.ts watch",
"nx serve-base dep-graph-dep-graph"
],
"readyWhen": "No issues found."
}
}
} }
}, },
"tags": ["core"] "tags": ["core"]

View File

@ -1,31 +1,38 @@
// nx-ignore-next-line // nx-ignore-next-line
import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph'; import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { ProjectGraph } from '@nrwl/devkit'; import { fromEvent } from 'rxjs';
import { combineLatest, fromEvent, Subject } from 'rxjs'; import { startWith } from 'rxjs/operators';
import { startWith, takeUntil } from 'rxjs/operators';
import { DebuggerPanel } from './debugger-panel'; import { DebuggerPanel } from './debugger-panel';
import { GraphComponent } from './graph'; import { GraphComponent } from './graph';
import { AppConfig, DEFAULT_CONFIG } from './models'; import { useDepGraphService } from './machines/dep-graph.service';
import { DepGraphSend } from './machines/interfaces';
import { AppConfig, DEFAULT_CONFIG, ProjectGraphService } from './models';
import { GraphTooltipService } from './tooltip-service'; import { GraphTooltipService } from './tooltip-service';
import { SidebarComponent } from './ui-sidebar/sidebar'; import { SidebarComponent } from './ui-sidebar/sidebar';
export class AppComponent { export class AppComponent {
private sidebar: SidebarComponent; private sidebar = new SidebarComponent();
private tooltipService = new GraphTooltipService(); private tooltipService = new GraphTooltipService();
private graph = new GraphComponent(this.tooltipService); private graph = new GraphComponent(this.tooltipService);
private debuggerPanel: DebuggerPanel; private debuggerPanel: DebuggerPanel;
private windowResize$ = fromEvent(window, 'resize').pipe(startWith({})); private windowResize$ = fromEvent(window, 'resize').pipe(startWith({}));
private render$ = new Subject<{ newProjects: string[] }>();
constructor(private config: AppConfig = DEFAULT_CONFIG) { private send: DepGraphSend;
this.render$.subscribe((nextRenderConfig) => this.render(nextRenderConfig));
constructor(
private config: AppConfig = DEFAULT_CONFIG,
private projectGraphService: ProjectGraphService
) {
const [_, send] = useDepGraphService();
this.send = send;
this.loadProjectGraph(config.defaultProjectGraph); this.loadProjectGraph(config.defaultProjectGraph);
this.render();
if (window.watch === true) { if (window.watch === true) {
setInterval( setInterval(
() => this.loadProjectGraph(config.defaultProjectGraph), () => this.updateProjectGraph(config.defaultProjectGraph),
5000 5000
); );
} }
@ -37,39 +44,49 @@ export class AppComponent {
); );
const project: DepGraphClientResponse = const project: DepGraphClientResponse =
await this.config.projectGraphService.getProjectGraph(projectInfo.url); await this.projectGraphService.getProjectGraph(projectInfo.url);
const workspaceLayout = project?.layout; const workspaceLayout = project?.layout;
const nodes = Object.values(project.projects).reduce((acc, cur: any) => { this.send({
acc[cur.name] = cur; type: 'initGraph',
return acc; projects: project.projects,
}, {});
const newProjects = !!window.graph
? project.changes.added.filter(
(addedProject) => !window.graph.nodes[addedProject]
)
: project.changes.added;
window.projects = project.projects;
window.graph = <ProjectGraph>{
dependencies: project.dependencies, dependencies: project.dependencies,
nodes: nodes, affectedProjects: project.affected,
}; workspaceLayout: workspaceLayout,
window.focusedProject = null; });
window.projectGraphList = this.config.projectGraphs;
window.selectedProjectGraph = projectGraphId;
window.workspaceLayout = workspaceLayout;
if (this.sidebar) { if (!!window.focusedProject) {
this.render$.next({ newProjects }); this.send({
} else { type: 'focusProject',
this.render$.next(); projectName: window.focusedProject,
});
}
if (window.groupByFolder) {
this.send({
type: 'setGroupByFolder',
groupByFolder: window.groupByFolder,
});
} }
} }
private render(renderConfig: { newProjects: string[] } | undefined) { private async updateProjectGraph(projectGraphId: string) {
const projectInfo = this.config.projectGraphs.find(
(graph) => graph.id === projectGraphId
);
const project: DepGraphClientResponse =
await this.projectGraphService.getProjectGraph(projectInfo.url);
this.send({
type: 'updateGraph',
projects: project.projects,
dependencies: project.dependencies,
});
}
private render() {
const debuggerPanelContainer = document.getElementById('debugger-panel'); const debuggerPanelContainer = document.getElementById('debugger-panel');
if (this.config.showDebugger) { if (this.config.showDebugger) {
@ -78,59 +95,17 @@ export class AppComponent {
this.debuggerPanel = new DebuggerPanel( this.debuggerPanel = new DebuggerPanel(
debuggerPanelContainer, debuggerPanelContainer,
window.projectGraphList this.config.projectGraphs,
this.config.defaultProjectGraph
); );
this.debuggerPanel.selectProject$.subscribe((id) => { this.debuggerPanel.selectProject$.subscribe((id) => {
this.loadProjectGraph(id); this.loadProjectGraph(id);
this.sidebar.resetSidebarVisibility();
});
}
this.graph.projectGraph = window.graph;
const affectedProjects = window.affected;
this.graph.affectedProjects = affectedProjects;
if (!this.sidebar) {
this.sidebar = new SidebarComponent(affectedProjects);
} else {
this.sidebar.projects = window.projects;
if (renderConfig?.newProjects.length > 0) {
this.sidebar.selectProjects(renderConfig.newProjects);
}
}
combineLatest([
this.sidebar.selectedProjectsChanged$,
this.sidebar.groupByFolderChanged$,
this.windowResize$,
])
.pipe(takeUntil(this.render$))
.subscribe(([selectedProjectNames, groupByFolder]) => {
const selectedProjects = [];
selectedProjectNames.forEach((projectName) => {
if (window.graph.nodes[projectName]) {
selectedProjects.push(window.graph.nodes[projectName]);
}
});
if (selectedProjects.length === 0) {
document.getElementById('no-projects-chosen').style.display = 'flex';
} else {
document.getElementById('no-projects-chosen').style.display = 'none';
this.graph.render(selectedProjects, groupByFolder);
}
}); });
if (this.debuggerPanel) { this.graph.renderTimes$.subscribe(
this.graph.renderTimes$ (renderTime) => (this.debuggerPanel.renderTime = renderTime)
.pipe(takeUntil(this.render$)) );
.subscribe(
(renderTime) => (this.debuggerPanel.renderTime = renderTime)
);
} }
} }
} }

View File

@ -16,7 +16,8 @@ export class DebuggerPanel {
constructor( constructor(
private container: HTMLElement, private container: HTMLElement,
private projectGraphs: ProjectGraphList[] private projectGraphs: ProjectGraphList[],
private initialSelectedGraph: string
) { ) {
this.render(); this.render();
} }
@ -40,7 +41,7 @@ export class DebuggerPanel {
select.appendChild(option); select.appendChild(option);
}); });
select.value = window.selectedProjectGraph; select.value = this.initialSelectedGraph;
select.dataset['cy'] = 'project-select'; select.dataset['cy'] = 'project-select';
select.onchange = (event) => select.onchange = (event) =>

View File

@ -1,9 +1,15 @@
import type { ProjectGraph, ProjectGraphNode } from '@nrwl/devkit'; import type {
ProjectGraph,
ProjectGraphDependency,
ProjectGraphNode,
} from '@nrwl/devkit';
import type { VirtualElement } from '@popperjs/core';
import * as cy from 'cytoscape'; import * as cy from 'cytoscape';
import cytoscapeDagre from 'cytoscape-dagre'; import cytoscapeDagre from 'cytoscape-dagre';
import popper from 'cytoscape-popper'; import popper from 'cytoscape-popper';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import type { Instance } from 'tippy.js'; import type { Instance } from 'tippy.js';
import { useDepGraphService } from './machines/dep-graph.service';
import { ProjectNodeToolTip } from './project-node-tooltip'; import { ProjectNodeToolTip } from './project-node-tooltip';
import { edgeStyles, nodeStyles } from './styles-graph'; import { edgeStyles, nodeStyles } from './styles-graph';
import { GraphTooltipService } from './tooltip-service'; import { GraphTooltipService } from './tooltip-service';
@ -13,7 +19,6 @@ import {
ProjectEdge, ProjectEdge,
ProjectNode, ProjectNode,
} from './util-cytoscape'; } from './util-cytoscape';
import type { VirtualElement } from '@popperjs/core';
export interface GraphPerfReport { export interface GraphPerfReport {
renderTime: number; renderTime: number;
@ -24,22 +29,57 @@ export class GraphComponent {
private graph: cy.Core; private graph: cy.Core;
private openTooltip: Instance = null; private openTooltip: Instance = null;
affectedProjects: string[];
projectGraph: ProjectGraph;
private renderTimesSubject = new Subject<GraphPerfReport>(); private renderTimesSubject = new Subject<GraphPerfReport>();
renderTimes$ = this.renderTimesSubject.asObservable(); renderTimes$ = this.renderTimesSubject.asObservable();
private send;
constructor(private tooltipService: GraphTooltipService) { constructor(private tooltipService: GraphTooltipService) {
cy.use(cytoscapeDagre); cy.use(cytoscapeDagre);
cy.use(popper); 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) { render(
selectedProjects: ProjectGraphNode[],
groupByFolder: boolean,
workspaceLayout,
focusedProject: string,
affectedProjects: string[],
dependencies: Record<string, ProjectGraphDependency[]>
) {
const time = Date.now(); 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';
}
this.tooltipService.hideAll(); this.tooltipService.hideAll();
this.generateCytoscapeLayout(selectedProjects, groupByFolder); this.generateCytoscapeLayout(
selectedProjects,
groupByFolder,
workspaceLayout,
focusedProject,
affectedProjects,
dependencies
);
this.listenForProjectNodeClicks(); this.listenForProjectNodeClicks();
this.listenForProjectNodeHovers(); this.listenForProjectNodeHovers();
@ -56,9 +96,20 @@ export class GraphComponent {
private generateCytoscapeLayout( private generateCytoscapeLayout(
selectedProjects: ProjectGraphNode[], selectedProjects: ProjectGraphNode[],
groupByFolder: boolean groupByFolder: boolean,
workspaceLayout,
focusedProject: string,
affectedProjects: string[],
dependencies: Record<string, ProjectGraphDependency[]>
) { ) {
const elements = this.createElements(selectedProjects, groupByFolder); const elements = this.createElements(
selectedProjects,
groupByFolder,
workspaceLayout,
focusedProject,
affectedProjects,
dependencies
);
this.graph = cy({ this.graph = cy({
container: document.getElementById('graph-container'), container: document.getElementById('graph-container'),
@ -84,7 +135,14 @@ export class GraphComponent {
private createElements( private createElements(
selectedProjects: ProjectGraphNode[], selectedProjects: ProjectGraphNode[],
groupByFolder: boolean groupByFolder: boolean,
workspaceLayout: {
appsDir: string;
libsDir: string;
},
focusedProject: string,
affectedProjects: string[],
dependencies: Record<string, ProjectGraphDependency[]>
) { ) {
let elements: cy.ElementDefinition[] = []; let elements: cy.ElementDefinition[] = [];
const filteredProjectNames = selectedProjects.map( const filteredProjectNames = selectedProjects.map(
@ -101,21 +159,21 @@ export class GraphComponent {
selectedProjects.forEach((project) => { selectedProjects.forEach((project) => {
const workspaceRoot = const workspaceRoot =
project.type === 'app' || project.type === 'e2e' project.type === 'app' || project.type === 'e2e'
? window.workspaceLayout.appsDir ? workspaceLayout.appsDir
: window.workspaceLayout.libsDir; : workspaceLayout.libsDir;
const projectNode = new ProjectNode(project, workspaceRoot); const projectNode = new ProjectNode(project, workspaceRoot);
projectNode.focused = project.name === window.focusedProject; projectNode.focused = project.name === focusedProject;
projectNode.affected = this.affectedProjects.includes(project.name); projectNode.affected = affectedProjects.includes(project.name);
projectNodes.push(projectNode); projectNodes.push(projectNode);
this.projectGraph.dependencies[project.name].forEach((dep) => { dependencies[project.name].forEach((dep) => {
if (filteredProjectNames.includes(dep.target)) { if (filteredProjectNames.includes(dep.target)) {
const edge = new ProjectEdge(dep); const edge = new ProjectEdge(dep);
edge.affected = edge.affected =
this.affectedProjects.includes(dep.source) && affectedProjects.includes(dep.source) &&
this.affectedProjects.includes(dep.target); affectedProjects.includes(dep.target);
edgeNodes.push(edge); edgeNodes.push(edge);
} }
}); });

View File

@ -0,0 +1,24 @@
import { assign } from '@xstate/immer';
import { DepGraphStateNodeConfig } from './interfaces';
export const customSelectedStateConfig: DepGraphStateNodeConfig = {
on: {
updateGraph: {
actions: [
assign((ctx, event) => {
const existingProjectNames = ctx.projects.map(
(project) => project.name
);
const newProjectNames = event.projects.map((project) => project.name);
const selectedProjects = newProjectNames.filter(
(projectName) => !existingProjectNames.includes(projectName)
);
ctx.projects = event.projects;
ctx.dependencies = event.dependencies;
ctx.selectedProjects = [...ctx.selectedProjects, ...selectedProjects];
}),
],
},
},
};

View File

@ -0,0 +1,154 @@
import { assign } from '@xstate/immer';
import { Machine } from 'xstate';
import { customSelectedStateConfig } from './custom-selected.state';
import { focusedStateConfig } from './focused.state';
import { DepGraphContext, DepGraphEvents, DepGraphSchema } from './interfaces';
import { textFilteredStateConfig } from './text-filtered.state';
import { unselectedStateConfig } from './unselected.state';
export const initialContext: DepGraphContext = {
projects: [],
dependencies: {},
affectedProjects: [],
selectedProjects: [],
focusedProject: null,
textFilter: '',
includePath: false,
searchDepth: 1,
searchDepthEnabled: false,
groupByFolder: false,
workspaceLayout: {
libsDir: '',
appsDir: '',
},
};
export const depGraphMachine = Machine<
DepGraphContext,
DepGraphSchema,
DepGraphEvents
>(
{
id: 'DepGraph',
initial: 'idle',
context: initialContext,
states: {
idle: {},
unselected: unselectedStateConfig,
customSelected: customSelectedStateConfig,
focused: focusedStateConfig,
textFiltered: textFilteredStateConfig,
},
on: {
initGraph: {
target: 'unselected',
actions: assign((ctx, event) => {
ctx.projects = event.projects;
ctx.affectedProjects = event.affectedProjects;
ctx.dependencies = event.dependencies;
}),
},
selectProject: {
target: 'customSelected',
actions: [
assign((ctx, event) => {
ctx.selectedProjects.push(event.projectName);
}),
],
},
selectAll: {
target: 'customSelected',
actions: [
assign((ctx) => {
ctx.selectedProjects = ctx.projects.map((project) => project.name);
}),
],
},
selectAffected: {
target: 'customSelected',
actions: [
assign((ctx) => {
ctx.selectedProjects = ctx.affectedProjects;
}),
],
},
deselectProject: [
{
target: 'unselected',
cond: 'deselectLastProject',
},
{
target: 'customSelected',
actions: [
assign((ctx, event) => {
const index = ctx.selectedProjects.findIndex(
(project) => project === event.projectName
);
ctx.selectedProjects.splice(index, 1);
}),
],
},
],
deselectAll: {
target: 'unselected',
},
focusProject: {
target: 'focused',
},
setGroupByFolder: {
actions: [
assign((ctx, event: any) => {
ctx.groupByFolder = event.groupByFolder;
}),
],
},
setIncludeProjectsByPath: {
actions: [
assign((ctx, event) => {
ctx.includePath = event.includeProjectsByPath;
}),
],
},
setSearchDepth: {
actions: [
assign((ctx, event) => {
ctx.searchDepth = event.searchDepth;
}),
],
},
incrementSearchDepth: {
actions: [
assign((ctx) => {
ctx.searchDepth = ctx.searchDepth + 1;
}),
],
},
decrementSearchDepth: {
actions: [
assign((ctx) => {
ctx.searchDepth = ctx.searchDepth - 1;
}),
],
},
setSearchDepthEnabled: {
actions: [
assign((ctx, event) => {
ctx.searchDepthEnabled = event.searchDepthEnabled;
}),
],
},
filterByText: {
target: 'textFiltered',
},
},
},
{
guards: {
deselectLastProject: (ctx) => {
return ctx.selectedProjects.length <= 1;
},
},
}
);

View File

@ -0,0 +1,38 @@
import { from } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
import { interpret, Interpreter, Typestate } from 'xstate';
import { depGraphMachine } from './dep-graph.machine';
import {
DepGraphContext,
DepGraphEvents,
DepGraphSend,
DepGraphStateObservable,
} from './interfaces';
let depGraphService: Interpreter<
DepGraphContext,
any,
DepGraphEvents,
Typestate<DepGraphContext>
>;
let depGraphState$: DepGraphStateObservable;
export function useDepGraphService(): [DepGraphStateObservable, DepGraphSend] {
if (!depGraphService) {
depGraphService = interpret(depGraphMachine, {
devTools: !!window.useXstateInspect,
});
depGraphService.start();
depGraphState$ = from(depGraphService).pipe(
map((state) => ({
value: state.value,
context: state.context,
})),
shareReplay(1)
);
}
return [depGraphState$, depGraphService.send];
}

View File

@ -0,0 +1,260 @@
import { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
import { depGraphMachine } from './dep-graph.machine';
export const mockProjects: ProjectGraphNode[] = [
{
name: 'app1',
type: 'app',
data: {},
},
{
name: 'app2',
type: 'app',
data: {},
},
{
name: 'ui-lib',
type: 'lib',
data: {},
},
{
name: 'feature-lib1',
type: 'lib',
data: {},
},
{
name: 'feature-lib2',
type: 'lib',
data: {},
},
{
name: 'auth-lib',
type: 'lib',
data: {},
},
];
export const mockDependencies: Record<string, ProjectGraphDependency[]> = {
app1: [
{
type: 'static',
source: 'app1',
target: 'auth-lib',
},
{
type: 'static',
source: 'app1',
target: 'feature-lib1',
},
],
app2: [
{
type: 'static',
source: 'app2',
target: 'auth-lib',
},
{
type: 'static',
source: 'app2',
target: 'feature-lib2',
},
],
'feature-lib1': [
{
type: 'static',
source: 'feature-lib1',
target: 'ui-lib',
},
],
'feature-lib2': [
{
type: 'static',
source: 'feature-lib2',
target: 'ui-lib',
},
],
'ui-lib': [],
};
describe('dep-graph machine', () => {
describe('initGraph', () => {
it('should set projects and dependencies', () => {
const result = depGraphMachine.transition(depGraphMachine.initialState, {
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
expect(result.context.projects).toEqual(mockProjects);
expect(result.context.dependencies).toEqual(mockDependencies);
});
it('should start with no projects selected', () => {
const result = depGraphMachine.transition(depGraphMachine.initialState, {
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
expect(result.value).toEqual('unselected');
expect(result.context.selectedProjects).toEqual([]);
});
});
describe('selecting projects', () => {
it('should select projects', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
result = depGraphMachine.transition(result, {
type: 'selectProject',
projectName: 'app1',
});
expect(result.value).toEqual('customSelected');
expect(result.context.selectedProjects).toEqual(['app1']);
result = depGraphMachine.transition(result, {
type: 'selectProject',
projectName: 'app2',
});
expect(result.context.selectedProjects).toEqual(['app1', 'app2']);
});
});
describe('deselecting projects', () => {
it('should deselect projects', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
result = depGraphMachine.transition(result, {
type: 'selectProject',
projectName: 'app1',
});
result = depGraphMachine.transition(result, {
type: 'selectProject',
projectName: 'app2',
});
result = depGraphMachine.transition(result, {
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', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
result = depGraphMachine.transition(result, {
type: 'selectProject',
projectName: 'app1',
});
result = depGraphMachine.transition(result, {
type: 'selectProject',
projectName: 'app2',
});
result = depGraphMachine.transition(result, {
type: 'deselectProject',
projectName: 'app1',
});
result = depGraphMachine.transition(result, {
type: 'deselectProject',
projectName: 'app2',
});
expect(result.value).toEqual('unselected');
expect(result.context.selectedProjects).toEqual([]);
});
});
describe('focusing projects', () => {
it('should set the focused project', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
result = depGraphMachine.transition(result, {
type: 'focusProject',
projectName: 'app1',
});
expect(result.value).toEqual('focused');
expect(result.context.focusedProject).toEqual('app1');
});
it('should select the projects by the focused project', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
affectedProjects: [],
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',
]);
});
it('should select no projects on unfocus', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
result = depGraphMachine.transition(result, {
type: 'focusProject',
projectName: 'app1',
});
result = depGraphMachine.transition(result, {
type: 'unfocusProject',
});
expect(result.value).toEqual('unselected');
expect(result.context.selectedProjects).toEqual([]);
});
});
});

View File

@ -0,0 +1,91 @@
import { assign } from '@xstate/immer';
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
);
ctx.focusedProject = event.projectName;
}),
],
exit: [
assign((ctx) => {
ctx.focusedProject = null;
}),
],
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;
}),
],
},
decrementSearchDepth: {
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;
}),
],
},
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);
}),
],
},
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;
}),
],
},
},
};

View File

@ -0,0 +1,78 @@
import { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
import { Observable } from 'rxjs';
import { ActionObject, StateNodeConfig, StateValue } from 'xstate';
// The hierarchical (recursive) schema for the states
export interface DepGraphSchema {
states: {
idle: {};
unselected: {};
focused: {};
textFiltered: {};
customSelected: {};
};
}
// The events that the machine handles
export type DepGraphEvents =
| { type: 'selectProject'; projectName: string }
| { type: 'deselectProject'; projectName: string }
| { type: 'selectAll' }
| { type: 'deselectAll' }
| { type: 'selectAffected' }
| { type: 'setGroupByFolder'; groupByFolder: boolean }
| { type: 'setIncludeProjectsByPath'; includeProjectsByPath: boolean }
| { type: 'setSearchDepth'; searchDepth: number }
| { type: 'incrementSearchDepth' }
| { type: 'decrementSearchDepth' }
| { type: 'setSearchDepthEnabled'; searchDepthEnabled: boolean }
| { type: 'focusProject'; projectName: string }
| { type: 'unfocusProject' }
| { type: 'filterByText'; search: string }
| { type: 'clearTextFilter' }
| {
type: 'initGraph';
projects: ProjectGraphNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
}
| {
type: 'updateGraph';
projects: ProjectGraphNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
};
// The context (extended state) of the machine
export interface DepGraphContext {
projects: ProjectGraphNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
selectedProjects: string[];
focusedProject: string | null;
textFilter: string;
includePath: boolean;
searchDepth: number;
searchDepthEnabled: boolean;
groupByFolder: boolean;
workspaceLayout: {
libsDir: string;
appsDir: string;
};
}
export type DepGraphStateNodeConfig = StateNodeConfig<
DepGraphContext,
{},
DepGraphEvents,
ActionObject<DepGraphContext, DepGraphEvents>
>;
export type DepGraphSend = (event: DepGraphEvents | DepGraphEvents[]) => void;
export type DepGraphStateObservable = Observable<{
value: StateValue;
context: DepGraphContext;
}>;

View File

@ -0,0 +1,85 @@
import { assign } from '@xstate/immer';
import { filterProjectsByText } from '../util';
import { DepGraphStateNodeConfig } from './interfaces';
export const textFilteredStateConfig: DepGraphStateNodeConfig = {
entry: [
assign((ctx, event: any) => {
ctx.textFilter = event.search;
ctx.selectedProjects = filterProjectsByText(
event.search,
ctx.includePath,
ctx.searchDepthEnabled ? ctx.searchDepth : -1,
ctx.projects,
ctx.dependencies
);
}),
],
on: {
clearTextFilter: {
target: 'unselected',
actions: assign((ctx) => {
ctx.includePath = false;
ctx.textFilter = '';
}),
},
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
);
}),
],
},
setSearchDepth: {
actions: [
assign((ctx, event) => {
ctx.searchDepth = event.searchDepth;
ctx.selectedProjects = filterProjectsByText(
ctx.textFilter,
ctx.includePath,
ctx.searchDepthEnabled ? event.searchDepth : -1,
ctx.projects,
ctx.dependencies
);
}),
],
},
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
);
}),
],
},
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;
}),
],
},
},
};

View File

@ -0,0 +1,29 @@
import { assign } from '@xstate/immer';
import { DepGraphStateNodeConfig } from './interfaces';
export const unselectedStateConfig: DepGraphStateNodeConfig = {
entry: [
assign((ctx) => {
ctx.selectedProjects = [];
}),
],
on: {
updateGraph: {
actions: [
assign((ctx, event) => {
const existingProjectNames = ctx.projects.map(
(project) => project.name
);
const newProjectNames = event.projects.map((project) => project.name);
const selectedProjects = newProjectNames.filter(
(projectName) => !existingProjectNames.includes(projectName)
);
ctx.projects = event.projects;
ctx.dependencies = event.dependencies;
ctx.selectedProjects = [...ctx.selectedProjects, ...selectedProjects];
}),
],
},
},
};

View File

@ -38,9 +38,6 @@ export class MockProjectGraphService implements ProjectGraphService {
], ],
'existing-lib-1': [], 'existing-lib-1': [],
}, },
changes: {
added: [],
},
affected: [], affected: [],
focus: null, focus: null,
exclude: [], exclude: [],
@ -88,8 +85,14 @@ export class MockProjectGraphService implements ProjectGraphService {
type: 'static', type: 'static',
}, },
]; ];
this.response.projects.push(newProject);
this.response.dependencies[newProject.name] = newDependency; this.response = {
this.response.changes.added.push(newProject.name); ...this.response,
projects: [...this.response.projects, newProject],
dependencies: {
...this.response.dependencies,
[newProject.name]: newDependency,
},
};
} }
} }

View File

@ -17,20 +17,17 @@ export interface ProjectGraphService {
getProjectGraph: (url: string) => Promise<DepGraphClientResponse>; getProjectGraph: (url: string) => Promise<DepGraphClientResponse>;
} }
export interface Environment { export interface Environment {
environment: 'dev' | 'dev-watch' | 'release'; environment: 'dev' | 'watch' | 'release';
appConfig: AppConfig;
} }
export interface AppConfig { export interface AppConfig {
showDebugger: boolean; showDebugger: boolean;
projectGraphs: ProjectGraphList[]; projectGraphs: ProjectGraphList[];
defaultProjectGraph: string; defaultProjectGraph: string;
projectGraphService: ProjectGraphService;
} }
export const DEFAULT_CONFIG: AppConfig = { export const DEFAULT_CONFIG: AppConfig = {
showDebugger: false, showDebugger: false,
projectGraphs: [], projectGraphs: [],
defaultProjectGraph: null, defaultProjectGraph: null,
projectGraphService: null,
}; };

View File

@ -1,4 +1,5 @@
import * as cy from 'cytoscape'; import * as cy from 'cytoscape';
import { useDepGraphService } from './machines/dep-graph.service';
export class ProjectNodeToolTip { export class ProjectNodeToolTip {
constructor(private node: cy.NodeSingular) {} constructor(private node: cy.NodeSingular) {}
@ -53,13 +54,15 @@ export class ProjectNodeToolTip {
wrapper.classList.add('flex'); wrapper.classList.add('flex');
const [_, send] = useDepGraphService();
focusButton.addEventListener('click', () => focusButton.addEventListener('click', () =>
window.focusProject(this.node.attr('id')) send({ type: 'focusProject', projectName: this.node.attr('id') })
); );
focusButton.innerText = 'Focus'; focusButton.innerText = 'Focus';
excludeButton.addEventListener('click', () => { excludeButton.addEventListener('click', () => {
window.excludeProject(this.node.attr('id')); send({ type: 'deselectProject', projectName: this.node.attr('id') });
}); });
excludeButton.innerText = 'Exclude'; excludeButton.innerText = 'Exclude';

View File

@ -1,51 +1,34 @@
import { BehaviorSubject, combineLatest, Subject } from 'rxjs'; import { useDepGraphService } from '../machines/dep-graph.service';
import { distinctUntilChanged, map, withLatestFrom } from 'rxjs/operators'; import { DepGraphSend } from '../machines/interfaces';
import { removeChildrenFromContainer } from '../util'; import { removeChildrenFromContainer } from '../util';
export class DisplayOptionsPanel { export class DisplayOptionsPanel {
private showAffected = false;
private groupByFolder = false;
private selectAffectedSubject = new Subject<void>();
private selectAllSubject = new Subject<void>();
private deselectAllSubject = new Subject<void>();
private groupByFolderSubject = new Subject<boolean>();
private searchByDepthSubject = new BehaviorSubject<number>(1);
private searchByDepthEnabledSubject = new BehaviorSubject<boolean>(false);
private searchDepthChangesSubject = new Subject<'increment' | 'decrement'>();
selectAffected$ = this.selectAffectedSubject.asObservable();
selectAll$ = this.selectAllSubject.asObservable();
deselectAll$ = this.deselectAllSubject.asObservable();
groupByFolder$ = this.groupByFolderSubject.asObservable();
searchDepth$ = combineLatest([
this.searchByDepthSubject,
this.searchByDepthEnabledSubject,
]).pipe(
map(([searchDepth, enabled]) => {
return enabled ? searchDepth : -1;
}),
distinctUntilChanged()
);
searchDepthDisplay: HTMLSpanElement; searchDepthDisplay: HTMLSpanElement;
affectedButtonElement: HTMLElement;
groupByFolderCheckboxElement: HTMLInputElement;
constructor(showAffected = false, groupByFolder = false) { send: DepGraphSend;
this.showAffected = showAffected;
this.groupByFolder = groupByFolder;
this.searchDepthChangesSubject constructor(private container: HTMLElement) {
.pipe(withLatestFrom(this.searchByDepthSubject)) const [state$, send] = useDepGraphService();
.subscribe(([action, current]) => { this.send = send;
if (action === 'decrement' && current > 1) { this.render();
this.searchByDepthSubject.next(current - 1);
} else if (action === 'increment') {
this.searchByDepthSubject.next(current + 1);
}
});
this.searchByDepthSubject.subscribe((current) => { state$.subscribe((state) => {
if (this.searchDepthDisplay) { if (state.context.affectedProjects.length > 0) {
this.searchDepthDisplay.innerText = current.toString(); this.affectedButtonElement.classList.remove('hidden');
this.affectedButtonElement.addEventListener('click', () =>
this.send({ type: 'selectAffected' })
);
}
this.searchDepthDisplay.innerText = state.context.searchDepth.toString();
if (
this.groupByFolderCheckboxElement.checked !==
state.context.groupByFolder
) {
this.groupByFolderCheckboxElement.checked = state.context.groupByFolder;
} }
}); });
} }
@ -119,44 +102,39 @@ export class DisplayOptionsPanel {
return render.content.firstChild as HTMLElement; return render.content.firstChild as HTMLElement;
} }
render(container: HTMLElement) { private render() {
removeChildrenFromContainer(container); removeChildrenFromContainer(this.container);
const element = DisplayOptionsPanel.renderHtmlTemplate(); const element = DisplayOptionsPanel.renderHtmlTemplate();
const affectedButtonElement: HTMLElement = element.querySelector( this.affectedButtonElement = element.querySelector(
'[data-cy="affectedButton"]' '[data-cy="affectedButton"]'
); );
if (this.showAffected) {
affectedButtonElement.classList.remove('hidden');
affectedButtonElement.addEventListener('click', () =>
this.selectAffectedSubject.next()
);
}
const selectAllButtonElement: HTMLElement = element.querySelector( const selectAllButtonElement: HTMLElement = element.querySelector(
'[data-cy="selectAllButton"]' '[data-cy="selectAllButton"]'
); );
selectAllButtonElement.addEventListener('click', () => { selectAllButtonElement.addEventListener('click', () => {
this.selectAllSubject.next(); this.send({ type: 'selectAll' });
}); });
const deselectAllButtonElement: HTMLElement = element.querySelector( const deselectAllButtonElement: HTMLElement = element.querySelector(
'[data-cy="deselectAllButton"]' '[data-cy="deselectAllButton"]'
); );
deselectAllButtonElement.addEventListener('click', () => { deselectAllButtonElement.addEventListener('click', () => {
this.deselectAllSubject.next(); this.send({ type: 'deselectAll' });
}); });
const groupByFolderCheckboxElement: HTMLInputElement = this.groupByFolderCheckboxElement =
element.querySelector('#displayOptions'); element.querySelector('#displayOptions');
groupByFolderCheckboxElement.checked = this.groupByFolder;
groupByFolderCheckboxElement.addEventListener( this.groupByFolderCheckboxElement.addEventListener(
'change', 'change',
(event: InputEvent) => (event: InputEvent) =>
this.groupByFolderSubject.next((<HTMLInputElement>event.target).checked) this.send({
type: 'setGroupByFolder',
groupByFolder: (event.target as HTMLInputElement).checked,
})
); );
this.searchDepthDisplay = element.querySelector('#depthFilterValue'); this.searchDepthDisplay = element.querySelector('#depthFilterValue');
@ -170,18 +148,19 @@ export class DisplayOptionsPanel {
element.querySelector('#depthFilter'); element.querySelector('#depthFilter');
incrementButtonElement.addEventListener('click', () => { incrementButtonElement.addEventListener('click', () => {
this.searchDepthChangesSubject.next('increment'); this.send({ type: 'incrementSearchDepth' });
}); });
decrementButtonElement.addEventListener('click', () => { decrementButtonElement.addEventListener('click', () => {
this.searchDepthChangesSubject.next('decrement'); this.send({ type: 'decrementSearchDepth' });
}); });
searchDepthEnabledElement.addEventListener('change', (event: InputEvent) => searchDepthEnabledElement.addEventListener('change', (event: InputEvent) =>
this.searchByDepthEnabledSubject.next( this.send({
(<HTMLInputElement>event.target).checked type: 'setSearchDepthEnabled',
) searchDepthEnabled: (<HTMLInputElement>event.target).checked,
})
); );
container.appendChild(element); this.container.appendChild(element);
} }
} }

View File

@ -1,17 +1,18 @@
import { Subject } from 'rxjs'; import { map } from 'rxjs/operators';
import { useDepGraphService } from '../machines/dep-graph.service';
import { DepGraphSend } from '../machines/interfaces';
import { removeChildrenFromContainer } from '../util'; import { removeChildrenFromContainer } from '../util';
export class FocusedProjectPanel { export class FocusedProjectPanel {
private unfocusSubject = new Subject<void>(); private send: DepGraphSend;
set projectName(projectName: string) {
this.render(projectName);
}
unfocus$ = this.unfocusSubject.asObservable();
constructor(private container: HTMLElement) { constructor(private container: HTMLElement) {
this.render(); const [state$, send] = useDepGraphService();
this.send = send;
state$
.pipe(map(({ context }) => context.focusedProject))
.subscribe((focusedProject) => this.render(focusedProject));
} }
private static renderHtmlTemplate(): HTMLElement { private static renderHtmlTemplate(): HTMLElement {
@ -39,10 +40,6 @@ export class FocusedProjectPanel {
return render.content.firstChild as HTMLElement; return render.content.firstChild as HTMLElement;
} }
unfocusProject() {
this.render();
}
private render(projectName?: string) { private render(projectName?: string) {
removeChildrenFromContainer(this.container); removeChildrenFromContainer(this.container);
@ -62,7 +59,7 @@ export class FocusedProjectPanel {
} }
unfocusButtonElement.addEventListener('click', () => unfocusButtonElement.addEventListener('click', () =>
this.unfocusSubject.next() this.send({ type: 'unfocusProject' })
); );
this.container.appendChild(element); this.container.appendChild(element);

View File

@ -1,35 +1,24 @@
import type { ProjectGraphNode } from '@nrwl/devkit'; import type { ProjectGraphNode } from '@nrwl/devkit';
import { Subject } from 'rxjs'; import { useDepGraphService } from '../machines/dep-graph.service';
import { DepGraphSend } from '../machines/interfaces';
import { import {
parseParentDirectoriesFromPilePath, parseParentDirectoriesFromPilePath,
removeChildrenFromContainer, removeChildrenFromContainer,
} from '../util'; } from '../util';
export class ProjectList { export class ProjectList {
private focusProjectSubject = new Subject<string>(); private projectItems: Record<string, HTMLElement> = {};
private checkedProjectsChangeSubject = new Subject<string[]>();
private selectedItems: Record<string, HTMLElement> = {};
checkedProjectsChange$ = this.checkedProjectsChangeSubject.asObservable();
focusProject$ = this.focusProjectSubject.asObservable();
private _projects: ProjectGraphNode[] = []; private send: DepGraphSend;
set projects(projects: ProjectGraphNode[]) {
this._projects = projects;
const previouslyCheckedProjects = Object.values(this.selectedItems)
.filter((checkbox) => checkbox.dataset['active'] === 'true')
.map((checkbox) => checkbox.dataset['project']);
this.render();
this.selectProjects(previouslyCheckedProjects);
}
get projects(): ProjectGraphNode[] {
return this._projects;
}
constructor(private container: HTMLElement) { constructor(private container: HTMLElement) {
this.render(); const [state$, send] = useDepGraphService();
this.send = send;
state$.subscribe((state) => {
this.render(state.context.projects, state.context.workspaceLayout);
this.setSelectedProjects(state.context.selectedProjects);
});
} }
private static renderHtmlItemTemplate(): HTMLElement { private static renderHtmlItemTemplate(): HTMLElement {
@ -60,63 +49,49 @@ export class ProjectList {
return render.content.firstChild as HTMLElement; return render.content.firstChild as HTMLElement;
} }
selectProjects(projects: string[]) { setSelectedProjects(selectedProjects: string[]) {
projects.forEach((projectName) => { Object.keys(this.projectItems).forEach((projectName) => {
if (!!this.selectedItems[projectName]) { this.projectItems[projectName].dataset['active'] = selectedProjects
this.selectedItems[projectName].dataset['active'] = 'true';
this.selectedItems[projectName].dispatchEvent(
new CustomEvent('change')
);
}
});
this.emitChanges();
}
setCheckedProjects(selectedProjects: string[]) {
Object.keys(this.selectedItems).forEach((projectName) => {
this.selectedItems[projectName].dataset['active'] = selectedProjects
.includes(projectName) .includes(projectName)
.toString(); .toString();
this.selectedItems[projectName].dispatchEvent(new CustomEvent('change')); this.projectItems[projectName].dispatchEvent(new CustomEvent('change'));
}); });
} }
checkAllProjects() { checkAllProjects() {
Object.values(this.selectedItems).forEach((item) => { this.send({ type: 'selectAll' });
item.dataset['active'] = 'true';
item.dispatchEvent(new CustomEvent('change'));
});
} }
uncheckAllProjects() { uncheckAllProjects() {
Object.values(this.selectedItems).forEach((item) => { this.send({ type: 'deselectAll' });
item.dataset['active'] = 'false';
item.dispatchEvent(new CustomEvent('change'));
});
} }
uncheckProject(projectName: string) { uncheckProject(projectName: string) {
this.selectedItems[projectName].dataset['active'] = 'false'; this.send({ type: 'deselectProject', projectName });
this.selectedItems[projectName].dispatchEvent(new CustomEvent('change'));
} }
private emitChanges() { private render(
const changes = Object.values(this.selectedItems) projects: ProjectGraphNode[],
.filter((item) => item.dataset['active'] === 'true') workspaceLayout: { appsDir: string; libsDir: string }
.map((item) => item.dataset['project']); ) {
this.checkedProjectsChangeSubject.next(changes);
}
private render() {
removeChildrenFromContainer(this.container); removeChildrenFromContainer(this.container);
const appProjects = this.getProjectsByType('app'); const appProjects = this.getProjectsByType('app', projects);
const libProjects = this.getProjectsByType('lib'); const libProjects = this.getProjectsByType('lib', projects);
const e2eProjects = this.getProjectsByType('e2e'); const e2eProjects = this.getProjectsByType('e2e', projects);
const appDirectoryGroups = this.groupProjectsByDirectory(appProjects); const appDirectoryGroups = this.groupProjectsByDirectory(
const libDirectoryGroups = this.groupProjectsByDirectory(libProjects); appProjects,
const e2eDirectoryGroups = this.groupProjectsByDirectory(e2eProjects); workspaceLayout
);
const libDirectoryGroups = this.groupProjectsByDirectory(
libProjects,
workspaceLayout
);
const e2eDirectoryGroups = this.groupProjectsByDirectory(
e2eProjects,
workspaceLayout
);
const sortedAppDirectories = Object.keys(appDirectoryGroups).sort(); const sortedAppDirectories = Object.keys(appDirectoryGroups).sort();
const sortedLibDirectories = Object.keys(libDirectoryGroups).sort(); const sortedLibDirectories = Object.keys(libDirectoryGroups).sort();
@ -153,20 +128,23 @@ export class ProjectList {
}); });
} }
private getProjectsByType(type) { private getProjectsByType(type: string, projects: ProjectGraphNode[]) {
return this.projects return projects
.filter((project) => project.type === type) .filter((project) => project.type === type)
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => a.name.localeCompare(b.name));
} }
private groupProjectsByDirectory(projects: ProjectGraphNode[]) { private groupProjectsByDirectory(
projects: ProjectGraphNode[],
workspaceLayout: { appsDir: string; libsDir: string }
) {
let groups = {}; let groups = {};
projects.forEach((project) => { projects.forEach((project) => {
const workspaceRoot = const workspaceRoot =
project.type === 'app' || project.type === 'e2e' project.type === 'app' || project.type === 'e2e'
? window.workspaceLayout.appsDir ? workspaceLayout.appsDir
: window.workspaceLayout.libsDir; : workspaceLayout.libsDir;
const directories = parseParentDirectoriesFromPilePath( const directories = parseParentDirectoriesFromPilePath(
project.data.root, project.data.root,
workspaceRoot workspaceRoot
@ -203,7 +181,7 @@ export class ProjectList {
); );
const focusButtonElement: HTMLElement = element.querySelector('button'); const focusButtonElement: HTMLElement = element.querySelector('button');
focusButtonElement.addEventListener('click', () => focusButtonElement.addEventListener('click', () =>
this.focusProjectSubject.next(project.name) this.send({ type: 'focusProject', projectName: project.name })
); );
const projectNameElement: HTMLElement = element.querySelector('label'); const projectNameElement: HTMLElement = element.querySelector('label');
@ -214,12 +192,19 @@ export class ProjectList {
projectNameElement.addEventListener('click', (event) => { projectNameElement.addEventListener('click', (event) => {
const el = event.target as HTMLElement; const el = event.target as HTMLElement;
el.dataset['active'] = if (el.dataset['active'] === 'true') {
el.dataset['active'] === 'false' ? 'true' : 'false'; this.send({
el.dispatchEvent(new CustomEvent('change')); type: 'deselectProject',
projectName: el.dataset['project'],
this.emitChanges(); });
} else {
this.send({
type: 'selectProject',
projectName: el.dataset['project'],
});
}
}); });
projectNameElement.addEventListener('change', (event) => { projectNameElement.addEventListener('change', (event) => {
const el = event.target as HTMLElement; const el = event.target as HTMLElement;
if (el.dataset['active'] === 'false') { if (el.dataset['active'] === 'false') {
@ -231,7 +216,7 @@ export class ProjectList {
projectNameElement.dispatchEvent(new Event('click')); projectNameElement.dispatchEvent(new Event('click'));
}); });
this.selectedItems[project.name] = projectNameElement; this.projectItems[project.name] = projectNameElement;
formGroup.append(element); formGroup.append(element);
}); });

View File

@ -1,52 +1,28 @@
import { ProjectGraphNode } from '@nrwl/devkit';
import { BehaviorSubject, combineLatest, fromEvent, Subject } from 'rxjs';
import { DisplayOptionsPanel } from './display-options-panel'; import { DisplayOptionsPanel } from './display-options-panel';
import { FocusedProjectPanel } from './focused-project-panel'; import { FocusedProjectPanel } from './focused-project-panel';
import { ProjectList } from './project-list'; import { ProjectList } from './project-list';
import { TextFilterChangeEvent, TextFilterPanel } from './text-filter-panel'; import { TextFilterPanel } from './text-filter-panel';
declare var ResizeObserver; declare var ResizeObserver;
export class SidebarComponent { export class SidebarComponent {
private selectedProjectsChangedSubject = new BehaviorSubject<string[]>([]);
private groupByFolderChangedSubject = new BehaviorSubject<boolean>(
window.groupByFolder
);
private focusProjectSubject = new Subject<string>();
private filterByTextSubject = new Subject<TextFilterChangeEvent>();
selectedProjectsChanged$ = this.selectedProjectsChangedSubject.asObservable();
groupByFolderChanged$ = this.groupByFolderChangedSubject.asObservable();
private displayOptionsPanel: DisplayOptionsPanel; private displayOptionsPanel: DisplayOptionsPanel;
private focusedProjectPanel: FocusedProjectPanel; private focusedProjectPanel: FocusedProjectPanel;
private textFilterPanel: TextFilterPanel; private textFilterPanel: TextFilterPanel;
private projectList: ProjectList; private projectList: ProjectList;
private groupByFolder = window.groupByFolder; constructor() {
private selectedProjects: string[] = [];
set projects(projects: ProjectGraphNode[]) {
this.projectList.projects = projects;
this.focusedProjectPanel.unfocusProject();
}
constructor(private affectedProjects: string[]) {
const showAffected = this.affectedProjects.length > 0;
const displayOptionsPanelContainer = document.getElementById( const displayOptionsPanelContainer = document.getElementById(
'display-options-panel' 'display-options-panel'
); );
this.displayOptionsPanel = new DisplayOptionsPanel( this.displayOptionsPanel = new DisplayOptionsPanel(
showAffected, displayOptionsPanelContainer
this.groupByFolder
); );
this.displayOptionsPanel.render(displayOptionsPanelContainer);
const focusedProjectPanelContainer = const focusedProjectPanelContainer =
document.getElementById('focused-project'); document.getElementById('focused-project');
this.focusedProjectPanel = new FocusedProjectPanel( this.focusedProjectPanel = new FocusedProjectPanel(
focusedProjectPanelContainer focusedProjectPanelContainer
); );
@ -57,232 +33,5 @@ export class SidebarComponent {
const projectListContainer = document.getElementById('project-lists'); const projectListContainer = document.getElementById('project-lists');
this.projectList = new ProjectList(projectListContainer); this.projectList = new ProjectList(projectListContainer);
this.projectList.projects = window.projects;
if (showAffected) {
this.selectAffectedProjects();
}
window.focusProject = (projectId) => {
this.focusProjectSubject.next(projectId);
};
window.excludeProject = (projectId) => {
this.excludeProject(projectId);
};
this.listenForDOMEvents();
if (window.focusedProject !== null) {
this.focusProject(window.focusedProject);
}
if (window.exclude.length > 0) {
window.exclude.forEach((project) => this.excludeProject(project));
}
}
selectProjects(projects: string[]) {
this.projectList.selectProjects(projects);
}
resetSidebarVisibility() {
const sidebarElement = document.getElementById('sidebar');
if (sidebarElement.classList.contains('hidden')) {
sidebarElement.classList.remove('hidden');
sidebarElement.style.marginLeft = `0px`;
}
}
listenForDOMEvents() {
this.displayOptionsPanel.selectAll$.subscribe(() => {
this.selectAllProjects();
});
this.displayOptionsPanel.deselectAll$.subscribe(() => {
this.deselectAllProjects();
});
this.displayOptionsPanel.selectAffected$.subscribe(() => {
this.selectAffectedProjects();
});
this.displayOptionsPanel.groupByFolder$.subscribe((groupByFolder) => {
this.groupByFolderChangedSubject.next(groupByFolder);
});
this.focusedProjectPanel.unfocus$.subscribe(() => {
this.unfocusProject();
});
this.textFilterPanel.changes$.subscribe((event) => {
this.filterByTextSubject.next(event);
});
combineLatest([
this.filterByTextSubject,
this.displayOptionsPanel.searchDepth$,
]).subscribe(([event, searchDepth]) => {
if (event.text && !!event.text.length) {
this.filterProjectsByText(event.text, event.includeInPath, searchDepth);
} else this.deselectAllProjects();
});
this.projectList.checkedProjectsChange$.subscribe((checkedProjects) => {
this.emitSelectedProjects(checkedProjects);
});
this.projectList.focusProject$.subscribe((projectName) => {
this.focusProjectSubject.next(projectName);
});
combineLatest([
this.focusProjectSubject,
this.displayOptionsPanel.searchDepth$,
]).subscribe(([projectName, searchDepth]) => {
if (projectName) {
this.focusProject(projectName, searchDepth);
}
});
}
private setFocusedProject(projectId: string = null) {
window.focusedProject = projectId;
if (projectId) {
this.focusedProjectPanel.projectName = window.graph.nodes[projectId].name;
} else {
this.focusedProjectPanel.projectName = null;
}
}
selectAffectedProjects() {
this.setFocusedProject(null);
this.projectList.setCheckedProjects(this.affectedProjects);
this.emitSelectedProjects(this.affectedProjects);
}
selectAllProjects() {
this.setFocusedProject(null);
this.projectList.checkAllProjects();
this.emitSelectedProjects(window.projects.map((project) => project.name));
}
deselectAllProjects() {
this.setFocusedProject(null);
this.projectList.uncheckAllProjects();
this.emitSelectedProjects([]);
}
focusProject(id: string, searchDepth: number = -1) {
this.filterByTextSubject.next({ text: null, includeInPath: false });
this.setFocusedProject(id);
const selectedProjects = window.projects
.map((project) => project.name)
.filter(
(projectName) =>
this.hasPath(id, projectName, [], 1, searchDepth) ||
this.hasPath(projectName, id, [], 1, searchDepth)
);
this.projectList.setCheckedProjects(selectedProjects);
this.emitSelectedProjects(selectedProjects);
}
unfocusProject() {
this.focusProjectSubject.next(null);
this.setFocusedProject(null);
this.projectList.uncheckAllProjects();
this.emitSelectedProjects([]);
}
excludeProject(id: string) {
const selectedProjects = [...this.selectedProjects];
selectedProjects.splice(this.selectedProjects.indexOf(id), 1);
this.projectList.uncheckProject(id);
this.emitSelectedProjects(selectedProjects);
}
emitSelectedProjects(selectedProjects: string[]) {
this.selectedProjects = selectedProjects;
this.selectedProjectsChangedSubject.next(selectedProjects);
}
filterProjectsByText(
text: string,
includeInPath: boolean,
searchDepth: number
) {
this.focusProjectSubject.next(null);
this.setFocusedProject(null);
this.projectList.uncheckAllProjects();
const split = text.split(',').map((splitItem) => splitItem.trim());
const selectedProjects = new Set<string>();
window.projects
.map((project) => project.name)
.forEach((project) => {
const projectMatch =
split.findIndex((splitItem) => project.includes(splitItem)) > -1;
if (projectMatch) {
selectedProjects.add(project);
if (includeInPath) {
window.projects
.map((project) => project.name)
.forEach((projectInPath) => {
if (
this.hasPath(project, projectInPath, [], 1, searchDepth) ||
this.hasPath(projectInPath, project, [], 1, searchDepth)
) {
selectedProjects.add(projectInPath);
}
});
}
}
});
const selectedProjectsArray = Array.from(selectedProjects);
this.projectList.setCheckedProjects(selectedProjectsArray);
this.emitSelectedProjects(selectedProjectsArray);
}
private hasPath(
target,
node,
visited,
currentSearchDepth: number,
maxSearchDepth: number = -1 // -1 indicates unlimited search depth
) {
if (target === node) return true;
if (maxSearchDepth === -1 || currentSearchDepth <= maxSearchDepth) {
for (let d of window.graph.dependencies[node] || []) {
if (visited.indexOf(d.target) > -1) continue;
visited.push(d.target);
if (
this.hasPath(
target,
d.target,
visited,
currentSearchDepth + 1,
maxSearchDepth
)
)
return true;
}
}
return false;
} }
} }

View File

@ -1,6 +1,8 @@
import { fromEvent, Subject, Subscription } from 'rxjs'; import { fromEvent, Subscription } from 'rxjs';
import { removeChildrenFromContainer } from '../util';
import { debounceTime, filter, map } from 'rxjs/operators'; import { debounceTime, filter, map } from 'rxjs/operators';
import { useDepGraphService } from '../machines/dep-graph.service';
import { DepGraphSend } from '../machines/interfaces';
import { removeChildrenFromContainer } from '../util';
export interface TextFilterChangeEvent { export interface TextFilterChangeEvent {
text: string; text: string;
@ -10,13 +12,11 @@ export interface TextFilterChangeEvent {
export class TextFilterPanel { export class TextFilterPanel {
private textInput: HTMLInputElement; private textInput: HTMLInputElement;
private includeInPathCheckbox: HTMLInputElement; private includeInPathCheckbox: HTMLInputElement;
private changesSubject = new Subject<TextFilterChangeEvent>(); private send: DepGraphSend;
private subscriptions: Subscription[] = [];
changes$ = this.changesSubject.asObservable();
constructor(private container: HTMLElement) { constructor(private container: HTMLElement) {
this.subscriptions.map((s) => s.unsubscribe()); const [_, send] = useDepGraphService();
this.send = send;
this.render(); this.render();
} }
@ -42,7 +42,7 @@ export class TextFilterPanel {
<div class="mt-4 px-4"> <div class="mt-4 px-4">
<div class="flex items-start"> <div class="flex items-start">
<div class="flex items-center h-5"> <div class="flex items-center h-5">
<input id="includeInPath" name="textFilterCheckbox" type="checkbox" value="includeInPath" class="h-4 w-4 border-gray-300 rounded" disabled> <input disabled id="includeInPath" name="textFilterCheckbox" type="checkbox" value="includeInPath" class="h-4 w-4 border-gray-300 rounded">
</div> </div>
<div class="ml-3 text-sm"> <div class="ml-3 text-sm">
<label for="includeInPath" class="font-medium text-gray-700 cursor-pointer">Include related libraries</label> <label for="includeInPath" class="font-medium text-gray-700 cursor-pointer">Include related libraries</label>
@ -55,13 +55,6 @@ export class TextFilterPanel {
return render.content.firstChild as HTMLElement; return render.content.firstChild as HTMLElement;
} }
private emitChanges() {
this.changesSubject.next({
text: this.textInput.value.toLowerCase(),
includeInPath: this.includeInPathCheckbox.checked,
});
}
private render() { private render() {
removeChildrenFromContainer(this.container); removeChildrenFromContainer(this.container);
@ -72,7 +65,10 @@ export class TextFilterPanel {
this.textInput = element.querySelector('input[type="text"]'); this.textInput = element.querySelector('input[type="text"]');
this.textInput.addEventListener('keyup', (event) => { this.textInput.addEventListener('keyup', (event) => {
if (event.key === 'Enter') this.emitChanges(); if (event.key === 'Enter') {
this.send({ type: 'filterByText', search: this.textInput.value });
}
if (!!this.textInput.value.length) { if (!!this.textInput.value.length) {
resetInputElement.classList.remove('hidden'); resetInputElement.classList.remove('hidden');
this.includeInPathCheckbox.disabled = false; this.includeInPathCheckbox.disabled = false;
@ -82,19 +78,22 @@ export class TextFilterPanel {
} }
}); });
this.subscriptions.push( fromEvent(this.textInput, 'keyup')
fromEvent(this.textInput, 'keyup') .pipe(
.pipe( filter((event: KeyboardEvent) => event.key !== 'Enter'),
filter((event: KeyboardEvent) => event.key !== 'Enter'), debounceTime(500),
debounceTime(500), map(() =>
map(() => this.emitChanges()) this.send({ type: 'filterByText', search: this.textInput.value })
) )
.subscribe() )
); .subscribe();
this.includeInPathCheckbox = element.querySelector('#includeInPath'); this.includeInPathCheckbox = element.querySelector('#includeInPath');
this.includeInPathCheckbox.addEventListener('change', () => this.includeInPathCheckbox.addEventListener('change', () =>
this.emitChanges() this.send({
type: 'setIncludeProjectsByPath',
includeProjectsByPath: this.includeInPathCheckbox.checked,
})
); );
resetInputElement.addEventListener('click', () => { resetInputElement.addEventListener('click', () => {
@ -102,7 +101,7 @@ export class TextFilterPanel {
this.includeInPathCheckbox.checked = false; this.includeInPathCheckbox.checked = false;
this.includeInPathCheckbox.disabled = true; this.includeInPathCheckbox.disabled = true;
resetInputElement.classList.add('hidden'); resetInputElement.classList.add('hidden');
this.emitChanges(); this.send([{ type: 'clearTextFilter' }]);
}); });
this.container.appendChild(element); this.container.appendChild(element);

View File

@ -1,3 +1,5 @@
import { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
export function removeChildrenFromContainer(container: HTMLElement) { export function removeChildrenFromContainer(container: HTMLElement) {
Array.from(container.children).forEach((child) => Array.from(container.children).forEach((child) =>
container.removeChild(child) container.removeChild(child)
@ -27,3 +29,115 @@ export function parseParentDirectoriesFromPilePath(
return split; return split;
} }
export function hasPath(
dependencies: Record<string, ProjectGraphDependency[]>,
target: string,
node: string,
visited: string[],
currentSearchDepth: number,
maxSearchDepth: number = -1 // -1 indicates unlimited search depth
) {
if (target === node) return true;
if (maxSearchDepth === -1 || currentSearchDepth <= maxSearchDepth) {
for (let d of dependencies[node] || []) {
if (visited.indexOf(d.target) > -1) continue;
visited.push(d.target);
if (
hasPath(
dependencies,
target,
d.target,
visited,
currentSearchDepth + 1,
maxSearchDepth
)
)
return true;
}
}
return false;
}
export function selectProjectsForFocusedProject(
projects: ProjectGraphNode[],
dependencies: Record<string, ProjectGraphDependency[]>,
focusedProjectName: string,
searchDepth: number
) {
return projects
.map((project) => project.name)
.filter(
(projectName) =>
hasPath(
dependencies,
focusedProjectName,
projectName,
[],
1,
searchDepth
) ||
hasPath(
dependencies,
projectName,
focusedProjectName,
[],
1,
searchDepth
)
);
}
export function filterProjectsByText(
text: string,
includeInPath: boolean,
searchDepth: number,
projects: ProjectGraphNode[],
dependencies: Record<string, ProjectGraphDependency[]>
) {
const split = text.split(',').map((splitItem) => splitItem.trim());
const selectedProjects = new Set<string>();
projects
.map((project) => project.name)
.forEach((project) => {
const projectMatch =
split.findIndex((splitItem) => project.includes(splitItem)) > -1;
if (projectMatch) {
selectedProjects.add(project);
if (includeInPath) {
projects
.map((project) => project.name)
.forEach((projectInPath) => {
if (
hasPath(
dependencies,
project,
projectInPath,
[],
1,
searchDepth
) ||
hasPath(
dependencies,
projectInPath,
project,
[],
1,
searchDepth
)
) {
selectedProjects.add(projectInPath);
}
});
}
}
});
return Array.from(selectedProjects);
}

View File

@ -0,0 +1,38 @@
window.exclude = [];
window.focusedProject = null;
window.groupByFolder = false;
window.watch = false;
window.environment = 'dev';
window.useXstateInspect = false;
window.appConfig = {
showDebugger: true,
projectGraphs: [
{
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',
},
],
defaultProjectGraph: 'nx',
};

View File

@ -0,0 +1,18 @@
window.exclude = [];
window.focusedProject = null;
window.groupByFolder = false;
window.watch = true;
window.environment = 'watch';
window.useXstateInspect = false;
window.appConfig = {
showDebugger: false,
projectGraphs: [
{
id: 'local',
label: 'local',
url: 'projectGraph.json',
},
],
defaultProjectGraph: 'local',
};

View File

@ -1,13 +0,0 @@
import { FetchProjectGraphService } from '../app/fetch-project-graph-service';
import { Environment } from '../app/models';
import { projectGraphs } from '../graphs';
export const environment: Environment = {
environment: 'dev',
appConfig: {
showDebugger: true,
projectGraphs,
defaultProjectGraph: 'nx',
projectGraphService: new FetchProjectGraphService(),
},
};

View File

@ -1,18 +0,0 @@
import { FetchProjectGraphService } from '../app/fetch-project-graph-service';
import { Environment } from '../app/models';
export const environment: Environment = {
environment: 'release',
appConfig: {
showDebugger: false,
projectGraphs: [
{
id: 'local',
label: 'local',
url: 'projectGraph.json',
},
],
defaultProjectGraph: 'local',
projectGraphService: new FetchProjectGraphService(),
},
};

View File

@ -1,18 +0,0 @@
import { MockProjectGraphService } from '../app/mock-project-graph-service';
import { Environment } from '../app/models';
export const environment: Environment = {
environment: 'dev-watch',
appConfig: {
showDebugger: false,
projectGraphs: [
{
id: 'local',
label: 'local',
url: 'projectGraph.json',
},
],
defaultProjectGraph: 'local',
projectGraphService: new MockProjectGraphService(),
},
};

View File

@ -2,27 +2,19 @@
import { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph'; import { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { ProjectGraph, ProjectGraphNode } from '@nrwl/devkit'; import { ProjectGraph, ProjectGraphNode } from '@nrwl/devkit';
import { ProjectGraphList } from './graphs'; import { ProjectGraphList } from './graphs';
import { AppConfig } from './app/models';
export declare global { export declare global {
export interface Window { export interface Window {
watch: boolean;
projects: ProjectGraphNode[];
graph: ProjectGraph;
filteredProjects: ProjectGraphNode[];
affected: string[];
exclude: string[]; exclude: string[];
focusedProject: string; focusedProject: string;
groupByFolder: boolean; groupByFolder: boolean;
focusProject: Function; watch: boolean;
excludeProject: Function;
projectGraphList: ProjectGraphList[];
selectedProjectGraph: string;
workspaceLayout: {
libsDir: string;
appsDir: string;
};
projectGraphResponse: DepGraphClientResponse;
localMode: 'serve' | 'build'; localMode: 'serve' | 'build';
projectGraphResponse?: DepGraphClientResponse;
environment: 'dev' | 'watch' | 'release';
appConfig: AppConfig;
useXstateInspect: boolean = false;
} }
} }

View File

@ -10,6 +10,8 @@
href="https://fonts.googleapis.com/css?family=Montserrat&display=swap" href="https://fonts.googleapis.com/css?family=Montserrat&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<script id="environment" src="environment.js"></script>
</head> </head>
<body> <body>
<svg xmlns="http://www.w3.org/2000/svg" style="display: none"> <svg xmlns="http://www.w3.org/2000/svg" style="display: none">
@ -143,17 +145,4 @@
<div id="graph-container"></div> <div id="graph-container"></div>
</div> </div>
</div> </div>
<script>
window.watch = false;
window.projects = [];
window.graph = {};
window.affected = [];
window.focusedProject = null;
window.filteredProjects = [];
window.groupByFolder = false;
window.exclude = [];
window.workspaceLayout = null;
window.localMode = 'serve';
window.projectGraphResponse = null;
</script>
</body> </body>

View File

@ -1,13 +1,29 @@
import { AppComponent } from './app/app'; import { AppComponent } from './app/app';
import { LocalProjectGraphService } from './app/local-project-graph-service'; import { LocalProjectGraphService } from './app/local-project-graph-service';
import { environment } from './environments/environment'; import { inspect } from '@xstate/inspect';
import { ProjectGraphService } from './app/models';
import { MockProjectGraphService } from './app/mock-project-graph-service';
import { FetchProjectGraphService } from './app/fetch-project-graph-service';
if (environment.environment === 'dev-watch') { if (window.useXstateInspect === true) {
window.watch = true; inspect({
url: 'https://stately.ai/viz?inspect',
iframe: false, // open in new window
});
} }
if (environment.environment === 'release' && window.localMode === 'build') { let projectGraphService: ProjectGraphService;
environment.appConfig.projectGraphService = new LocalProjectGraphService();
if (window.environment === 'dev') {
projectGraphService = new FetchProjectGraphService();
} else if (window.environment === 'watch') {
projectGraphService = new MockProjectGraphService();
} else if (window.environment === 'release') {
if (window.localMode === 'build') {
projectGraphService = new LocalProjectGraphService();
} else {
projectGraphService = new FetchProjectGraphService();
}
} }
setTimeout(() => new AppComponent(environment.appConfig)); setTimeout(() => new AppComponent(window.appConfig, projectGraphService));

View File

@ -116,6 +116,8 @@
"@typescript-eslint/eslint-plugin": "~4.33.0", "@typescript-eslint/eslint-plugin": "~4.33.0",
"@typescript-eslint/experimental-utils": "~4.33.0", "@typescript-eslint/experimental-utils": "~4.33.0",
"@typescript-eslint/parser": "~4.33.0", "@typescript-eslint/parser": "~4.33.0",
"@xstate/immer": "^0.2.0",
"@xstate/inspect": "^0.5.1",
"angular": "1.8.0", "angular": "1.8.0",
"autoprefixer": "^10.2.5", "autoprefixer": "^10.2.5",
"babel-jest": "27.2.3", "babel-jest": "27.2.3",
@ -136,8 +138,8 @@
"cz-customizable": "^6.2.0", "cz-customizable": "^6.2.0",
"depcheck": "^1.3.1", "depcheck": "^1.3.1",
"dotenv": "~10.0.0", "dotenv": "~10.0.0",
"enhanced-resolve": "^5.8.3",
"ejs": "^3.1.5", "ejs": "^3.1.5",
"enhanced-resolve": "^5.8.3",
"eslint": "7.32.0", "eslint": "7.32.0",
"eslint-config-next": "^12.0.0", "eslint-config-next": "^12.0.0",
"eslint-config-prettier": "^8.1.0", "eslint-config-prettier": "^8.1.0",
@ -158,6 +160,7 @@
"husky": "^6.0.0", "husky": "^6.0.0",
"identity-obj-proxy": "3.0.0", "identity-obj-proxy": "3.0.0",
"ignore": "^5.0.4", "ignore": "^5.0.4",
"immer": "^9.0.6",
"import-fresh": "^3.1.0", "import-fresh": "^3.1.0",
"injection-js": "^2.4.0", "injection-js": "^2.4.0",
"is-ci": "^3.0.0", "is-ci": "^3.0.0",
@ -254,6 +257,7 @@
"webpack-sources": "^3.0.2", "webpack-sources": "^3.0.2",
"webpack-subresource-integrity": "^1.5.2", "webpack-subresource-integrity": "^1.5.2",
"worker-plugin": "3.2.0", "worker-plugin": "3.2.0",
"xstate": "^4.25.0",
"yargs": "15.4.1", "yargs": "15.4.1",
"yargs-parser": "20.0.0", "yargs-parser": "20.0.0",
"zone.js": "~0.11.4" "zone.js": "~0.11.4"

View File

@ -20,10 +20,6 @@ import {
ProjectGraphNode, ProjectGraphNode,
pruneExternalNodes, pruneExternalNodes,
} from '../core/project-graph'; } from '../core/project-graph';
import {
cacheDirectory,
readCacheDirectoryProperty,
} from '../utilities/cache-directory';
import { writeJsonFile } from '../utilities/fileutils'; import { writeJsonFile } from '../utilities/fileutils';
import { output } from '../utilities/output'; import { output } from '../utilities/output';
@ -32,9 +28,6 @@ export interface DepGraphClientResponse {
projects: ProjectGraphNode[]; projects: ProjectGraphNode[];
dependencies: Record<string, ProjectGraphDependency[]>; dependencies: Record<string, ProjectGraphDependency[]>;
layout: { appsDir: string; libsDir: string }; layout: { appsDir: string; libsDir: string };
changes: {
added: string[];
};
affected: string[]; affected: string[];
focus: string; focus: string;
groupByFolder: boolean; groupByFolder: boolean;
@ -59,74 +52,46 @@ const mimeType = {
'.ttf': 'aplication/font-sfnt', '.ttf': 'aplication/font-sfnt',
}; };
const nxDepsDir = cacheDirectory( function buildEnvironmentJs(
appRootPath, exclude: string[],
readCacheDirectoryProperty(appRootPath)
);
async function projectsToHtml(
projects: ProjectGraphNode[],
graph: ProjectGraph,
affected: string[],
focus: string, focus: string,
groupByFolder: boolean, groupByFolder: boolean,
exclude: string[], watchMode: boolean,
layout: { appsDir: string; libsDir: string }, localMode: 'build' | 'serve',
localMode: 'serve' | 'build', depGraphClientResponse?: DepGraphClientResponse
watchMode: boolean = false
) { ) {
let f = readFileSync( let environmentJs = `window.exclude = ${JSON.stringify(exclude)};
join(__dirname, '../core/dep-graph/index.html'), window.groupByFolder = ${!!groupByFolder};
'utf-8' window.watch = ${!!watchMode};
); window.environment = 'release';
window.localMode = '${localMode}';
f = f
.replace( window.appConfig = {
`window.projects = []`, showDebugger: false,
`window.projects = ${JSON.stringify(projects)}` projectGraphs: [
) {
.replace(`window.graph = {}`, `window.graph = ${JSON.stringify(graph)}`) id: 'local',
.replace( label: 'local',
`window.affected = []`, url: 'projectGraph.json',
`window.affected = ${JSON.stringify(affected)}` }
) ],
.replace( defaultProjectGraph: 'local',
`window.groupByFolder = false`, };
`window.groupByFolder = ${!!groupByFolder}` `;
)
.replace(
`window.exclude = []`,
`window.exclude = ${JSON.stringify(exclude)}`
)
.replace(
`window.workspaceLayout = null`,
`window.workspaceLayout = ${JSON.stringify(layout)}`
);
if (focus) {
f = f.replace(
`window.focusedProject = null`,
`window.focusedProject = '${focus}'`
);
}
if (watchMode) {
f = f.replace(`window.watch = false`, `window.watch = true`);
}
if (localMode === 'build') { if (localMode === 'build') {
currentDepGraphClientResponse = await createDepGraphClientResponse(); environmentJs += `window.projectGraphResponse = ${JSON.stringify(
f = f.replace( depGraphClientResponse
`window.projectGraphResponse = null`, )};`;
`window.projectGraphResponse = ${JSON.stringify( } else {
currentDepGraphClientResponse environmentJs += `window.projectGraphResponse = null;`;
)}`
);
f = f.replace(`window.localMode = 'serve'`, `window.localMode = 'build'`);
} }
return f; if (!!focus) {
environmentJs += `window.focusedProject = '${focus}';`;
}
return environmentJs;
} }
function projectExists(projects: ProjectGraphNode[], projectToFind: string) { function projectExists(projects: ProjectGraphNode[], projectToFind: string) {
@ -242,23 +207,12 @@ export async function generateGraph(
} }
} }
let html: string; let html = readFileSync(
join(__dirname, '../core/dep-graph/index.html'),
'utf-8'
);
if (!args.file || extname(args.file) === '.html') { graph = filterGraph(graph, args.focus || null, args.exclude || []);
html = await projectsToHtml(
projects,
graph,
affectedProjects,
args.focus || null,
args.groupByFolder || false,
args.exclude || [],
layout,
!!args.file && args.file.endsWith('html') ? 'build' : 'serve',
args.watch
);
} else {
graph = filterGraph(graph, args.focus || null, args.exclude || []);
}
if (args.file) { if (args.file) {
const workspaceFolder = appRootPath; const workspaceFolder = appRootPath;
@ -281,14 +235,23 @@ export async function generateGraph(
}, },
}); });
currentDepGraphClientResponse = await createDepGraphClientResponse(); const depGraphClientResponse = await createDepGraphClientResponse();
const environmentJs = buildEnvironmentJs(
args.exclude || [],
args.focus || null,
args.groupByFolder || false,
args.watch,
!!args.file && args.file.endsWith('html') ? 'build' : 'serve',
depGraphClientResponse
);
html = html.replace(/src="/g, 'src="static/'); html = html.replace(/src="/g, 'src="static/');
html = html.replace(/href="styles/g, 'href="static/styles'); html = html.replace(/href="styles/g, 'href="static/styles');
html = html.replace('<base href="/" />', ''); html = html.replace('<base href="/" />', '');
html = html.replace(/type="module"/g, ''); html = html.replace(/type="module"/g, '');
writeFileSync(fullFilePath, html); writeFileSync(fullFilePath, html);
writeFileSync(join(assetsFolder, 'environment.js'), environmentJs);
output.success({ output.success({
title: `HTML output created in ${fileFolderPath}`, title: `HTML output created in ${fileFolderPath}`,
@ -315,8 +278,17 @@ export async function generateGraph(
process.exit(1); process.exit(1);
} }
} else { } else {
const environmentJs = buildEnvironmentJs(
args.exclude || [],
args.focus || null,
args.groupByFolder || false,
args.watch,
!!args.file && args.file.endsWith('html') ? 'build' : 'serve'
);
await startServer( await startServer(
html, html,
environmentJs,
args.host || '127.0.0.1', args.host || '127.0.0.1',
args.port || 4211, args.port || 4211,
args.watch, args.watch,
@ -331,6 +303,7 @@ export async function generateGraph(
async function startServer( async function startServer(
html: string, html: string,
environmentJs: string,
host: string, host: string,
port = 4211, port = 4211,
watchForchanges = false, watchForchanges = false,
@ -372,6 +345,12 @@ async function startServer(
return; return;
} }
if (sanitizePath === 'environment.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(environmentJs);
return;
}
let pathname = join(__dirname, '../core/dep-graph/', sanitizePath); let pathname = join(__dirname, '../core/dep-graph/', sanitizePath);
if (!existsSync(pathname)) { if (!existsSync(pathname)) {
@ -383,7 +362,6 @@ async function startServer(
// if is a directory, then look for index.html // if is a directory, then look for index.html
if (statSync(pathname).isDirectory()) { if (statSync(pathname).isDirectory()) {
// pathname += '/index.html';
res.writeHead(200, { 'Content-Type': 'text/html' }); res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html); res.end(html);
return; return;
@ -420,9 +398,6 @@ let currentDepGraphClientResponse: DepGraphClientResponse = {
appsDir: '', appsDir: '',
libsDir: '', libsDir: '',
}, },
changes: {
added: [],
},
affected: [], affected: [],
focus: null, focus: null,
groupByFolder: false, groupByFolder: false,
@ -522,21 +497,6 @@ async function createDepGraphClientResponse(): Promise<DepGraphClientResponse> {
const hash = hasher.digest('hex'); const hash = hasher.digest('hex');
let added = [];
if (
currentDepGraphClientResponse.hash !== null &&
hash !== currentDepGraphClientResponse.hash
) {
added = projects
.filter((project) => {
const result = currentDepGraphClientResponse.projects.find(
(previousProject) => previousProject.name === project.name
);
return !result;
})
.map((project) => project.name);
}
performance.mark('dep graph response generation:end'); performance.mark('dep graph response generation:end');
performance.measure( performance.measure(
@ -557,8 +517,5 @@ async function createDepGraphClientResponse(): Promise<DepGraphClientResponse> {
layout, layout,
projects, projects,
dependencies, dependencies,
changes: {
added: [...currentDepGraphClientResponse.changes.added, ...added],
},
}; };
} }

View File

@ -0,0 +1,12 @@
import { copyFileSync } from 'fs';
import { argv } from 'yargs';
type Mode = 'dev' | 'watch';
const mode = argv._[0];
console.log(`Setting up dep-graph for ${mode}`);
copyFileSync(
`dep-graph/dep-graph/src/assets/environment.${mode}.js`,
`dep-graph/dep-graph/src/assets/environment.js`
);

View File

@ -5392,6 +5392,18 @@
"@webassemblyjs/wast-parser" "1.9.0" "@webassemblyjs/wast-parser" "1.9.0"
"@xtuc/long" "4.2.2" "@xtuc/long" "4.2.2"
"@xstate/immer@^0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@xstate/immer/-/immer-0.2.0.tgz#4f128947c3cbb3e68357b886485a36852d4e06b3"
integrity sha512-ZKwAwS84kfmN108lEtVHw8jztKDiFeaQsTxkOlOghpK1Lr7+13G8HhZZXyN1/pVkplloUUOPMH5EXVtitZDr8w==
"@xstate/inspect@^0.5.1":
version "0.5.1"
resolved "https://registry.yarnpkg.com/@xstate/inspect/-/inspect-0.5.1.tgz#12dbed78e6123e407458fde322273e7a64650f1e"
integrity sha512-m1zKFzyV/skUfpdiO/52w9o5EUporqIYEevryjcPpUEiIjVXKgti3EXl8TfXxggeNmsa2H9P0M0wZ5alM8M3Ng==
dependencies:
fast-safe-stringify "^2.0.7"
"@xtuc/ieee754@^1.2.0": "@xtuc/ieee754@^1.2.0":
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@ -10588,7 +10600,7 @@ fast-redact@^3.0.0:
resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.0.2.tgz#c940ba7162dde3aeeefc522926ae8c5231412904" resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.0.2.tgz#c940ba7162dde3aeeefc522926ae8c5231412904"
integrity sha512-YN+CYfCVRVMUZOUPeinHNKgytM1wPI/C/UCLEi56EsY2dwwvI00kIJHJoI7pMVqGoMew8SMZ2SSfHKHULHXDsg== integrity sha512-YN+CYfCVRVMUZOUPeinHNKgytM1wPI/C/UCLEi56EsY2dwwvI00kIJHJoI7pMVqGoMew8SMZ2SSfHKHULHXDsg==
fast-safe-stringify@2.1.1, fast-safe-stringify@^2.0.8: fast-safe-stringify@2.1.1, fast-safe-stringify@^2.0.7, fast-safe-stringify@^2.0.8:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884"
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==
@ -12354,7 +12366,7 @@ immer@8.0.1:
resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656" resolved "https://registry.yarnpkg.com/immer/-/immer-8.0.1.tgz#9c73db683e2b3975c424fb0572af5889877ae656"
integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==
immer@^9.0.1: immer@^9.0.1, immer@^9.0.6:
version "9.0.6" version "9.0.6"
resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73"
integrity sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ== integrity sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==
@ -22960,6 +22972,11 @@ xmlhttprequest-ssl@~1.5.4, xmlhttprequest-ssl@~1.6.2:
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6" resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6"
integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q== integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==
xstate@^4.25.0:
version "4.25.0"
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.25.0.tgz#d902ef33137532043f7a88597af8e5e1c7ad6bdf"
integrity sha512-qP7lc/ypOuuWME4ArOBnzaCa90TfHkjiqYDmxpiCjPy6FcXstInA2vH6qRVAHbPXRK4KQIYfIEOk1X38P+TldQ==
xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1: xtend@^4.0.0, xtend@^4.0.1, xtend@^4.0.2, xtend@~4.0.1:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"