diff --git a/.gitignore b/.gitignore index 859b504507f8f..9cee22106f0ed 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ tmp jest.debug.config.js .tool-versions /.verdaccio/build/local-registry +dep-graph/dep-graph/src/assets/environment.js \ No newline at end of file diff --git a/dep-graph/dep-graph-e2e/project.json b/dep-graph/dep-graph-e2e/project.json index c03edc27b4810..1635bce8cf43c 100644 --- a/dep-graph/dep-graph-e2e/project.json +++ b/dep-graph/dep-graph-e2e/project.json @@ -8,7 +8,8 @@ "options": { "cypressConfig": "dep-graph/dep-graph-e2e/cypress.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": { @@ -16,7 +17,8 @@ "options": { "cypressConfig": "dep-graph/dep-graph-e2e/cypress-watch-mode.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": { diff --git a/dep-graph/dep-graph-e2e/src/integration/app.spec.ts b/dep-graph/dep-graph-e2e/src/integration/app.spec.ts index da2ee68366c34..75a8a7d685c34 100644 --- a/dep-graph/dep-graph-e2e/src/integration/app.spec.ts +++ b/dep-graph/dep-graph-e2e/src/integration/app.spec.ts @@ -12,7 +12,12 @@ import { describe('dep-graph-client', () => { beforeEach(() => { + cy.intercept('/assets/graphs/*').as('getGraph'); + cy.visit('/'); + + // wait for first graph to finish loading + cy.wait('@getGraph'); }); it('should display message to select projects', () => { @@ -51,12 +56,12 @@ describe('dep-graph-client', () => { describe('selecting projects', () => { 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"]') - .click({ - force: true, - }) - .should('have.data', 'active', true); + cy.get('[data-project="nx-dev"]').should('have.data', 'active', false); + cy.get('[data-project="nx-dev"]').click({ + force: true, + }); + + cy.get('[data-project="nx-dev"]').should('have.data', 'active', true); }); it('should deselect a project by clicking on the project name again', () => { diff --git a/dep-graph/dep-graph/project.json b/dep-graph/dep-graph/project.json index 817225bd2e60b..0376cd4140f4f 100644 --- a/dep-graph/dep-graph/project.json +++ b/dep-graph/dep-graph/project.json @@ -32,39 +32,16 @@ }, "configurations": { "dev": { - "fileReplacements": [ - { - "replace": "dep-graph/dep-graph/src/environments/environment.ts", - "with": "dep-graph/dep-graph/src/environments/environment.dev.ts" - } - ], + "fileReplacements": [], "assets": [ "dep-graph/dep-graph/src/favicon.ico", - "dep-graph/dep-graph/src/assets" - ], - "optimization": false, - "outputHashing": "none", - "sourceMap": true, - "extractCss": true, - "namedChunks": false, - "extractLicenses": false, - "vendorChunk": true, - "budgets": [ + "dep-graph/dep-graph/src/assets/graphs/", { - "type": "initial", - "maximumWarning": "2mb", - "maximumError": "5mb" - } - ] - }, - "watch": { - "fileReplacements": [ - { - "replace": "dep-graph/dep-graph/src/environments/environment.ts", - "with": "dep-graph/dep-graph/src/environments/environment.watch.ts" + "input": "dep-graph/dep-graph/src/assets", + "output": "/", + "glob": "environment.js" } ], - "assets": [], "optimization": false, "outputHashing": "none", "sourceMap": true, @@ -83,15 +60,10 @@ }, "outputs": ["{options.outputPath}"] }, - "serve": { + "serve-base": { "executor": "@nrwl/web:dev-server", "options": { "buildTarget": "dep-graph-dep-graph:build-base:dev" - }, - "configurations": { - "watch": { - "buildTarget": "dep-graph-dep-graph:build-base:watch" - } } }, "lint": { @@ -112,6 +84,44 @@ "jestConfig": "dep-graph/dep-graph/jest.config.js", "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"] diff --git a/dep-graph/dep-graph/src/app/app.ts b/dep-graph/dep-graph/src/app/app.ts index 31393621b22b4..db243e3d97f3a 100644 --- a/dep-graph/dep-graph/src/app/app.ts +++ b/dep-graph/dep-graph/src/app/app.ts @@ -1,31 +1,38 @@ // nx-ignore-next-line import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph'; -import { ProjectGraph } from '@nrwl/devkit'; -import { combineLatest, fromEvent, Subject } from 'rxjs'; -import { startWith, takeUntil } from 'rxjs/operators'; +import { fromEvent } from 'rxjs'; +import { startWith } from 'rxjs/operators'; import { DebuggerPanel } from './debugger-panel'; 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 { SidebarComponent } from './ui-sidebar/sidebar'; export class AppComponent { - private sidebar: SidebarComponent; + private sidebar = new SidebarComponent(); private tooltipService = new GraphTooltipService(); private graph = new GraphComponent(this.tooltipService); private debuggerPanel: DebuggerPanel; private windowResize$ = fromEvent(window, 'resize').pipe(startWith({})); - private render$ = new Subject<{ newProjects: string[] }>(); - constructor(private config: AppConfig = DEFAULT_CONFIG) { - this.render$.subscribe((nextRenderConfig) => this.render(nextRenderConfig)); + private send: DepGraphSend; + + constructor( + private config: AppConfig = DEFAULT_CONFIG, + private projectGraphService: ProjectGraphService + ) { + const [_, send] = useDepGraphService(); + this.send = send; this.loadProjectGraph(config.defaultProjectGraph); + this.render(); if (window.watch === true) { setInterval( - () => this.loadProjectGraph(config.defaultProjectGraph), + () => this.updateProjectGraph(config.defaultProjectGraph), 5000 ); } @@ -37,39 +44,49 @@ export class AppComponent { ); const project: DepGraphClientResponse = - await this.config.projectGraphService.getProjectGraph(projectInfo.url); + await this.projectGraphService.getProjectGraph(projectInfo.url); const workspaceLayout = project?.layout; - const nodes = Object.values(project.projects).reduce((acc, cur: any) => { - acc[cur.name] = cur; - return acc; - }, {}); + this.send({ + type: 'initGraph', + projects: project.projects, + dependencies: project.dependencies, + affectedProjects: project.affected, + workspaceLayout: workspaceLayout, + }); + + if (!!window.focusedProject) { + this.send({ + type: 'focusProject', + projectName: window.focusedProject, + }); + } + + if (window.groupByFolder) { + this.send({ + type: 'setGroupByFolder', + groupByFolder: window.groupByFolder, + }); + } + } - const newProjects = !!window.graph - ? project.changes.added.filter( - (addedProject) => !window.graph.nodes[addedProject] - ) - : project.changes.added; + private async updateProjectGraph(projectGraphId: string) { + const projectInfo = this.config.projectGraphs.find( + (graph) => graph.id === projectGraphId + ); - window.projects = project.projects; - window.graph = { + const project: DepGraphClientResponse = + await this.projectGraphService.getProjectGraph(projectInfo.url); + + this.send({ + type: 'updateGraph', + projects: project.projects, dependencies: project.dependencies, - nodes: nodes, - }; - window.focusedProject = null; - window.projectGraphList = this.config.projectGraphs; - window.selectedProjectGraph = projectGraphId; - window.workspaceLayout = workspaceLayout; - - if (this.sidebar) { - this.render$.next({ newProjects }); - } else { - this.render$.next(); - } + }); } - private render(renderConfig: { newProjects: string[] } | undefined) { + private render() { const debuggerPanelContainer = document.getElementById('debugger-panel'); if (this.config.showDebugger) { @@ -78,59 +95,17 @@ export class AppComponent { this.debuggerPanel = new DebuggerPanel( debuggerPanelContainer, - window.projectGraphList + this.config.projectGraphs, + this.config.defaultProjectGraph ); this.debuggerPanel.selectProject$.subscribe((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$ - .pipe(takeUntil(this.render$)) - .subscribe( - (renderTime) => (this.debuggerPanel.renderTime = renderTime) - ); + this.graph.renderTimes$.subscribe( + (renderTime) => (this.debuggerPanel.renderTime = renderTime) + ); } } } diff --git a/dep-graph/dep-graph/src/app/debugger-panel.ts b/dep-graph/dep-graph/src/app/debugger-panel.ts index 29b6700b3f233..f44b5f1480f3c 100644 --- a/dep-graph/dep-graph/src/app/debugger-panel.ts +++ b/dep-graph/dep-graph/src/app/debugger-panel.ts @@ -16,7 +16,8 @@ export class DebuggerPanel { constructor( private container: HTMLElement, - private projectGraphs: ProjectGraphList[] + private projectGraphs: ProjectGraphList[], + private initialSelectedGraph: string ) { this.render(); } @@ -40,7 +41,7 @@ export class DebuggerPanel { select.appendChild(option); }); - select.value = window.selectedProjectGraph; + select.value = this.initialSelectedGraph; select.dataset['cy'] = 'project-select'; select.onchange = (event) => diff --git a/dep-graph/dep-graph/src/app/graph.ts b/dep-graph/dep-graph/src/app/graph.ts index 2a118e9b3e6a4..47a0d60cc5bbb 100644 --- a/dep-graph/dep-graph/src/app/graph.ts +++ b/dep-graph/dep-graph/src/app/graph.ts @@ -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 cytoscapeDagre from 'cytoscape-dagre'; import popper from 'cytoscape-popper'; import { Subject } from 'rxjs'; import type { Instance } from 'tippy.js'; +import { useDepGraphService } from './machines/dep-graph.service'; import { ProjectNodeToolTip } from './project-node-tooltip'; import { edgeStyles, nodeStyles } from './styles-graph'; import { GraphTooltipService } from './tooltip-service'; @@ -13,7 +19,6 @@ import { ProjectEdge, ProjectNode, } from './util-cytoscape'; -import type { VirtualElement } from '@popperjs/core'; export interface GraphPerfReport { renderTime: number; @@ -24,22 +29,57 @@ export class GraphComponent { private graph: cy.Core; private openTooltip: Instance = null; - affectedProjects: string[]; - projectGraph: ProjectGraph; - private renderTimesSubject = new Subject(); renderTimes$ = this.renderTimesSubject.asObservable(); + private send; constructor(private tooltipService: GraphTooltipService) { 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) { + render( + selectedProjects: ProjectGraphNode[], + groupByFolder: boolean, + workspaceLayout, + focusedProject: string, + affectedProjects: string[], + dependencies: Record + ) { 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.generateCytoscapeLayout(selectedProjects, groupByFolder); + this.generateCytoscapeLayout( + selectedProjects, + groupByFolder, + workspaceLayout, + focusedProject, + affectedProjects, + dependencies + ); this.listenForProjectNodeClicks(); this.listenForProjectNodeHovers(); @@ -56,9 +96,20 @@ export class GraphComponent { private generateCytoscapeLayout( selectedProjects: ProjectGraphNode[], - groupByFolder: boolean + groupByFolder: boolean, + workspaceLayout, + focusedProject: string, + affectedProjects: string[], + dependencies: Record ) { - const elements = this.createElements(selectedProjects, groupByFolder); + const elements = this.createElements( + selectedProjects, + groupByFolder, + workspaceLayout, + focusedProject, + affectedProjects, + dependencies + ); this.graph = cy({ container: document.getElementById('graph-container'), @@ -84,7 +135,14 @@ export class GraphComponent { private createElements( selectedProjects: ProjectGraphNode[], - groupByFolder: boolean + groupByFolder: boolean, + workspaceLayout: { + appsDir: string; + libsDir: string; + }, + focusedProject: string, + affectedProjects: string[], + dependencies: Record ) { let elements: cy.ElementDefinition[] = []; const filteredProjectNames = selectedProjects.map( @@ -101,21 +159,21 @@ export class GraphComponent { selectedProjects.forEach((project) => { const workspaceRoot = project.type === 'app' || project.type === 'e2e' - ? window.workspaceLayout.appsDir - : window.workspaceLayout.libsDir; + ? workspaceLayout.appsDir + : workspaceLayout.libsDir; const projectNode = new ProjectNode(project, workspaceRoot); - projectNode.focused = project.name === window.focusedProject; - projectNode.affected = this.affectedProjects.includes(project.name); + projectNode.focused = project.name === focusedProject; + projectNode.affected = affectedProjects.includes(project.name); projectNodes.push(projectNode); - this.projectGraph.dependencies[project.name].forEach((dep) => { + dependencies[project.name].forEach((dep) => { if (filteredProjectNames.includes(dep.target)) { const edge = new ProjectEdge(dep); edge.affected = - this.affectedProjects.includes(dep.source) && - this.affectedProjects.includes(dep.target); + affectedProjects.includes(dep.source) && + affectedProjects.includes(dep.target); edgeNodes.push(edge); } }); diff --git a/dep-graph/dep-graph/src/app/machines/custom-selected.state.ts b/dep-graph/dep-graph/src/app/machines/custom-selected.state.ts new file mode 100644 index 0000000000000..c54ae951af4d3 --- /dev/null +++ b/dep-graph/dep-graph/src/app/machines/custom-selected.state.ts @@ -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]; + }), + ], + }, + }, +}; diff --git a/dep-graph/dep-graph/src/app/machines/dep-graph.machine.ts b/dep-graph/dep-graph/src/app/machines/dep-graph.machine.ts new file mode 100644 index 0000000000000..8af6769679796 --- /dev/null +++ b/dep-graph/dep-graph/src/app/machines/dep-graph.machine.ts @@ -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; + }, + }, + } +); diff --git a/dep-graph/dep-graph/src/app/machines/dep-graph.service.ts b/dep-graph/dep-graph/src/app/machines/dep-graph.service.ts new file mode 100644 index 0000000000000..01713406de4f5 --- /dev/null +++ b/dep-graph/dep-graph/src/app/machines/dep-graph.service.ts @@ -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 +>; + +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]; +} diff --git a/dep-graph/dep-graph/src/app/machines/dep-graph.spec.ts b/dep-graph/dep-graph/src/app/machines/dep-graph.spec.ts new file mode 100644 index 0000000000000..60566b758fb06 --- /dev/null +++ b/dep-graph/dep-graph/src/app/machines/dep-graph.spec.ts @@ -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 = { + 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([]); + }); + }); +}); diff --git a/dep-graph/dep-graph/src/app/machines/focused.state.ts b/dep-graph/dep-graph/src/app/machines/focused.state.ts new file mode 100644 index 0000000000000..d2776f1650f7d --- /dev/null +++ b/dep-graph/dep-graph/src/app/machines/focused.state.ts @@ -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; + }), + ], + }, + }, +}; diff --git a/dep-graph/dep-graph/src/app/machines/interfaces.ts b/dep-graph/dep-graph/src/app/machines/interfaces.ts new file mode 100644 index 0000000000000..ce00fa095d3cf --- /dev/null +++ b/dep-graph/dep-graph/src/app/machines/interfaces.ts @@ -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; + affectedProjects: string[]; + workspaceLayout: { + libsDir: string; + appsDir: string; + }; + } + | { + type: 'updateGraph'; + projects: ProjectGraphNode[]; + dependencies: Record; + }; + +// The context (extended state) of the machine +export interface DepGraphContext { + projects: ProjectGraphNode[]; + dependencies: Record; + 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 +>; + +export type DepGraphSend = (event: DepGraphEvents | DepGraphEvents[]) => void; +export type DepGraphStateObservable = Observable<{ + value: StateValue; + context: DepGraphContext; +}>; diff --git a/dep-graph/dep-graph/src/app/machines/text-filtered.state.ts b/dep-graph/dep-graph/src/app/machines/text-filtered.state.ts new file mode 100644 index 0000000000000..a877d90650964 --- /dev/null +++ b/dep-graph/dep-graph/src/app/machines/text-filtered.state.ts @@ -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; + }), + ], + }, + }, +}; diff --git a/dep-graph/dep-graph/src/app/machines/unselected.state.ts b/dep-graph/dep-graph/src/app/machines/unselected.state.ts new file mode 100644 index 0000000000000..285be7215a8ca --- /dev/null +++ b/dep-graph/dep-graph/src/app/machines/unselected.state.ts @@ -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]; + }), + ], + }, + }, +}; diff --git a/dep-graph/dep-graph/src/app/mock-project-graph-service.ts b/dep-graph/dep-graph/src/app/mock-project-graph-service.ts index 2a9cce7f495a9..df9951f6caea1 100644 --- a/dep-graph/dep-graph/src/app/mock-project-graph-service.ts +++ b/dep-graph/dep-graph/src/app/mock-project-graph-service.ts @@ -38,9 +38,6 @@ export class MockProjectGraphService implements ProjectGraphService { ], 'existing-lib-1': [], }, - changes: { - added: [], - }, affected: [], focus: null, exclude: [], @@ -88,8 +85,14 @@ export class MockProjectGraphService implements ProjectGraphService { type: 'static', }, ]; - this.response.projects.push(newProject); - this.response.dependencies[newProject.name] = newDependency; - this.response.changes.added.push(newProject.name); + + this.response = { + ...this.response, + projects: [...this.response.projects, newProject], + dependencies: { + ...this.response.dependencies, + [newProject.name]: newDependency, + }, + }; } } diff --git a/dep-graph/dep-graph/src/app/models.ts b/dep-graph/dep-graph/src/app/models.ts index b6ee7261e459e..15e9fe832d309 100644 --- a/dep-graph/dep-graph/src/app/models.ts +++ b/dep-graph/dep-graph/src/app/models.ts @@ -17,20 +17,17 @@ export interface ProjectGraphService { getProjectGraph: (url: string) => Promise; } export interface Environment { - environment: 'dev' | 'dev-watch' | 'release'; - appConfig: AppConfig; + environment: 'dev' | 'watch' | 'release'; } export interface AppConfig { showDebugger: boolean; projectGraphs: ProjectGraphList[]; defaultProjectGraph: string; - projectGraphService: ProjectGraphService; } export const DEFAULT_CONFIG: AppConfig = { showDebugger: false, projectGraphs: [], defaultProjectGraph: null, - projectGraphService: null, }; diff --git a/dep-graph/dep-graph/src/app/project-node-tooltip.ts b/dep-graph/dep-graph/src/app/project-node-tooltip.ts index 3bc100cdc865a..9c990e8b8bc2e 100644 --- a/dep-graph/dep-graph/src/app/project-node-tooltip.ts +++ b/dep-graph/dep-graph/src/app/project-node-tooltip.ts @@ -1,4 +1,5 @@ import * as cy from 'cytoscape'; +import { useDepGraphService } from './machines/dep-graph.service'; export class ProjectNodeToolTip { constructor(private node: cy.NodeSingular) {} @@ -53,13 +54,15 @@ export class ProjectNodeToolTip { wrapper.classList.add('flex'); + const [_, send] = useDepGraphService(); + focusButton.addEventListener('click', () => - window.focusProject(this.node.attr('id')) + send({ type: 'focusProject', projectName: this.node.attr('id') }) ); focusButton.innerText = 'Focus'; excludeButton.addEventListener('click', () => { - window.excludeProject(this.node.attr('id')); + send({ type: 'deselectProject', projectName: this.node.attr('id') }); }); excludeButton.innerText = 'Exclude'; diff --git a/dep-graph/dep-graph/src/app/ui-sidebar/display-options-panel.ts b/dep-graph/dep-graph/src/app/ui-sidebar/display-options-panel.ts index c8843e9c497ed..69a652a758d75 100644 --- a/dep-graph/dep-graph/src/app/ui-sidebar/display-options-panel.ts +++ b/dep-graph/dep-graph/src/app/ui-sidebar/display-options-panel.ts @@ -1,51 +1,34 @@ -import { BehaviorSubject, combineLatest, Subject } from 'rxjs'; -import { distinctUntilChanged, map, withLatestFrom } from 'rxjs/operators'; +import { useDepGraphService } from '../machines/dep-graph.service'; +import { DepGraphSend } from '../machines/interfaces'; import { removeChildrenFromContainer } from '../util'; export class DisplayOptionsPanel { - private showAffected = false; - private groupByFolder = false; - private selectAffectedSubject = new Subject(); - private selectAllSubject = new Subject(); - private deselectAllSubject = new Subject(); - private groupByFolderSubject = new Subject(); - private searchByDepthSubject = new BehaviorSubject(1); - private searchByDepthEnabledSubject = new BehaviorSubject(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; + affectedButtonElement: HTMLElement; + groupByFolderCheckboxElement: HTMLInputElement; + + send: DepGraphSend; + + constructor(private container: HTMLElement) { + const [state$, send] = useDepGraphService(); + this.send = send; + this.render(); + + state$.subscribe((state) => { + if (state.context.affectedProjects.length > 0) { + this.affectedButtonElement.classList.remove('hidden'); + this.affectedButtonElement.addEventListener('click', () => + this.send({ type: 'selectAffected' }) + ); + } - constructor(showAffected = false, groupByFolder = false) { - this.showAffected = showAffected; - this.groupByFolder = groupByFolder; - - this.searchDepthChangesSubject - .pipe(withLatestFrom(this.searchByDepthSubject)) - .subscribe(([action, current]) => { - if (action === 'decrement' && current > 1) { - this.searchByDepthSubject.next(current - 1); - } else if (action === 'increment') { - this.searchByDepthSubject.next(current + 1); - } - }); - - this.searchByDepthSubject.subscribe((current) => { - if (this.searchDepthDisplay) { - this.searchDepthDisplay.innerText = current.toString(); + 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; } - render(container: HTMLElement) { - removeChildrenFromContainer(container); + private render() { + removeChildrenFromContainer(this.container); const element = DisplayOptionsPanel.renderHtmlTemplate(); - const affectedButtonElement: HTMLElement = element.querySelector( + this.affectedButtonElement = element.querySelector( '[data-cy="affectedButton"]' ); - if (this.showAffected) { - affectedButtonElement.classList.remove('hidden'); - affectedButtonElement.addEventListener('click', () => - this.selectAffectedSubject.next() - ); - } - const selectAllButtonElement: HTMLElement = element.querySelector( '[data-cy="selectAllButton"]' ); selectAllButtonElement.addEventListener('click', () => { - this.selectAllSubject.next(); + this.send({ type: 'selectAll' }); }); const deselectAllButtonElement: HTMLElement = element.querySelector( '[data-cy="deselectAllButton"]' ); deselectAllButtonElement.addEventListener('click', () => { - this.deselectAllSubject.next(); + this.send({ type: 'deselectAll' }); }); - const groupByFolderCheckboxElement: HTMLInputElement = + this.groupByFolderCheckboxElement = element.querySelector('#displayOptions'); - groupByFolderCheckboxElement.checked = this.groupByFolder; - groupByFolderCheckboxElement.addEventListener( + this.groupByFolderCheckboxElement.addEventListener( 'change', (event: InputEvent) => - this.groupByFolderSubject.next((event.target).checked) + this.send({ + type: 'setGroupByFolder', + groupByFolder: (event.target as HTMLInputElement).checked, + }) ); this.searchDepthDisplay = element.querySelector('#depthFilterValue'); @@ -170,18 +148,19 @@ export class DisplayOptionsPanel { element.querySelector('#depthFilter'); incrementButtonElement.addEventListener('click', () => { - this.searchDepthChangesSubject.next('increment'); + this.send({ type: 'incrementSearchDepth' }); }); decrementButtonElement.addEventListener('click', () => { - this.searchDepthChangesSubject.next('decrement'); + this.send({ type: 'decrementSearchDepth' }); }); searchDepthEnabledElement.addEventListener('change', (event: InputEvent) => - this.searchByDepthEnabledSubject.next( - (event.target).checked - ) + this.send({ + type: 'setSearchDepthEnabled', + searchDepthEnabled: (event.target).checked, + }) ); - container.appendChild(element); + this.container.appendChild(element); } } diff --git a/dep-graph/dep-graph/src/app/ui-sidebar/focused-project-panel.ts b/dep-graph/dep-graph/src/app/ui-sidebar/focused-project-panel.ts index 357c9fe7edf30..93e6b8d689f94 100644 --- a/dep-graph/dep-graph/src/app/ui-sidebar/focused-project-panel.ts +++ b/dep-graph/dep-graph/src/app/ui-sidebar/focused-project-panel.ts @@ -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'; export class FocusedProjectPanel { - private unfocusSubject = new Subject(); - - set projectName(projectName: string) { - this.render(projectName); - } - - unfocus$ = this.unfocusSubject.asObservable(); + private send: DepGraphSend; 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 { @@ -39,10 +40,6 @@ export class FocusedProjectPanel { return render.content.firstChild as HTMLElement; } - unfocusProject() { - this.render(); - } - private render(projectName?: string) { removeChildrenFromContainer(this.container); @@ -62,7 +59,7 @@ export class FocusedProjectPanel { } unfocusButtonElement.addEventListener('click', () => - this.unfocusSubject.next() + this.send({ type: 'unfocusProject' }) ); this.container.appendChild(element); diff --git a/dep-graph/dep-graph/src/app/ui-sidebar/project-list.ts b/dep-graph/dep-graph/src/app/ui-sidebar/project-list.ts index 91a56ac4ffdfd..3da59c0762331 100644 --- a/dep-graph/dep-graph/src/app/ui-sidebar/project-list.ts +++ b/dep-graph/dep-graph/src/app/ui-sidebar/project-list.ts @@ -1,35 +1,24 @@ import type { ProjectGraphNode } from '@nrwl/devkit'; -import { Subject } from 'rxjs'; +import { useDepGraphService } from '../machines/dep-graph.service'; +import { DepGraphSend } from '../machines/interfaces'; import { parseParentDirectoriesFromPilePath, removeChildrenFromContainer, } from '../util'; export class ProjectList { - private focusProjectSubject = new Subject(); - private checkedProjectsChangeSubject = new Subject(); - private selectedItems: Record = {}; - checkedProjectsChange$ = this.checkedProjectsChangeSubject.asObservable(); - focusProject$ = this.focusProjectSubject.asObservable(); - - private _projects: ProjectGraphNode[] = []; - - 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); - } + private projectItems: Record = {}; - get projects(): ProjectGraphNode[] { - return this._projects; - } + private send: DepGraphSend; 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 { @@ -60,63 +49,49 @@ export class ProjectList { return render.content.firstChild as HTMLElement; } - selectProjects(projects: string[]) { - projects.forEach((projectName) => { - if (!!this.selectedItems[projectName]) { - 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 + setSelectedProjects(selectedProjects: string[]) { + Object.keys(this.projectItems).forEach((projectName) => { + this.projectItems[projectName].dataset['active'] = selectedProjects .includes(projectName) .toString(); - this.selectedItems[projectName].dispatchEvent(new CustomEvent('change')); + this.projectItems[projectName].dispatchEvent(new CustomEvent('change')); }); } checkAllProjects() { - Object.values(this.selectedItems).forEach((item) => { - item.dataset['active'] = 'true'; - item.dispatchEvent(new CustomEvent('change')); - }); + this.send({ type: 'selectAll' }); } uncheckAllProjects() { - Object.values(this.selectedItems).forEach((item) => { - item.dataset['active'] = 'false'; - item.dispatchEvent(new CustomEvent('change')); - }); + this.send({ type: 'deselectAll' }); } uncheckProject(projectName: string) { - this.selectedItems[projectName].dataset['active'] = 'false'; - this.selectedItems[projectName].dispatchEvent(new CustomEvent('change')); - } - - private emitChanges() { - const changes = Object.values(this.selectedItems) - .filter((item) => item.dataset['active'] === 'true') - .map((item) => item.dataset['project']); - this.checkedProjectsChangeSubject.next(changes); + this.send({ type: 'deselectProject', projectName }); } - private render() { + private render( + projects: ProjectGraphNode[], + workspaceLayout: { appsDir: string; libsDir: string } + ) { removeChildrenFromContainer(this.container); - const appProjects = this.getProjectsByType('app'); - const libProjects = this.getProjectsByType('lib'); - const e2eProjects = this.getProjectsByType('e2e'); - - const appDirectoryGroups = this.groupProjectsByDirectory(appProjects); - const libDirectoryGroups = this.groupProjectsByDirectory(libProjects); - const e2eDirectoryGroups = this.groupProjectsByDirectory(e2eProjects); + const appProjects = this.getProjectsByType('app', projects); + const libProjects = this.getProjectsByType('lib', projects); + const e2eProjects = this.getProjectsByType('e2e', projects); + + const appDirectoryGroups = this.groupProjectsByDirectory( + appProjects, + workspaceLayout + ); + const libDirectoryGroups = this.groupProjectsByDirectory( + libProjects, + workspaceLayout + ); + const e2eDirectoryGroups = this.groupProjectsByDirectory( + e2eProjects, + workspaceLayout + ); const sortedAppDirectories = Object.keys(appDirectoryGroups).sort(); const sortedLibDirectories = Object.keys(libDirectoryGroups).sort(); @@ -153,20 +128,23 @@ export class ProjectList { }); } - private getProjectsByType(type) { - return this.projects + private getProjectsByType(type: string, projects: ProjectGraphNode[]) { + return projects .filter((project) => project.type === type) .sort((a, b) => a.name.localeCompare(b.name)); } - private groupProjectsByDirectory(projects: ProjectGraphNode[]) { + private groupProjectsByDirectory( + projects: ProjectGraphNode[], + workspaceLayout: { appsDir: string; libsDir: string } + ) { let groups = {}; projects.forEach((project) => { const workspaceRoot = project.type === 'app' || project.type === 'e2e' - ? window.workspaceLayout.appsDir - : window.workspaceLayout.libsDir; + ? workspaceLayout.appsDir + : workspaceLayout.libsDir; const directories = parseParentDirectoriesFromPilePath( project.data.root, workspaceRoot @@ -203,7 +181,7 @@ export class ProjectList { ); const focusButtonElement: HTMLElement = element.querySelector('button'); focusButtonElement.addEventListener('click', () => - this.focusProjectSubject.next(project.name) + this.send({ type: 'focusProject', projectName: project.name }) ); const projectNameElement: HTMLElement = element.querySelector('label'); @@ -214,12 +192,19 @@ export class ProjectList { projectNameElement.addEventListener('click', (event) => { const el = event.target as HTMLElement; - el.dataset['active'] = - el.dataset['active'] === 'false' ? 'true' : 'false'; - el.dispatchEvent(new CustomEvent('change')); - - this.emitChanges(); + if (el.dataset['active'] === 'true') { + this.send({ + type: 'deselectProject', + projectName: el.dataset['project'], + }); + } else { + this.send({ + type: 'selectProject', + projectName: el.dataset['project'], + }); + } }); + projectNameElement.addEventListener('change', (event) => { const el = event.target as HTMLElement; if (el.dataset['active'] === 'false') { @@ -231,7 +216,7 @@ export class ProjectList { projectNameElement.dispatchEvent(new Event('click')); }); - this.selectedItems[project.name] = projectNameElement; + this.projectItems[project.name] = projectNameElement; formGroup.append(element); }); diff --git a/dep-graph/dep-graph/src/app/ui-sidebar/sidebar.ts b/dep-graph/dep-graph/src/app/ui-sidebar/sidebar.ts index 7886c9e47d6d1..c320dc4466d3e 100644 --- a/dep-graph/dep-graph/src/app/ui-sidebar/sidebar.ts +++ b/dep-graph/dep-graph/src/app/ui-sidebar/sidebar.ts @@ -1,52 +1,28 @@ -import { ProjectGraphNode } from '@nrwl/devkit'; -import { BehaviorSubject, combineLatest, fromEvent, Subject } from 'rxjs'; import { DisplayOptionsPanel } from './display-options-panel'; import { FocusedProjectPanel } from './focused-project-panel'; import { ProjectList } from './project-list'; -import { TextFilterChangeEvent, TextFilterPanel } from './text-filter-panel'; +import { TextFilterPanel } from './text-filter-panel'; declare var ResizeObserver; export class SidebarComponent { - private selectedProjectsChangedSubject = new BehaviorSubject([]); - private groupByFolderChangedSubject = new BehaviorSubject( - window.groupByFolder - ); - - private focusProjectSubject = new Subject(); - private filterByTextSubject = new Subject(); - - selectedProjectsChanged$ = this.selectedProjectsChangedSubject.asObservable(); - groupByFolderChanged$ = this.groupByFolderChangedSubject.asObservable(); - private displayOptionsPanel: DisplayOptionsPanel; private focusedProjectPanel: FocusedProjectPanel; private textFilterPanel: TextFilterPanel; private projectList: ProjectList; - private groupByFolder = window.groupByFolder; - private selectedProjects: string[] = []; - - set projects(projects: ProjectGraphNode[]) { - this.projectList.projects = projects; - this.focusedProjectPanel.unfocusProject(); - } - - constructor(private affectedProjects: string[]) { - const showAffected = this.affectedProjects.length > 0; - + constructor() { const displayOptionsPanelContainer = document.getElementById( 'display-options-panel' ); this.displayOptionsPanel = new DisplayOptionsPanel( - showAffected, - this.groupByFolder + displayOptionsPanelContainer ); - this.displayOptionsPanel.render(displayOptionsPanelContainer); const focusedProjectPanelContainer = document.getElementById('focused-project'); + this.focusedProjectPanel = new FocusedProjectPanel( focusedProjectPanelContainer ); @@ -57,232 +33,5 @@ export class SidebarComponent { const projectListContainer = document.getElementById('project-lists'); 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(); - - 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; } } diff --git a/dep-graph/dep-graph/src/app/ui-sidebar/text-filter-panel.ts b/dep-graph/dep-graph/src/app/ui-sidebar/text-filter-panel.ts index 04ecc11a83269..a9b026ecd979d 100644 --- a/dep-graph/dep-graph/src/app/ui-sidebar/text-filter-panel.ts +++ b/dep-graph/dep-graph/src/app/ui-sidebar/text-filter-panel.ts @@ -1,6 +1,8 @@ -import { fromEvent, Subject, Subscription } from 'rxjs'; -import { removeChildrenFromContainer } from '../util'; +import { fromEvent, Subscription } from 'rxjs'; 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 { text: string; @@ -10,13 +12,11 @@ export interface TextFilterChangeEvent { export class TextFilterPanel { private textInput: HTMLInputElement; private includeInPathCheckbox: HTMLInputElement; - private changesSubject = new Subject(); - private subscriptions: Subscription[] = []; - - changes$ = this.changesSubject.asObservable(); + private send: DepGraphSend; constructor(private container: HTMLElement) { - this.subscriptions.map((s) => s.unsubscribe()); + const [_, send] = useDepGraphService(); + this.send = send; this.render(); } @@ -42,7 +42,7 @@ export class TextFilterPanel {
- +
@@ -55,13 +55,6 @@ export class TextFilterPanel { return render.content.firstChild as HTMLElement; } - private emitChanges() { - this.changesSubject.next({ - text: this.textInput.value.toLowerCase(), - includeInPath: this.includeInPathCheckbox.checked, - }); - } - private render() { removeChildrenFromContainer(this.container); @@ -72,7 +65,10 @@ export class TextFilterPanel { this.textInput = element.querySelector('input[type="text"]'); 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) { resetInputElement.classList.remove('hidden'); this.includeInPathCheckbox.disabled = false; @@ -82,19 +78,22 @@ export class TextFilterPanel { } }); - this.subscriptions.push( - fromEvent(this.textInput, 'keyup') - .pipe( - filter((event: KeyboardEvent) => event.key !== 'Enter'), - debounceTime(500), - map(() => this.emitChanges()) + fromEvent(this.textInput, 'keyup') + .pipe( + filter((event: KeyboardEvent) => event.key !== 'Enter'), + debounceTime(500), + map(() => + this.send({ type: 'filterByText', search: this.textInput.value }) ) - .subscribe() - ); + ) + .subscribe(); this.includeInPathCheckbox = element.querySelector('#includeInPath'); this.includeInPathCheckbox.addEventListener('change', () => - this.emitChanges() + this.send({ + type: 'setIncludeProjectsByPath', + includeProjectsByPath: this.includeInPathCheckbox.checked, + }) ); resetInputElement.addEventListener('click', () => { @@ -102,7 +101,7 @@ export class TextFilterPanel { this.includeInPathCheckbox.checked = false; this.includeInPathCheckbox.disabled = true; resetInputElement.classList.add('hidden'); - this.emitChanges(); + this.send([{ type: 'clearTextFilter' }]); }); this.container.appendChild(element); diff --git a/dep-graph/dep-graph/src/app/util.ts b/dep-graph/dep-graph/src/app/util.ts index 6496df3f32f65..0c0e44f7508fb 100644 --- a/dep-graph/dep-graph/src/app/util.ts +++ b/dep-graph/dep-graph/src/app/util.ts @@ -1,3 +1,5 @@ +import { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit'; + export function removeChildrenFromContainer(container: HTMLElement) { Array.from(container.children).forEach((child) => container.removeChild(child) @@ -27,3 +29,115 @@ export function parseParentDirectoriesFromPilePath( return split; } + +export function hasPath( + dependencies: Record, + 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, + 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 +) { + const split = text.split(',').map((splitItem) => splitItem.trim()); + + const selectedProjects = new Set(); + + 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); +} diff --git a/dep-graph/dep-graph/src/assets/environment.dev.js b/dep-graph/dep-graph/src/assets/environment.dev.js new file mode 100644 index 0000000000000..bc1411acbee1e --- /dev/null +++ b/dep-graph/dep-graph/src/assets/environment.dev.js @@ -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', +}; diff --git a/dep-graph/dep-graph/src/assets/environment.watch.js b/dep-graph/dep-graph/src/assets/environment.watch.js new file mode 100644 index 0000000000000..36a0710947a4a --- /dev/null +++ b/dep-graph/dep-graph/src/assets/environment.watch.js @@ -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', +}; diff --git a/dep-graph/dep-graph/src/environments/environment.dev.ts b/dep-graph/dep-graph/src/environments/environment.dev.ts deleted file mode 100644 index 3db975085faeb..0000000000000 --- a/dep-graph/dep-graph/src/environments/environment.dev.ts +++ /dev/null @@ -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(), - }, -}; diff --git a/dep-graph/dep-graph/src/environments/environment.ts b/dep-graph/dep-graph/src/environments/environment.ts deleted file mode 100644 index 395659311e7e0..0000000000000 --- a/dep-graph/dep-graph/src/environments/environment.ts +++ /dev/null @@ -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(), - }, -}; diff --git a/dep-graph/dep-graph/src/environments/environment.watch.ts b/dep-graph/dep-graph/src/environments/environment.watch.ts deleted file mode 100644 index 1cb29d48982da..0000000000000 --- a/dep-graph/dep-graph/src/environments/environment.watch.ts +++ /dev/null @@ -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(), - }, -}; diff --git a/dep-graph/dep-graph/src/globals.d.ts b/dep-graph/dep-graph/src/globals.d.ts index 1c983fc28313a..d91999b287f23 100644 --- a/dep-graph/dep-graph/src/globals.d.ts +++ b/dep-graph/dep-graph/src/globals.d.ts @@ -2,27 +2,19 @@ import { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph'; import { ProjectGraph, ProjectGraphNode } from '@nrwl/devkit'; import { ProjectGraphList } from './graphs'; +import { AppConfig } from './app/models'; export declare global { export interface Window { - watch: boolean; - projects: ProjectGraphNode[]; - graph: ProjectGraph; - filteredProjects: ProjectGraphNode[]; - affected: string[]; exclude: string[]; focusedProject: string; groupByFolder: boolean; - focusProject: Function; - excludeProject: Function; - projectGraphList: ProjectGraphList[]; - selectedProjectGraph: string; - workspaceLayout: { - libsDir: string; - appsDir: string; - }; - projectGraphResponse: DepGraphClientResponse; + watch: boolean; localMode: 'serve' | 'build'; + projectGraphResponse?: DepGraphClientResponse; + environment: 'dev' | 'watch' | 'release'; + appConfig: AppConfig; + useXstateInspect: boolean = false; } } diff --git a/dep-graph/dep-graph/src/index.html b/dep-graph/dep-graph/src/index.html index 7680299c0fe40..2610b921f20de 100644 --- a/dep-graph/dep-graph/src/index.html +++ b/dep-graph/dep-graph/src/index.html @@ -10,6 +10,8 @@ href="https://fonts.googleapis.com/css?family=Montserrat&display=swap" rel="stylesheet" /> + + @@ -143,17 +145,4 @@

Please select projects in the sidebar.

- diff --git a/dep-graph/dep-graph/src/main.ts b/dep-graph/dep-graph/src/main.ts index 0c50979cdf0e3..5f66ff45a980f 100644 --- a/dep-graph/dep-graph/src/main.ts +++ b/dep-graph/dep-graph/src/main.ts @@ -1,13 +1,29 @@ import { AppComponent } from './app/app'; 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') { - window.watch = true; +if (window.useXstateInspect === true) { + inspect({ + url: 'https://stately.ai/viz?inspect', + iframe: false, // open in new window + }); } -if (environment.environment === 'release' && window.localMode === 'build') { - environment.appConfig.projectGraphService = new LocalProjectGraphService(); +let projectGraphService: ProjectGraphService; + +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)); diff --git a/package.json b/package.json index af635fbf21058..b2ec3a1f8bc34 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,8 @@ "@typescript-eslint/eslint-plugin": "~4.33.0", "@typescript-eslint/experimental-utils": "~4.33.0", "@typescript-eslint/parser": "~4.33.0", + "@xstate/immer": "^0.2.0", + "@xstate/inspect": "^0.5.1", "angular": "1.8.0", "autoprefixer": "^10.2.5", "babel-jest": "27.2.3", @@ -136,8 +138,8 @@ "cz-customizable": "^6.2.0", "depcheck": "^1.3.1", "dotenv": "~10.0.0", - "enhanced-resolve": "^5.8.3", "ejs": "^3.1.5", + "enhanced-resolve": "^5.8.3", "eslint": "7.32.0", "eslint-config-next": "^12.0.0", "eslint-config-prettier": "^8.1.0", @@ -158,6 +160,7 @@ "husky": "^6.0.0", "identity-obj-proxy": "3.0.0", "ignore": "^5.0.4", + "immer": "^9.0.6", "import-fresh": "^3.1.0", "injection-js": "^2.4.0", "is-ci": "^3.0.0", @@ -254,6 +257,7 @@ "webpack-sources": "^3.0.2", "webpack-subresource-integrity": "^1.5.2", "worker-plugin": "3.2.0", + "xstate": "^4.25.0", "yargs": "15.4.1", "yargs-parser": "20.0.0", "zone.js": "~0.11.4" diff --git a/packages/workspace/src/command-line/dep-graph.ts b/packages/workspace/src/command-line/dep-graph.ts index e823d9a44dd7d..26d1a70684963 100644 --- a/packages/workspace/src/command-line/dep-graph.ts +++ b/packages/workspace/src/command-line/dep-graph.ts @@ -20,10 +20,6 @@ import { ProjectGraphNode, pruneExternalNodes, } from '../core/project-graph'; -import { - cacheDirectory, - readCacheDirectoryProperty, -} from '../utilities/cache-directory'; import { writeJsonFile } from '../utilities/fileutils'; import { output } from '../utilities/output'; @@ -32,9 +28,6 @@ export interface DepGraphClientResponse { projects: ProjectGraphNode[]; dependencies: Record; layout: { appsDir: string; libsDir: string }; - changes: { - added: string[]; - }; affected: string[]; focus: string; groupByFolder: boolean; @@ -59,74 +52,46 @@ const mimeType = { '.ttf': 'aplication/font-sfnt', }; -const nxDepsDir = cacheDirectory( - appRootPath, - readCacheDirectoryProperty(appRootPath) -); - -async function projectsToHtml( - projects: ProjectGraphNode[], - graph: ProjectGraph, - affected: string[], +function buildEnvironmentJs( + exclude: string[], focus: string, groupByFolder: boolean, - exclude: string[], - layout: { appsDir: string; libsDir: string }, - localMode: 'serve' | 'build', - watchMode: boolean = false + watchMode: boolean, + localMode: 'build' | 'serve', + depGraphClientResponse?: DepGraphClientResponse ) { - let f = readFileSync( - join(__dirname, '../core/dep-graph/index.html'), - 'utf-8' - ); - - f = f - .replace( - `window.projects = []`, - `window.projects = ${JSON.stringify(projects)}` - ) - .replace(`window.graph = {}`, `window.graph = ${JSON.stringify(graph)}`) - .replace( - `window.affected = []`, - `window.affected = ${JSON.stringify(affected)}` - ) - .replace( - `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`); - } + let environmentJs = `window.exclude = ${JSON.stringify(exclude)}; + window.groupByFolder = ${!!groupByFolder}; + window.watch = ${!!watchMode}; + window.environment = 'release'; + window.localMode = '${localMode}'; + + window.appConfig = { + showDebugger: false, + projectGraphs: [ + { + id: 'local', + label: 'local', + url: 'projectGraph.json', + } + ], + defaultProjectGraph: 'local', + }; + `; if (localMode === 'build') { - currentDepGraphClientResponse = await createDepGraphClientResponse(); - f = f.replace( - `window.projectGraphResponse = null`, - `window.projectGraphResponse = ${JSON.stringify( - currentDepGraphClientResponse - )}` - ); + environmentJs += `window.projectGraphResponse = ${JSON.stringify( + depGraphClientResponse + )};`; + } else { + environmentJs += `window.projectGraphResponse = null;`; + } - f = f.replace(`window.localMode = 'serve'`, `window.localMode = 'build'`); + if (!!focus) { + environmentJs += `window.focusedProject = '${focus}';`; } - return f; + return environmentJs; } 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') { - 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 || []); - } + graph = filterGraph(graph, args.focus || null, args.exclude || []); if (args.file) { 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(/href="styles/g, 'href="static/styles'); html = html.replace('', ''); html = html.replace(/type="module"/g, ''); writeFileSync(fullFilePath, html); + writeFileSync(join(assetsFolder, 'environment.js'), environmentJs); output.success({ title: `HTML output created in ${fileFolderPath}`, @@ -315,8 +278,17 @@ export async function generateGraph( process.exit(1); } } else { + const environmentJs = buildEnvironmentJs( + args.exclude || [], + args.focus || null, + args.groupByFolder || false, + args.watch, + !!args.file && args.file.endsWith('html') ? 'build' : 'serve' + ); + await startServer( html, + environmentJs, args.host || '127.0.0.1', args.port || 4211, args.watch, @@ -331,6 +303,7 @@ export async function generateGraph( async function startServer( html: string, + environmentJs: string, host: string, port = 4211, watchForchanges = false, @@ -372,6 +345,12 @@ async function startServer( return; } + if (sanitizePath === 'environment.js') { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(environmentJs); + return; + } + let pathname = join(__dirname, '../core/dep-graph/', sanitizePath); if (!existsSync(pathname)) { @@ -383,7 +362,6 @@ async function startServer( // if is a directory, then look for index.html if (statSync(pathname).isDirectory()) { - // pathname += '/index.html'; res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(html); return; @@ -420,9 +398,6 @@ let currentDepGraphClientResponse: DepGraphClientResponse = { appsDir: '', libsDir: '', }, - changes: { - added: [], - }, affected: [], focus: null, groupByFolder: false, @@ -522,21 +497,6 @@ async function createDepGraphClientResponse(): Promise { 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.measure( @@ -557,8 +517,5 @@ async function createDepGraphClientResponse(): Promise { layout, projects, dependencies, - changes: { - added: [...currentDepGraphClientResponse.changes.added, ...added], - }, }; } diff --git a/scripts/copy-dep-graph-environment.ts b/scripts/copy-dep-graph-environment.ts new file mode 100644 index 0000000000000..b7722e208c0c0 --- /dev/null +++ b/scripts/copy-dep-graph-environment.ts @@ -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` +); diff --git a/yarn.lock b/yarn.lock index 9217cf65ada50..5bdafa2a495e9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5392,6 +5392,18 @@ "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" +"@xstate/immer@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@xstate/immer/-/immer-0.2.0.tgz#4f128947c3cbb3e68357b886485a36852d4e06b3" + integrity sha512-ZKwAwS84kfmN108lEtVHw8jztKDiFeaQsTxkOlOghpK1Lr7+13G8HhZZXyN1/pVkplloUUOPMH5EXVtitZDr8w== + +"@xstate/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": version "1.2.0" 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" 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" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884" 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" integrity sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA== -immer@^9.0.1: +immer@^9.0.1, immer@^9.0.6: version "9.0.6" resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73" 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" 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: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"