chore(dep-graph): migrate dep-graph app to React (#8152)

This commit is contained in:
Philip Fulcher 2021-12-16 16:30:04 -07:00 committed by GitHub
parent a78d43189a
commit 717a560a54
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
112 changed files with 2042 additions and 1503 deletions

2
.gitignore vendored
View File

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

View File

@ -6,7 +6,7 @@
"pluginsFile": "./src/plugins/index",
"supportFile": "./src/support/index.ts",
"video": true,
"videosFolder": "../../dist/cypress/dep-graph/dep-graph-e2e/videos",
"screenshotsFolder": "../../dist/cypress/dep-graph/dep-graph-e2e/screenshots",
"videosFolder": "../../dist/cypress/dep-graph/client-e2e/videos",
"screenshotsFolder": "../../dist/cypress/dep-graph/client-e2e/screenshots",
"chromeWebSecurity": false
}

View File

@ -6,7 +6,7 @@
"pluginsFile": "./src/plugins/index",
"supportFile": "./src/support/index.ts",
"video": true,
"videosFolder": "../../dist/cypress/dep-graph/dep-graph-e2e/videos",
"screenshotsFolder": "../../dist/cypress/dep-graph/dep-graph-e2e/screenshots",
"videosFolder": "../../dist/cypress/dep-graph/client-e2e/videos",
"screenshotsFolder": "../../dist/cypress/dep-graph/client-e2e/screenshots",
"chromeWebSecurity": false
}

View File

@ -0,0 +1,33 @@
{
"root": "dep-graph/client-e2e",
"sourceRoot": "dep-graph/client-e2e/src",
"projectType": "application",
"targets": {
"e2e-disabled": {
"executor": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "dep-graph/client-e2e/cypress.json",
"tsConfig": "dep-graph/client-e2e/tsconfig.e2e.json",
"devServerTarget": "dep-graph-client:serve-for-e2e",
"baseUrl": "http://localhost:4200"
}
},
"e2e-watch-disabled": {
"executor": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "dep-graph/client-e2e/cypress-watch-mode.json",
"tsConfig": "dep-graph/client-e2e/tsconfig.e2e.json",
"devServerTarget": "dep-graph-client:serve-for-e2e:watch",
"baseUrl": "http://localhost:4200"
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["dep-graph/client-e2e/**/*.ts"]
}
}
},
"implicitDependencies": ["dep-graph-client"]
}

View File

@ -1,11 +1,14 @@
import {
getCheckedProjectItems,
getDeselectAllButton,
getImageDownloadButton,
getIncludeProjectsInPathButton,
getProjectItems,
getSelectAffectedButton,
getSelectAllButton,
getSelectProjectsMessage,
getTextFilterInput,
getTextFilterReset,
getUncheckedProjectItems,
getUnfocusProjectButton,
} from '../support/app.po';
@ -20,15 +23,45 @@ describe('dep-graph-client', () => {
cy.wait('@getGraph');
});
it('should display message to select projects', () => {
describe('select projects message', () => {
it('should display on load', () => {
getSelectProjectsMessage().should('be.visible');
});
it('should hide select projects message when a project is selected', () => {
it('should hide when a project is selected', () => {
cy.contains('nx-dev').scrollIntoView().should('be.visible');
cy.get('[data-project="nx-dev"]').should('be.visible');
cy.get('[data-project="nx-dev"]').click({ force: true });
getSelectProjectsMessage().should('not.be.visible');
getSelectProjectsMessage().should('not.exist');
});
});
describe('text filter', () => {
it('should hide clear button initially', () => {
getTextFilterReset().should('not.exist');
});
it('should show clear button after typing', () => {
getTextFilterInput().type('nx-dev');
getTextFilterReset().should('exist');
});
it('should hide clear button after clicking', () => {
getTextFilterInput().type('nx-dev');
getTextFilterReset().click().should('not.exist');
});
it('should filter projects', () => {
getTextFilterInput().type('nx-dev');
getCheckedProjectItems().should('have.length', 9);
});
it('should clear selection on reset', () => {
getTextFilterInput().type('nx-dev');
getCheckedProjectItems().should('have.length', 9);
getTextFilterReset().click();
getCheckedProjectItems().should('have.length', 0);
});
});
describe('selecting a different project', () => {
@ -54,6 +87,20 @@ describe('dep-graph-client', () => {
});
});
describe('show affected button', () => {
it('should be hidden initially', () => {
getSelectAffectedButton().should('not.exist');
});
it('should check all affected project items', () => {
cy.get('[data-cy=project-select]').select('Affected', { force: true });
cy.wait('@getGraph');
getSelectAffectedButton().click();
getCheckedProjectItems().should('have.length', 5);
});
});
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);
@ -61,7 +108,7 @@ describe('dep-graph-client', () => {
force: true,
});
cy.get('[data-project="nx-dev"]').should('have.data', 'active', true);
cy.get('[data-project="nx-dev"][data-active="true"]').should('exist');
});
it('should deselect a project by clicking on the project name again', () => {
@ -125,4 +172,21 @@ describe('dep-graph-client', () => {
getCheckedProjectItems().should('have.length', 17);
});
});
describe('image download button', () => {
it('should be hidden initally', () => {
getImageDownloadButton().should('have.class', 'opacity-0');
});
it('should be shown when a project is selected', () => {
cy.get('[data-project="nx-dev"]').prev('button').click({ force: true });
getImageDownloadButton().should('not.have.class', 'opacity-0');
});
it('should be hidden when no more projects are selected', () => {
cy.get('[data-project="nx-dev"]').prev('button').click({ force: true });
getDeselectAllButton().click();
getImageDownloadButton().should('have.class', 'opacity-0');
});
});
});

View File

@ -2,6 +2,8 @@ export const getSelectProjectsMessage = () => cy.get('#no-projects-chosen');
export const getGraph = () => cy.get('#graph-container');
export const getSelectAllButton = () => cy.get('[data-cy=selectAllButton]');
export const getDeselectAllButton = () => cy.get('[data-cy=deselectAllButton]');
export const getSelectAffectedButton = () => cy.get('[data-cy=affectedButton]');
export const getUnfocusProjectButton = () => cy.get('[data-cy=unfocusButton]');
export const getProjectItems = () => cy.get('[data-project]');
@ -13,5 +15,10 @@ export const getGroupByfolderItems = () =>
cy.get('input[name=displayOptions][value=groupByFolder]');
export const getTextFilterInput = () => cy.get('[data-cy=textFilterInput]');
export const getTextFilterReset = () => cy.get('[data-cy=textFilterReset]');
export const getIncludeProjectsInPathButton = () =>
cy.get('input[name=textFilterCheckbox]');
export const getImageDownloadButton = () =>
cy.get('[data-cy=downloadImageButton]');

View File

@ -4,7 +4,7 @@ describe('dep-graph-client in watch mode', () => {
beforeEach(() => {
cy.clock();
cy.visit('/');
cy.tick(1000);
cy.tick(2000);
});
it('should auto-select new libs as they are created', () => {

11
dep-graph/client/.babelrc Normal file
View File

@ -0,0 +1,11 @@
{
"presets": [
[
"@nrwl/react/babel",
{
"runtime": "automatic"
}
]
],
"plugins": []
}

View File

@ -1,4 +1,7 @@
# This file is currently used by autoprefixer to adjust CSS to support the below specified browsers
# This file is used by:
# 1. autoprefixer to adjust CSS to support the below specified browsers
# 2. babel preset-env to adjust included polyfills
#
# For additional information regarding the format and rule options, please see:
# https://github.com/browserslist/browserslist#queries
#

View File

@ -0,0 +1,21 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"rules": {
"@typescript-eslint/no-implicit-any": "off"
},
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@ -0,0 +1,13 @@
// nx-ignore-next-line
const nxPreset = require('@nrwl/jest/preset');
module.exports = {
...nxPreset,
displayName: 'dep-graph-client',
transform: {
'^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': '@nrwl/react/plugins/jest',
'^.+\\.[tj]sx?$': ['babel-jest', { presets: ['@nrwl/next/babel'] }],
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/nx-dev/nx-dev',
};

View File

@ -1,7 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {
config: './dep-graph/dep-graph/tailwind.config.js',
config: './dep-graph/client/tailwind.config.js',
},
autoprefixer: {},
},

View File

@ -1,6 +1,6 @@
{
"root": "dep-graph/dep-graph",
"sourceRoot": "dep-graph/dep-graph/src",
"root": "dep-graph/client",
"sourceRoot": "dep-graph/client/src",
"projectType": "application",
"targets": {
"build-base": {
@ -8,11 +8,11 @@
"options": {
"maxWorkers": 8,
"outputPath": "build/apps/dep-graph",
"index": "dep-graph/dep-graph/src/index.html",
"main": "dep-graph/dep-graph/src/main.ts",
"polyfills": "dep-graph/dep-graph/src/polyfills.ts",
"tsConfig": "dep-graph/dep-graph/tsconfig.app.json",
"styles": ["dep-graph/dep-graph/src/styles.scss"],
"index": "dep-graph/client/src/index.html",
"main": "dep-graph/client/src/main.tsx",
"polyfills": "dep-graph/client/src/polyfills.ts",
"tsConfig": "dep-graph/client/tsconfig.app.json",
"styles": ["dep-graph/client/src/styles.scss"],
"scripts": [],
"assets": [],
"optimization": true,
@ -34,10 +34,10 @@
"dev": {
"fileReplacements": [],
"assets": [
"dep-graph/dep-graph/src/favicon.ico",
"dep-graph/dep-graph/src/assets/graphs/",
"dep-graph/client/src/favicon.ico",
"dep-graph/client/src/assets/graphs/",
{
"input": "dep-graph/dep-graph/src/assets",
"input": "dep-graph/client/src/assets",
"output": "/",
"glob": "environment.js"
}
@ -63,21 +63,21 @@
"serve-base": {
"executor": "@nrwl/web:dev-server",
"options": {
"buildTarget": "dep-graph-dep-graph:build-base:dev"
"buildTarget": "dep-graph-client:build-base:dev"
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["dep-graph/dep-graph/**/*.ts"]
"lintFilePatterns": ["dep-graph/client/**/*.{ts,tsx,js,jsx}"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["coverage/dep-graph/dep-graph"],
"outputs": ["coverage/dep-graph/client"],
"options": {
"jestConfig": "dep-graph/dep-graph/jest.config.js",
"jestConfig": "dep-graph/client/jest.config.js",
"passWithNoTests": true
}
},
@ -87,14 +87,14 @@
"options": {
"commands": [
"npx ts-node -P ./scripts/tsconfig.scripts.json ./scripts/copy-dep-graph-environment.ts dev",
"nx serve-base dep-graph-dep-graph"
"nx serve-base dep-graph-client"
]
},
"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"
"nx serve-base dep-graph-client"
]
}
}
@ -105,7 +105,7 @@
"options": {
"commands": [
"npx ts-node -P ./scripts/tsconfig.scripts.json ./scripts/copy-dep-graph-environment.ts dev",
"nx serve-base dep-graph-dep-graph"
"nx serve-base dep-graph-client"
],
"readyWhen": "No issues found."
},
@ -113,12 +113,12 @@
"watch": {
"commands": [
"npx ts-node -P ./scripts/tsconfig.scripts.json ./scripts/copy-dep-graph-environment.ts watch",
"nx serve-base dep-graph-dep-graph"
"nx serve-base dep-graph-client"
],
"readyWhen": "No issues found."
}
}
}
},
"tags": ["core"]
"tags": []
}

View File

@ -0,0 +1,12 @@
import { Shell } from './shell';
import { GlobalStateProvider } from './state.provider';
export function App() {
return (
<GlobalStateProvider>
<Shell></Shell>
</GlobalStateProvider>
);
}
export default App;

View File

@ -0,0 +1,59 @@
import { ProjectGraphList } from './interfaces';
import { GraphPerfReport } from './machines/interfaces';
import { memo } from 'react';
export interface DebuggerPanelProps {
projectGraphs: ProjectGraphList[];
selectedProjectGraph: string;
projectGraphChange: (projectName: string) => void;
lastPerfReport: GraphPerfReport;
}
export const DebuggerPanel = memo(function ({
projectGraphs,
selectedProjectGraph,
projectGraphChange,
lastPerfReport,
}: DebuggerPanelProps) {
return (
<div
id="debugger-panel"
className="
w-auto
text-gray-700
bg-gray-50
border-b border-gray-200
p-4
flex flex-column
items-center
justify-items-center
gap-4
"
>
<h4 className="text-lg font-bold mr-4">Debugger</h4>
<select
className="w-auto flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white"
data-cy="project-select"
onChange={(event) => projectGraphChange(event.target.value)}
value={selectedProjectGraph}
>
{projectGraphs.map((projectGraph) => {
return (
<option key={projectGraph.id} value={projectGraph.id}>
{projectGraph.label}
</option>
);
})}
</select>
<p className="text-sm">
Last render took {lastPerfReport.renderTime}ms:{' '}
<b className="font-mono text-medium">{lastPerfReport.numNodes} nodes</b>{' '}
|{' '}
<b className="font-mono text-medium">{lastPerfReport.numEdges} edges</b>
.
</p>
</div>
);
});
export default DebuggerPanel;

View File

@ -1,6 +1,6 @@
// nx-ignore-next-line
import { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { ProjectGraphService } from './models';
import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { ProjectGraphService } from './interfaces';
export class FetchProjectGraphService implements ProjectGraphService {
async getHash(): Promise<string> {

View File

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react';
export function useDebounce(value: string, delay: number) {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(
() => {
// Update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler);
};
},
[value, delay] // Only re-call effect if value or delay changes
);
return debouncedValue;
}

View File

@ -0,0 +1,11 @@
import { useSelector } from '@xstate/react';
import { DepGraphState } from '../machines/interfaces';
import { useDepGraphService } from './use-dep-graph';
export type DepGraphSelector<T> = (depGraphState: DepGraphState) => T;
export function useDepGraphSelector<T>(selectorFunc: DepGraphSelector<T>): T {
const depGraphService = useDepGraphService();
return useSelector<typeof depGraphService, T>(depGraphService, selectorFunc);
}

View File

@ -0,0 +1,8 @@
import { useContext } from 'react';
import { GlobalStateContext } from '../state.provider';
export function useDepGraphService() {
const globalState = useContext(GlobalStateContext);
return globalState;
}

View File

@ -0,0 +1,30 @@
// nx-ignore-next-line
import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { useRef } from 'react';
import { AppConfig } from '../interfaces';
export function useEnvironmentConfig(): {
exclude: string[];
focusedProject: string;
groupByFolder: boolean;
watch: boolean;
localMode: 'serve' | 'build';
projectGraphResponse?: DepGraphClientResponse;
environment: 'dev' | 'watch' | 'release';
appConfig: AppConfig;
useXstateInspect: boolean;
} {
const environmentConfig = useRef({
exclude: window.exclude,
focusedProject: window.focusedProject,
groupByFolder: window.groupByFolder,
watch: window.watch,
localMode: window.localMode,
projectGraphResponse: window.projectGraphResponse,
environment: window.environment,
appConfig: window.appConfig,
useXstateInspect: window.useXstateInspect,
});
return environmentConfig.current;
}

View File

@ -0,0 +1,28 @@
import { useRef, useEffect } from 'react';
export const useIntervalWhen = (
callback: () => void,
delay: number,
condition: boolean
) => {
const savedCallback = useRef(() => {});
useEffect(() => {
if (condition) {
savedCallback.current = callback;
}
}, [callback, condition]);
useEffect(() => {
if (condition) {
const tick = () => {
savedCallback.current();
};
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}
}, [delay, condition]);
};

View File

@ -0,0 +1,24 @@
import { FetchProjectGraphService } from '../fetch-project-graph-service';
import { ProjectGraphService } from '../interfaces';
import { LocalProjectGraphService } from '../local-project-graph-service';
import { MockProjectGraphService } from '../mock-project-graph-service';
let projectGraphService: ProjectGraphService;
export function useProjectGraphDataService() {
if (projectGraphService === undefined) {
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();
}
}
}
return projectGraphService;
}

View File

@ -1,5 +1,5 @@
// nx-ignore-next-line
import { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
export interface ProjectGraphList {
id: string;
@ -25,9 +25,3 @@ export interface AppConfig {
projectGraphs: ProjectGraphList[];
defaultProjectGraph: string;
}
export const DEFAULT_CONFIG: AppConfig = {
showDebugger: false,
projectGraphs: [],
defaultProjectGraph: null,
};

View File

@ -1,6 +1,6 @@
// nx-ignore-next-line
import { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { ProjectGraphService } from './models';
import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { ProjectGraphService } from './interfaces';
export class LocalProjectGraphService implements ProjectGraphService {
async getHash(): Promise<string> {

View File

@ -1,6 +1,6 @@
import { assign } from '@xstate/immer';
import { Machine, send, spawn } from 'xstate';
import { useGraphService } from '../graph.service';
import { getGraphService } from './graph.service';
import { customSelectedStateConfig } from './custom-selected.state';
import { focusedStateConfig } from './focused.state';
import {
@ -27,16 +27,22 @@ export const initialContext: DepGraphContext = {
appsDir: '',
},
graph: null,
lastPerfReport: {
numEdges: 0,
numNodes: 0,
renderTime: 0,
},
};
const graphActor = (callback, receive) => {
const graphService = useGraphService();
const graphService = getGraphService();
receive((e) => {
const selectedProjectNames = graphService.handleEvent(e);
const { selectedProjectNames, perfReport } = graphService.handleEvent(e);
callback({
type: 'setSelectedProjectsFromGraph',
selectedProjectNames,
perfReport,
});
});
};
@ -80,6 +86,7 @@ export const depGraphMachine = Machine<
setSelectedProjectsFromGraph: {
actions: assign((ctx, event) => {
ctx.selectedProjects = event.selectedProjectNames;
ctx.lastPerfReport = event.perfReport;
}),
},
selectProject: {
@ -163,9 +170,11 @@ export const depGraphMachine = Machine<
ctx.groupByFolder = event.groupByFolder;
}),
incrementSearchDepth: assign((ctx) => {
ctx.searchDepthEnabled = true;
ctx.searchDepth = ctx.searchDepth + 1;
}),
decrementSearchDepth: assign((ctx) => {
ctx.searchDepthEnabled = true;
ctx.searchDepth = ctx.searchDepth > 1 ? ctx.searchDepth - 1 : 1;
}),
setSearchDepthEnabled: assign((ctx, event) => {

View File

@ -1,13 +1,9 @@
import { from } from 'rxjs';
import { map, shareReplay } from 'rxjs/operators';
import { interpret, Interpreter, Typestate } from 'xstate';
import { depGraphMachine } from './dep-graph.machine';
import {
DepGraphContext,
DepGraphUIEvents,
DepGraphSend,
DepGraphStateObservable,
DepGraphSchema,
DepGraphUIEvents,
} from './interfaces';
let depGraphService: Interpreter<
@ -17,23 +13,13 @@ let depGraphService: Interpreter<
Typestate<DepGraphContext>
>;
let depGraphState$: DepGraphStateObservable;
export function useDepGraphService(): [DepGraphStateObservable, DepGraphSend] {
export function getDepGraphService() {
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];
return depGraphService;
}

View File

@ -1,4 +1,5 @@
import { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
// nx-ignore-next-line
import type { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
import { depGraphMachine } from './dep-graph.machine';
import { interpret } from 'xstate';
@ -6,32 +7,44 @@ export const mockProjects: ProjectGraphNode[] = [
{
name: 'app1',
type: 'app',
data: {},
data: {
root: 'apps/app1',
},
},
{
name: 'app2',
type: 'app',
data: {},
data: {
root: 'apps/app2',
},
},
{
name: 'ui-lib',
type: 'lib',
data: {},
data: {
root: 'libs/ui-lib',
},
},
{
name: 'feature-lib1',
type: 'lib',
data: {},
data: {
root: 'libs/feature/lib1',
},
},
{
name: 'feature-lib2',
type: 'lib',
data: {},
data: {
root: 'libs/feature/lib2',
},
},
{
name: 'auth-lib',
type: 'lib',
data: {},
data: {
root: 'libs/auth-lib',
},
},
];
@ -282,70 +295,9 @@ describe('dep-graph machine', () => {
expect(result.value).toEqual('unselected');
expect(result.context.selectedProjects).toEqual([]);
});
it('should not decrement search depth below 1', () => {
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.searchDepth).toEqual(1);
result = depGraphMachine.transition(result, {
type: 'incrementSearchDepth',
});
expect(result.context.searchDepth).toEqual(2);
result = depGraphMachine.transition(result, {
type: 'incrementSearchDepth',
});
result = depGraphMachine.transition(result, {
type: 'incrementSearchDepth',
});
expect(result.context.searchDepth).toEqual(4);
result = depGraphMachine.transition(result, {
type: 'decrementSearchDepth',
});
result = depGraphMachine.transition(result, {
type: 'decrementSearchDepth',
});
expect(result.context.searchDepth).toEqual(2);
result = depGraphMachine.transition(result, {
type: 'decrementSearchDepth',
});
expect(result.context.searchDepth).toEqual(1);
result = depGraphMachine.transition(result, {
type: 'decrementSearchDepth',
});
expect(result.context.searchDepth).toEqual(1);
result = depGraphMachine.transition(result, {
type: 'decrementSearchDepth',
});
expect(result.context.searchDepth).toEqual(1);
});
});
describe('filtering projects by text', () => {
describe('search depth', () => {
it('should not decrement search depth below 1', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
type: 'initGraph',
@ -406,5 +358,41 @@ describe('dep-graph machine', () => {
expect(result.context.searchDepth).toEqual(1);
});
it('should activate search depth if incremented or decremented', () => {
let result = depGraphMachine.transition(depGraphMachine.initialState, {
type: 'initGraph',
projects: mockProjects,
dependencies: mockDependencies,
affectedProjects: [],
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
});
result = depGraphMachine.transition(result, {
type: 'setSearchDepthEnabled',
searchDepthEnabled: false,
});
expect(result.context.searchDepthEnabled).toBe(false);
result = depGraphMachine.transition(result, {
type: 'incrementSearchDepth',
});
expect(result.context.searchDepthEnabled).toBe(true);
result = depGraphMachine.transition(result, {
type: 'setSearchDepthEnabled',
searchDepthEnabled: false,
});
expect(result.context.searchDepthEnabled).toBe(false);
result = depGraphMachine.transition(result, {
type: 'decrementSearchDepth',
});
expect(result.context.searchDepthEnabled).toBe(true);
});
});
});

View File

@ -1,6 +1,5 @@
import { assign } from '@xstate/immer';
import { send } from 'xstate';
import { selectProjectsForFocusedProject } from '../util';
import { DepGraphStateNodeConfig } from './interfaces';
export const focusedStateConfig: DepGraphStateNodeConfig = {

View File

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

View File

@ -1,35 +1,27 @@
// nx-ignore-next-line
import type { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
import type { VirtualElement } from '@popperjs/core';
import * as cy from 'cytoscape';
import * as cytoscapeDagre from 'cytoscape-dagre';
import * as popper from 'cytoscape-popper';
import { Subject } from 'rxjs';
import { default as cy } from 'cytoscape';
import { default as cytoscapeDagre } from 'cytoscape-dagre';
import { default as popper } from 'cytoscape-popper';
import type { Instance } from 'tippy.js';
import { GraphRenderEvents } from './machines/interfaces';
import { ProjectNodeToolTip } from './project-node-tooltip';
import { edgeStyles, nodeStyles } from './styles-graph';
import { GraphTooltipService } from './tooltip-service';
import { ProjectNodeToolTip } from '../project-node-tooltip';
import { edgeStyles, nodeStyles } from '../styles-graph';
import { GraphTooltipService } from '../tooltip-service';
import {
CytoscapeDagreConfig,
ParentNode,
ProjectEdge,
ProjectNode,
} from './util-cytoscape';
} from '../util-cytoscape';
import { GraphRenderEvents, GraphPerfReport } from './interfaces';
export interface GraphPerfReport {
renderTime: number;
numNodes: number;
numEdges: number;
}
export class GraphService {
private traversalGraph: cy.Core;
private renderGraph: cy.Core;
private openTooltip: Instance = null;
private renderTimesSubject = new Subject<GraphPerfReport>();
renderTimes$ = this.renderTimesSubject.asObservable();
constructor(
private tooltipService: GraphTooltipService,
private containerId: string
@ -38,17 +30,18 @@ export class GraphService {
cy.use(popper);
}
handleEvent(event: GraphRenderEvents): string[] {
handleEvent(event: GraphRenderEvents): {
selectedProjectNames: string[];
perfReport: GraphPerfReport;
} {
const time = Date.now();
if (
this.renderGraph &&
event.type !== 'notifyGraphFocusProject' &&
event.type !== 'notifyGraphUpdateGraph'
) {
if (this.renderGraph && event.type !== 'notifyGraphUpdateGraph') {
this.renderGraph.nodes('.focused').removeClass('focused');
}
this.tooltipService.hideAll();
switch (event.type) {
case 'notifyGraphInitGraph':
this.initGraph(
@ -104,40 +97,43 @@ export class GraphService {
break;
}
let visibleProjects: string[] = [];
let selectedProjectNames: string[] = [];
let perfReport: GraphPerfReport = {
numEdges: 0,
numNodes: 0,
renderTime: 0,
};
if (this.renderGraph) {
this.renderGraph
.elements()
.sort((a, b) => a.id().localeCompare(b.id()))
.layout(<CytoscapeDagreConfig>{
.layout({
name: 'dagre',
nodeDimensionsIncludeLabels: true,
rankSep: 75,
rankDir: 'TB',
edgeSep: 50,
ranker: 'network-simplex',
})
} as CytoscapeDagreConfig)
.run();
this.renderGraph.fit().center().resize();
visibleProjects = this.renderGraph
selectedProjectNames = this.renderGraph
.nodes('[type!="dir"]')
.map((node) => node.id());
const renderTime = Date.now() - time;
const report: GraphPerfReport = {
perfReport = {
renderTime,
numNodes: this.renderGraph.nodes().length,
numEdges: this.renderGraph.edges().length,
};
this.renderTimesSubject.next(report);
}
return visibleProjects;
return { selectedProjectNames, perfReport };
}
setShownProjects(selectedProjectNames: string[]) {
@ -178,9 +174,13 @@ export class GraphService {
this.renderGraph?.nodes() ?? this.traversalGraph.collection();
const nodeToHide = this.renderGraph.$id(projectName);
const nodesToAdd = currentNodes.difference(nodeToHide);
const nodesToAdd = currentNodes
.difference(nodeToHide)
.difference(nodeToHide.ancestors());
const ancestorsToAdd = nodesToAdd.ancestors();
const nodesToRender = nodesToAdd.union(ancestorsToAdd);
let nodesToRender = nodesToAdd.union(ancestorsToAdd);
const edgesToRender = nodesToRender.edgesTo(nodesToRender);
this.transferToRenderGraph(nodesToRender.union(edgesToRender));
@ -229,10 +229,15 @@ export class GraphService {
includePath: boolean,
searchDepth: number = -1
) {
if (search === '') {
this.transferToRenderGraph(this.traversalGraph.collection());
} else {
const split = search.split(',');
let filteredProjects = this.traversalGraph.nodes().filter((node) => {
return split.findIndex((splitItem) => node.id().includes(splitItem)) > -1;
return (
split.findIndex((splitItem) => node.id().includes(splitItem)) > -1
);
});
if (includePath) {
@ -246,6 +251,7 @@ export class GraphService {
this.transferToRenderGraph(filteredProjects.union(edgesToRender));
}
}
private transferToRenderGraph(elements: cy.Collection) {
let currentFocusedProjectName;
@ -281,10 +287,6 @@ export class GraphService {
this.listenForProjectNodeHovers();
}
getImage() {
return this.renderGraph.png({ bg: '#fff', full: true });
}
private includeProjectsByDepth(
projects: cy.NodeCollection | cy.NodeSingular,
depth: number = -1
@ -463,4 +465,8 @@ export class GraphService {
.removeClass('highlight');
});
}
getImage() {
return this.renderGraph.png({ bg: '#fff', full: true });
}
}

View File

@ -1,7 +1,13 @@
import { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
// nx-ignore-next-line
import type { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
import { Observable } from 'rxjs';
import { ActionObject, ActorRef, StateNodeConfig, StateValue } from 'xstate';
import { GraphService } from '../graph';
import {
ActionObject,
ActorRef,
State,
StateNodeConfig,
StateValue,
} from 'xstate';
// The hierarchical (recursive) schema for the states
export interface DepGraphSchema {
@ -14,10 +20,19 @@ export interface DepGraphSchema {
};
}
export interface GraphPerfReport {
renderTime: number;
numNodes: number;
numEdges: number;
}
// The events that the machine handles
export type DepGraphUIEvents =
| { type: 'setSelectedProjectsFromGraph'; selectedProjectNames: string[] }
| {
type: 'setSelectedProjectsFromGraph';
selectedProjectNames: string[];
perfReport: GraphPerfReport;
}
| { type: 'selectProject'; projectName: string }
| { type: 'deselectProject'; projectName: string }
| { type: 'selectAll' }
@ -122,6 +137,7 @@ export interface DepGraphContext {
appsDir: string;
};
graph: ActorRef<GraphRenderEvents>;
lastPerfReport: GraphPerfReport;
}
export type DepGraphStateNodeConfig = StateNodeConfig<
@ -138,3 +154,13 @@ export type DepGraphStateObservable = Observable<{
value: StateValue;
context: DepGraphContext;
}>;
export type DepGraphState = State<
DepGraphContext,
DepGraphUIEvents,
any,
{
value: any;
context: DepGraphContext;
}
>;

View File

@ -0,0 +1,43 @@
import type { ProjectGraphNode } from '@nrwl/devkit';
import { DepGraphSelector } from '../hooks/use-dep-graph-selector';
import { WorkspaceLayout } from '../interfaces';
import { GraphPerfReport } from './interfaces';
export const allProjectsSelector: DepGraphSelector<ProjectGraphNode[]> = (
state
) => state.context.projects;
export const workspaceLayoutSelector: DepGraphSelector<WorkspaceLayout> = (
state
) => state.context.workspaceLayout;
export const selectedProjectNamesSelector: DepGraphSelector<string[]> = (
state
) => state.context.selectedProjects;
export const projectIsSelectedSelector: DepGraphSelector<boolean> = (state) =>
state.context.selectedProjects.length > 0;
export const lastPerfReportSelector: DepGraphSelector<GraphPerfReport> = (
state
) => state.context.lastPerfReport;
export const focusedProjectNameSelector: DepGraphSelector<string> = (state) =>
state.context.focusedProject;
export const searchDepthSelector: DepGraphSelector<{
searchDepth: number;
searchDepthEnabled: boolean;
}> = (state) => ({
searchDepth: state.context.searchDepth,
searchDepthEnabled: state.context.searchDepthEnabled,
});
export const includePathSelector: DepGraphSelector<boolean> = (state) =>
state.context.includePath;
export const textFilterSelector: DepGraphSelector<string> = (state) =>
state.context.textFilter;
export const hasAffectedProjectsSelector: DepGraphSelector<boolean> = (state) =>
state.context.affectedProjects.length > 0;

View File

@ -1,6 +1,5 @@
import { assign } from '@xstate/immer';
import { send } from 'xstate';
import { useGraphService } from '../graph.service';
import { DepGraphStateNodeConfig } from './interfaces';
export const unselectedStateConfig: DepGraphStateNodeConfig = {

View File

@ -1,7 +1,7 @@
import { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
import type { ProjectGraphDependency, ProjectGraphNode } from '@nrwl/devkit';
// nx-ignore-next-line
import { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { ProjectGraphService } from '../app/models';
import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { ProjectGraphService } from '../app/interfaces';
export class MockProjectGraphService implements ProjectGraphService {
private response: DepGraphClientResponse = {

View File

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

View File

@ -0,0 +1,190 @@
// nx-ignore-next-line
import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import Tippy from '@tippyjs/react';
import { useEffect, useState } from 'react';
import DebuggerPanel from './debugger-panel';
import { useDepGraphService } from './hooks/use-dep-graph';
import { useDepGraphSelector } from './hooks/use-dep-graph-selector';
import { useEnvironmentConfig } from './hooks/use-environment-config';
import { useIntervalWhen } from './hooks/use-interval-when';
import { useProjectGraphDataService } from './hooks/use-project-graph-data-service';
import { getGraphService } from './machines/graph.service';
import {
lastPerfReportSelector,
projectIsSelectedSelector,
} from './machines/selectors';
import Sidebar from './sidebar/sidebar';
export function Shell() {
const depGraphService = useDepGraphService();
const projectGraphService = useProjectGraphDataService();
const environment = useEnvironmentConfig();
const lastPerfReport = useDepGraphSelector(lastPerfReportSelector);
const projectIsSelected = useDepGraphSelector(projectIsSelectedSelector);
const [selectedProjectId, setSelectedProjectId] = useState<string>(
environment.appConfig.defaultProjectGraph
);
function projectChange(projectGraphId: string) {
setSelectedProjectId(projectGraphId);
}
useEffect(() => {
const { appConfig } = environment;
const projectInfo = appConfig.projectGraphs.find(
(graph) => graph.id === selectedProjectId
);
const fetchProjectGraph = async () => {
const project: DepGraphClientResponse =
await projectGraphService.getProjectGraph(projectInfo.url);
const workspaceLayout = project?.layout;
depGraphService.send({
type: 'initGraph',
projects: project.projects,
dependencies: project.dependencies,
affectedProjects: project.affected,
workspaceLayout: workspaceLayout,
});
if (environment.focusedProject) {
depGraphService.send({
type: 'focusProject',
projectName: environment.focusedProject,
});
}
if (environment.groupByFolder) {
depGraphService.send({
type: 'setGroupByFolder',
groupByFolder: true,
});
}
};
fetchProjectGraph();
}, [selectedProjectId, environment, depGraphService, projectGraphService]);
useIntervalWhen(
() => {
const projectInfo = environment.appConfig.projectGraphs.find(
(graph) => graph.id === selectedProjectId
);
const fetchProjectGraph = async () => {
const project: DepGraphClientResponse =
await projectGraphService.getProjectGraph(projectInfo.url);
depGraphService.send({
type: 'updateGraph',
projects: project.projects,
dependencies: project.dependencies,
});
};
fetchProjectGraph();
},
5000,
environment.watch
);
function downloadImage() {
const graph = getGraphService();
const data = graph.getImage();
let downloadLink = document.createElement('a');
downloadLink.href = data;
downloadLink.download = 'graph.png';
// this is necessary as link.click() does not work on the latest firefox
downloadLink.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
})
);
}
return (
<>
<Sidebar></Sidebar>
<div id="main-content" className="flex-grow overflow-hidden">
{environment.appConfig.showDebugger ? (
<DebuggerPanel
projectGraphs={environment.appConfig.projectGraphs}
selectedProjectGraph={selectedProjectId}
lastPerfReport={lastPerfReport}
projectGraphChange={projectChange}
></DebuggerPanel>
) : null}
{!projectIsSelected ? (
<div id="no-projects-chosen" className="flex text-gray-700">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 mr-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M11 15l-3-3m0 0l3-3m-3 3h8M3 12a9 9 0 1118 0 9 9 0 01-18 0z"
/>
</svg>
<h4>Please select projects in the sidebar.</h4>
</div>
) : null}
<div id="graph-container">
<div id="cytoscape-graph"></div>
<Tippy content="Download Graph as PNG" placement="right" theme="nx">
<button
type="button"
className={`
fixed
z-50
bottom-4
right-4
w-16
h-16
rounded-full
bg-green-nx-base
shadow-sm
text-white
block
transition
duration-300
transform
${!projectIsSelected ? 'opacity-0' : ''}
`}
data-cy="downloadImageButton"
onClick={downloadImage}
>
<svg
height="24"
width="24"
className="absolute top-1/2 left-1/2 -mt-3 -ml-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
></path>
</svg>
</button>
</Tippy>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,59 @@
import { memo } from 'react';
export interface FocusedProjectPanelProps {
focusedProject: string;
resetFocus: () => void;
}
export const FocusedProjectPanel = memo(
({ focusedProject, resetFocus }: FocusedProjectPanelProps) => {
return (
<div className="mt-10 px-4">
<div
className="p-2 shadow-sm bg-green-nx-base text-gray-50 border border-gray-200 rounded-md flex items-center group relative cursor-pointer overflow-hidden"
data-cy="unfocusButton"
onClick={() => resetFocus()}
>
<p className="truncate transition duration-200 ease-in-out group-hover:opacity-60">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 inline -mt-1 mr-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span id="focused-project-name">Focused on {focusedProject}</span>
</p>
<div className="absolute right-2 flex transition-all translate-x-32 transition duration-200 ease-in-out group-hover:translate-x-0 pl-2 rounded-md text-gray-700 items-center text-sm font-medium bg-white shadow-sm ring-1 ring-gray-500">
Reset
<span className="p-1 rounded-md">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</span>
</div>
</div>
</div>
);
}
);
export default FocusedProjectPanel;

View File

@ -0,0 +1,39 @@
import { memo } from 'react';
export interface DisplayOptionsPanelProps {
groupByFolderChanged: (checked: boolean) => void;
}
export const GroupByFolderPanel = memo(
({ groupByFolderChanged }: DisplayOptionsPanelProps) => {
return (
<div className="mt-8 px-4">
<div className="flex items-start">
<div className="flex items-center h-5">
<input
id="displayOptions"
name="displayOptions"
value="groupByFolder"
type="checkbox"
className="h-4 w-4 border-gray-300 rounded"
onChange={(event) => groupByFolderChanged(event.target.checked)}
></input>
</div>
<div className="ml-3 text-sm">
<label
htmlFor="displayOptions"
className="cursor-pointer font-medium text-gray-700"
>
Group by folder
</label>
<p className="text-gray-500">
Visually arrange libraries by folders with different colors.
</p>
</div>
</div>
</div>
);
}
);
export default GroupByFolderPanel;

View File

@ -0,0 +1,280 @@
import type { ProjectGraphNode } from '@nrwl/devkit';
import { useDepGraphService } from '../hooks/use-dep-graph';
import { useDepGraphSelector } from '../hooks/use-dep-graph-selector';
import {
allProjectsSelector,
selectedProjectNamesSelector,
workspaceLayoutSelector,
} from '../machines/selectors';
import { parseParentDirectoriesFromPilePath } from '../util';
function getProjectsByType(type: string, projects: ProjectGraphNode[]) {
return projects
.filter((project) => project.type === type)
.sort((a, b) => a.name.localeCompare(b.name));
}
interface SidebarProject {
projectGraphNode: ProjectGraphNode;
isSelected: boolean;
}
type DirectoryProjectRecord = Record<string, SidebarProject[]>;
function groupProjectsByDirectory(
projects: ProjectGraphNode[],
selectedProjects: string[],
workspaceLayout: { appsDir: string; libsDir: string }
): DirectoryProjectRecord {
let groups = {};
projects.forEach((project) => {
const workspaceRoot =
project.type === 'app' || project.type === 'e2e'
? workspaceLayout.appsDir
: workspaceLayout.libsDir;
const directories = parseParentDirectoriesFromPilePath(
project.data.root,
workspaceRoot
);
const directory = directories.join('/');
if (!groups.hasOwnProperty(directory)) {
groups[directory] = [];
}
groups[directory].push({
projectGraphNode: project,
isSelected: selectedProjects.includes(project.name),
});
});
return groups;
}
function ProjectListItem({
project,
toggleProject,
focusProject,
}: {
project: SidebarProject;
toggleProject: (projectId: string, currentlySelected: boolean) => void;
focusProject: (projectId: string) => void;
}) {
return (
<li className="text-xs text-gray-600 block cursor-default select-none relative py-1 pl-3 pr-9">
<div className="flex items-center">
<button
type="button"
className="flex rounded-md"
title="Focus on this library"
onClick={() => focusProject(project.projectGraphNode.name)}
>
<span className="p-1 rounded-md flex items-center font-medium bg-white transition hover:bg-gray-50 shadow-sm ring-1 ring-gray-200">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2h-1.528A6 6 0 004 9.528V4z" />
<path
fillRule="evenodd"
d="M8 10a4 4 0 00-3.446 6.032l-1.261 1.26a1 1 0 101.414 1.415l1.261-1.261A4 4 0 108 10zm-2 4a2 2 0 114 0 2 2 0 01-4 0z"
clipRule="evenodd"
/>
</svg>
</span>
</button>
<label
className="font-mono font-normal ml-3 p-2 transition hover:bg-gray-50 cursor-pointer block rounded-md truncate w-full"
data-project={project.projectGraphNode.name}
data-active={project.isSelected}
onClick={() =>
toggleProject(project.projectGraphNode.name, project.isSelected)
}
>
{project.projectGraphNode.name}
</label>
</div>
{project.isSelected ? (
<span
title="This library is visible"
className="text-green-nx-base absolute inset-y-0 right-0 flex items-center cursor-pointer"
onClick={() =>
toggleProject(project.projectGraphNode.name, project.isSelected)
}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</span>
) : null}
</li>
);
}
function SubProjectList({
headerText,
projects,
selectProject,
deselectProject,
focusProject,
}: {
headerText: string;
projects: SidebarProject[];
selectProject: (projectName: string) => void;
deselectProject: (projectName: string) => void;
focusProject: (projectName: string) => void;
}) {
let sortedProjects = [...projects];
sortedProjects.sort((a, b) => {
return a.projectGraphNode.name.localeCompare(b.projectGraphNode.name);
});
function toggleProject(projectName: string, currentlySelected: boolean) {
if (currentlySelected) {
deselectProject(projectName);
} else {
selectProject(projectName);
}
}
return (
<>
<h3 className="mt-4 py-2 uppercase tracking-wide font-semibold text-sm lg:text-xs text-gray-900 cursor-text">
{headerText}
</h3>
<ul className="mt-2 -ml-3">
{sortedProjects.map((project) => {
return (
<ProjectListItem
key={project.projectGraphNode.name}
project={project}
toggleProject={toggleProject}
focusProject={focusProject}
></ProjectListItem>
);
})}
</ul>
</>
);
}
export function ProjectList() {
const depGraphService = useDepGraphService();
function deselectProject(projectName: string) {
depGraphService.send({ type: 'deselectProject', projectName });
}
function selectProject(projectName: string) {
depGraphService.send({ type: 'selectProject', projectName });
}
function focusProject(projectName: string) {
depGraphService.send({ type: 'focusProject', projectName });
}
const projects = useDepGraphSelector(allProjectsSelector);
const workspaceLayout = useDepGraphSelector(workspaceLayoutSelector);
const selectedProjects = useDepGraphSelector(selectedProjectNamesSelector);
const appProjects = getProjectsByType('app', projects);
const libProjects = getProjectsByType('lib', projects);
const e2eProjects = getProjectsByType('e2e', projects);
const appDirectoryGroups = groupProjectsByDirectory(
appProjects,
selectedProjects,
workspaceLayout
);
const libDirectoryGroups = groupProjectsByDirectory(
libProjects,
selectedProjects,
workspaceLayout
);
const e2eDirectoryGroups = groupProjectsByDirectory(
e2eProjects,
selectedProjects,
workspaceLayout
);
const sortedAppDirectories = Object.keys(appDirectoryGroups).sort();
const sortedLibDirectories = Object.keys(libDirectoryGroups).sort();
const sortedE2EDirectories = Object.keys(e2eDirectoryGroups).sort();
return (
<div id="project-lists" className="mt-8 px-4 border-t border-gray-200">
<h2 className="mt-8 text-lg font-bold border-b border-gray-50 border-solid">
app projects
</h2>
{sortedAppDirectories.map((directoryName) => {
return (
<SubProjectList
key={'app-' + directoryName}
headerText={directoryName}
projects={appDirectoryGroups[directoryName]}
deselectProject={deselectProject}
selectProject={selectProject}
focusProject={focusProject}
></SubProjectList>
);
})}
<h2 className="mt-8 text-lg font-bold border-b border-gray-50 border-solid">
e2e projects
</h2>
{sortedE2EDirectories.map((directoryName) => {
return (
<SubProjectList
key={'e2e-' + directoryName}
headerText={directoryName}
projects={e2eDirectoryGroups[directoryName]}
deselectProject={deselectProject}
selectProject={selectProject}
focusProject={focusProject}
></SubProjectList>
);
})}
<h2 className="mt-8 text-lg font-bold border-b border-gray-50 border-solid">
lib projects
</h2>
{sortedLibDirectories.map((directoryName) => {
return (
<SubProjectList
key={'lib-' + directoryName}
headerText={directoryName}
projects={libDirectoryGroups[directoryName]}
deselectProject={deselectProject}
selectProject={selectProject}
focusProject={focusProject}
></SubProjectList>
);
})}
</div>
);
}
export default ProjectList;

View File

@ -0,0 +1,102 @@
import { memo } from 'react';
export interface SearchDepthProps {
searchDepth: number;
searchDepthEnabled: boolean;
searchDepthFilterEnabledChange: (checked: boolean) => void;
decrementDepthFilter: () => void;
incrementDepthFilter: () => void;
}
export const SearchDepth = memo(
({
searchDepth,
searchDepthEnabled,
searchDepthFilterEnabledChange,
decrementDepthFilter,
incrementDepthFilter,
}: SearchDepthProps) => {
return (
<div className="mt-4 px-4">
<div className="mt-4 flex items-start">
<div className="flex items-center h-5">
<input
id="depthFilter"
name="depthFilter"
value="depthFilterActivated"
type="checkbox"
className="h-4 w-4 border-gray-300 rounded"
checked={searchDepthEnabled}
onChange={(event) =>
searchDepthFilterEnabledChange(event.target.checked)
}
></input>
</div>
<div className="ml-3 text-sm">
<label
htmlFor="depthFilter"
className="cursor-pointer font-medium text-gray-700"
>
Activate proximity
</label>
<p className="text-gray-500">
Explore connected libraries step by step.
</p>
</div>
</div>
<div className="mt-3 px-10">
<div className="flex rounded-md shadow-sm text-gray-500">
<button
title="Remove ancestor level"
className="inline-flex items-center py-2 px-4 rounded-l-md border border-gray-300 bg-gray-50 text-gray-500 hover:bg-gray-100"
onClick={decrementDepthFilter}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M20 12H4"
/>
</svg>
</button>
<span
id="depthFilterValue"
className="p-1.5 bg-white flex-1 block w-full rounded-none border-t border-b border-gray-300 text-center font-mono"
>
{searchDepth}
</span>
<button
title="Add ancestor level"
className="inline-flex items-center py-2 px-4 rounded-r-md border border-gray-300 bg-gray-50 text-gray-500 hover:bg-gray-100"
onClick={incrementDepthFilter}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
</button>
</div>
</div>
</div>
);
}
);
export default SearchDepth;

View File

@ -0,0 +1,100 @@
import { memo } from 'react';
export interface ShowHideAllProjectsProps {
showAllProjects: () => void;
hideAllProjects: () => void;
showAffectedProjects: () => void;
hasAffectedProjects: boolean;
}
export const ShowHideAllProjects = memo(
({
showAllProjects,
hideAllProjects,
showAffectedProjects,
hasAffectedProjects: affectedProjects,
}: ShowHideAllProjectsProps) => {
return (
<div className="mt-8 px-4">
<button
onClick={showAllProjects}
type="button"
className="w-full flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
data-cy="selectAllButton"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="-ml-1 mr-2 h-5 w-5 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
Show all projects
</button>
{affectedProjects ? (
<button
onClick={showAffectedProjects}
type="button"
className="mt-3 w-full flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-500 bg-white hover:bg-red-50"
data-cy="affectedButton"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="-ml-1 mr-2 h-5 w-5 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
Show affected projects
</button>
) : null}
<button
onClick={hideAllProjects}
type="button"
className="mt-3 w-full flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
data-cy="deselectAllButton"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="-ml-1 mr-2 h-5 w-5 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
/>
</svg>
Hide all projects
</button>
</div>
);
}
);
export default ShowHideAllProjects;

View File

@ -0,0 +1,181 @@
import { useCallback } from 'react';
import { useDepGraphService } from '../hooks/use-dep-graph';
import { useDepGraphSelector } from '../hooks/use-dep-graph-selector';
import {
focusedProjectNameSelector,
hasAffectedProjectsSelector,
includePathSelector,
searchDepthSelector,
textFilterSelector,
} from '../machines/selectors';
import FocusedProjectPanel from './focused-project-panel';
import GroupByFolderPanel from './group-by-folder-panel';
import ProjectList from './project-list';
import SearchDepth from './search-depth';
import ShowHideProjects from './show-hide-projects';
import TextFilterPanel from './text-filter-panel';
export function Sidebar() {
const depGraphService = useDepGraphService();
const focusedProject = useDepGraphSelector(focusedProjectNameSelector);
const searchDepthInfo = useDepGraphSelector(searchDepthSelector);
const includePath = useDepGraphSelector(includePathSelector);
const textFilter = useDepGraphSelector(textFilterSelector);
const hasAffectedProjects = useDepGraphSelector(hasAffectedProjectsSelector);
function resetFocus() {
depGraphService.send({ type: 'unfocusProject' });
}
function showAllProjects() {
depGraphService.send({ type: 'selectAll' });
}
function hideAllProjects() {
depGraphService.send({ type: 'deselectAll' });
}
function showAffectedProjects() {
depGraphService.send({ type: 'selectAffected' });
}
function searchDepthFilterEnabledChange(checked: boolean) {
depGraphService.send({
type: 'setSearchDepthEnabled',
searchDepthEnabled: checked,
});
}
function groupByFolderChanged(checked: boolean) {
depGraphService.send({ type: 'setGroupByFolder', groupByFolder: checked });
}
function incrementDepthFilter() {
depGraphService.send({ type: 'incrementSearchDepth' });
}
function decrementDepthFilter() {
depGraphService.send({ type: 'decrementSearchDepth' });
}
function resetTextFilter() {
depGraphService.send({ type: 'clearTextFilter' });
}
function includeLibsInPathChange() {
depGraphService.send({
type: 'setIncludeProjectsByPath',
includeProjectsByPath: !includePath,
});
}
const updateTextFilter = useCallback(
(textFilter: string) => {
depGraphService.send({ type: 'filterByText', search: textFilter });
},
[depGraphService]
);
return (
<div
className="
flex flex-col
h-full
overflow-scroll
w-72
pb-10
shadow-lg
ring-1 ring-gray-400 ring-opacity-10
relative
"
id="sidebar"
>
<div className="bg-blue-nx-base">
<div className="flex items-center justify-start mx-4 my-5 text-white">
<svg
className="h-10 w-auto"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>Nx</title>
<path d="M11.987 14.138l-3.132 4.923-5.193-8.427-.012 8.822H0V4.544h3.691l5.247 8.833.005-3.998 3.044 4.759zm.601-5.761c.024-.048 0-3.784.008-3.833h-3.65c.002.059-.005 3.776-.003 3.833h3.645zm5.634 4.134a2.061 2.061 0 0 0-1.969 1.336 1.963 1.963 0 0 1 2.343-.739c.396.161.917.422 1.33.283a2.1 2.1 0 0 0-1.704-.88zm3.39 1.061c-.375-.13-.8-.277-1.109-.681-.06-.08-.116-.17-.176-.265a2.143 2.143 0 0 0-.533-.642c-.294-.216-.68-.322-1.18-.322a2.482 2.482 0 0 0-2.294 1.536 2.325 2.325 0 0 1 4.002.388.75.75 0 0 0 .836.334c.493-.105.46.36 1.203.518v-.133c-.003-.446-.246-.55-.75-.733zm2.024 1.266a.723.723 0 0 0 .347-.638c-.01-2.957-2.41-5.487-5.37-5.487a5.364 5.364 0 0 0-4.487 2.418c-.01-.026-1.522-2.39-1.538-2.418H8.943l3.463 5.423-3.379 5.32h3.54l1.54-2.366 1.568 2.366h3.541l-3.21-5.052a.7.7 0 0 1-.084-.32 2.69 2.69 0 0 1 2.69-2.691h.001c1.488 0 1.736.89 2.057 1.308.634.826 1.9.464 1.9 1.541a.707.707 0 0 0 1.066.596zm.35.133c-.173.372-.56.338-.755.639-.176.271.114.412.114.412s.337.156.538-.311c.104-.231.14-.488.103-.74z" />
</svg>
<span className="ml-4 text-xl font-medium"> Dependency Graph </span>
</div>
</div>
<a
id="help"
className="
mt-3
px-4
text-xs text-gray-500
flex
items-center
cursor-pointer
hover:underline
"
href="https://nx.dev/structure/dependency-graph"
rel="nofollow noreferrer"
target="_blank"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Analyse and visualize your workspace.
</a>
{focusedProject ? (
<FocusedProjectPanel
focusedProject={focusedProject}
resetFocus={resetFocus}
></FocusedProjectPanel>
) : null}
<TextFilterPanel
includePath={includePath}
resetTextFilter={resetTextFilter}
textFilter={textFilter}
toggleIncludeLibsInPathChange={includeLibsInPathChange}
updateTextFilter={updateTextFilter}
></TextFilterPanel>
<div>
<ShowHideProjects
hideAllProjects={hideAllProjects}
showAllProjects={showAllProjects}
showAffectedProjects={showAffectedProjects}
hasAffectedProjects={hasAffectedProjects}
></ShowHideProjects>
<GroupByFolderPanel
groupByFolderChanged={groupByFolderChanged}
></GroupByFolderPanel>
<SearchDepth
searchDepth={searchDepthInfo.searchDepth}
searchDepthEnabled={searchDepthInfo.searchDepthEnabled}
searchDepthFilterEnabledChange={searchDepthFilterEnabledChange}
incrementDepthFilter={incrementDepthFilter}
decrementDepthFilter={decrementDepthFilter}
></SearchDepth>
</div>
<ProjectList></ProjectList>
</div>
);
}
export default Sidebar;

View File

@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
import { useDebounce } from '../hooks/use-debounce';
export interface TextFilterPanelProps {
textFilter: string;
resetTextFilter: () => void;
updateTextFilter: (textFilter: string) => void;
toggleIncludeLibsInPathChange: () => void;
includePath: boolean;
}
export function TextFilterPanel({
textFilter,
resetTextFilter,
updateTextFilter,
toggleIncludeLibsInPathChange,
includePath,
}: TextFilterPanelProps) {
const [currentTextFilter, setCurrentTextFilter] = useState('');
const debouncedTextFilter = useDebounce(currentTextFilter, 500);
function onTextFilterKeyUp(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter') {
updateTextFilter(event.currentTarget.value);
}
}
function onTextInputChange(change: string) {
if (change === '') {
setCurrentTextFilter('');
resetTextFilter();
} else {
setCurrentTextFilter(change);
}
}
function resetClicked() {
setCurrentTextFilter('');
resetTextFilter();
}
useEffect(() => {
if (debouncedTextFilter !== '') {
updateTextFilter(debouncedTextFilter);
}
}, [debouncedTextFilter, updateTextFilter]);
return (
<div>
<div className="mt-10 px-4">
<form
className="flex rounded-md shadow-sm relative"
onSubmit={(event) => event.preventDefault()}
>
<span className="inline-flex items-center p-2 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
</span>
<input
type="text"
className="p-1.5 bg-white text-gray-600 flex-1 block w-full rounded-none rounded-r-md border border-gray-300"
placeholder="lib name, other lib name"
data-cy="textFilterInput"
name="filter"
value={currentTextFilter}
onKeyUp={onTextFilterKeyUp}
onChange={(event) => onTextInputChange(event.currentTarget.value)}
></input>
{currentTextFilter.length > 0 ? (
<button
data-cy="textFilterReset"
type="reset"
onClick={resetClicked}
className="p-1 top-1 right-1 absolute bg-white inline-block rounded-md text-gray-500"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z"
/>
</svg>
</button>
) : null}
</form>
</div>
<div className="mt-4 px-4">
<div className="flex items-start">
<div className="flex items-center h-5">
<input
disabled={textFilter.length === 0}
id="includeInPath"
name="textFilterCheckbox"
type="checkbox"
value="includeInPath"
className="h-4 w-4 border-gray-300 rounded"
checked={includePath}
onChange={toggleIncludeLibsInPathChange}
></input>
</div>
<div className="ml-3 text-sm">
<label
htmlFor="includeInPath"
className="font-medium text-gray-700 cursor-pointer"
>
Include related libraries
</label>
<p className="text-gray-500">
Show libraries that are related to your search.
</p>
</div>
</div>
</div>
</div>
);
}
export default TextFilterPanel;

View File

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

View File

@ -1,6 +1,6 @@
import { parseParentDirectoriesFromPilePath } from './util';
describe('parseParentDirectoriesFromPilePath', () => {
describe('parseParentDirectoriesFromFilePath', () => {
// path, workspaceRoot, output
const cases: [string, string, string[]][] = [
['apps/app1', 'apps', []],

View File

@ -0,0 +1,56 @@
import { ProjectGraphDependency } from '@nrwl/devkit';
export function trimBackSlash(value: string): string {
return value.replace(/\/$/, '');
}
export function parseParentDirectoriesFromPilePath(
path: string,
workspaceRoot: string
) {
const root = trimBackSlash(path);
// split the source root on directory separator
const split: string[] = root.split('/');
// check the first part for libs or apps, depending on workspaceLayout
if (split[0] === trimBackSlash(workspaceRoot)) {
split.shift();
}
// pop off the last element, which should be the lib name
split.pop();
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;
}

View File

@ -0,0 +1,3 @@
export const environment = {
production: true,
};

View File

@ -0,0 +1,6 @@
// This file can be replaced during build by using the `fileReplacements` array.
// When building for production, this file is replaced with `environment.prod.ts`.
export const environment = {
production: false,
};

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,7 +1,5 @@
// nx-ignore-next-line
import { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { ProjectGraph, ProjectGraphNode } from '@nrwl/devkit';
import { ProjectGraphList } from './graphs';
import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { AppConfig } from './app/models';
export declare global {

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Nx Workspace Dependency Graph</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<script id="environment" src="environment.js"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -0,0 +1,18 @@
import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom';
import { inspect } from '@xstate/inspect';
import App from './app/app';
if (window.useXstateInspect === true) {
inspect({
url: 'https://stately.ai/viz?inspect',
iframe: false, // open in new window
});
}
ReactDOM.render(
<StrictMode>
<App />
</StrictMode>,
document.getElementById('app')
);

View File

@ -20,6 +20,24 @@ html {
display: flex;
}
#no-projects-chosen {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
#graph-container,
#cytoscape-graph {
width: 100%;
height: 100%;
}
canvas {
cursor: pointer;
}
.tippy-box[data-theme~='nx'] {
box-sizing: border-box;
border-style: solid;
@ -90,21 +108,3 @@ html {
background-color: rgba(243, 244, 246, 1);
}
}
#no-projects-chosen {
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
#graph-container,
#cytoscape-graph {
width: 100%;
height: 100%;
}
canvas {
cursor: pointer;
}

View File

@ -0,0 +1,23 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": ["node"],
"lib": ["DOM", "es2019"]
},
"files": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../node_modules/@nrwl/react/typings/image.d.ts"
],
"exclude": [
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}

View File

@ -1,5 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true
},
"files": [],
"include": [],
"references": [

View File

@ -0,0 +1,24 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"],
"lib": ["DOM"]
},
"include": [
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx",
"**/*.test.js",
"**/*.spec.js",
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts"
],
"files": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../node_modules/@nrwl/react/typings/image.d.ts"
]
}

View File

@ -1,33 +0,0 @@
{
"root": "dep-graph/dep-graph-e2e",
"sourceRoot": "dep-graph/dep-graph-e2e/src",
"projectType": "application",
"targets": {
"e2e-disabled": {
"executor": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "dep-graph/dep-graph-e2e/cypress.json",
"tsConfig": "dep-graph/dep-graph-e2e/tsconfig.e2e.json",
"devServerTarget": "dep-graph-dep-graph:serve-for-e2e",
"baseUrl": "http://localhost:4200"
}
},
"e2e-watch-disabled": {
"executor": "@nrwl/cypress:cypress",
"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-for-e2e:watch",
"baseUrl": "http://localhost:4200"
}
},
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["dep-graph/dep-graph-e2e/**/*.ts"]
}
}
},
"implicitDependencies": ["dep-graph-dep-graph"]
}

View File

@ -1,3 +0,0 @@
{
"presets": ["@nrwl/web/babel"]
}

View File

@ -1 +0,0 @@
{ "extends": "../../.eslintrc", "rules": {}, "ignorePatterns": ["!**/*"] }

View File

@ -1,15 +0,0 @@
module.exports = {
name: 'dep-graph-dep-graph',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
},
},
transform: {
'^.+\\.[tj]s$': 'ts-jest',
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/dep-graph/dep-graph',
};

View File

@ -1,153 +0,0 @@
// nx-ignore-next-line
import type { DepGraphClientResponse } from '@nrwl/workspace/src/command-line/dep-graph';
import { fromEvent } from 'rxjs';
import { startWith } from 'rxjs/operators';
import tippy from 'tippy.js';
import { DebuggerPanel } from './debugger-panel';
import { useGraphService } from './graph.service';
import { useDepGraphService } from './machines/dep-graph.service';
import { DepGraphSend } from './machines/interfaces';
import { AppConfig, DEFAULT_CONFIG, ProjectGraphService } from './models';
import { SidebarComponent } from './ui-sidebar/sidebar';
export class AppComponent {
private sidebar = new SidebarComponent();
private graph = useGraphService();
private debuggerPanel: DebuggerPanel;
private windowResize$ = fromEvent(window, 'resize').pipe(startWith({}));
private send: DepGraphSend;
private downloadImageButton: HTMLButtonElement;
constructor(
private config: AppConfig = DEFAULT_CONFIG,
private projectGraphService: ProjectGraphService
) {
const [state$, send] = useDepGraphService();
state$.subscribe((state) => {
if (state.context.selectedProjects.length !== 0) {
document.getElementById('no-projects-chosen').style.display = 'none';
if (this.downloadImageButton) {
this.downloadImageButton.classList.remove('opacity-0');
}
} else {
document.getElementById('no-projects-chosen').style.display = 'flex';
if (this.downloadImageButton) {
this.downloadImageButton.classList.add('opacity-0');
}
}
});
this.send = send;
this.loadProjectGraph(config.defaultProjectGraph);
this.render();
if (window.watch === true) {
setInterval(
() => this.updateProjectGraph(config.defaultProjectGraph),
5000
);
}
this.downloadImageButton = document.querySelector(
'[data-cy="downloadImageButton"]'
);
this.downloadImageButton.addEventListener('click', () => {
const graph = useGraphService();
const data = graph.getImage();
var downloadLink = document.createElement('a');
downloadLink.href = data;
downloadLink.download = 'graph.png';
// this is necessary as link.click() does not work on the latest firefox
downloadLink.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
})
);
});
tippy(this.downloadImageButton, {
content: 'Download Graph as PNG',
placement: 'right',
theme: 'nx',
});
}
private async loadProjectGraph(projectGraphId: string) {
const projectInfo = this.config.projectGraphs.find(
(graph) => graph.id === projectGraphId
);
const project: DepGraphClientResponse =
await this.projectGraphService.getProjectGraph(projectInfo.url);
const workspaceLayout = project?.layout;
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,
});
}
}
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');
if (this.config.showDebugger) {
debuggerPanelContainer.hidden = false;
debuggerPanelContainer.style.display = 'flex';
this.debuggerPanel = new DebuggerPanel(
debuggerPanelContainer,
this.config.projectGraphs,
this.config.defaultProjectGraph
);
this.debuggerPanel.selectProject$.subscribe((id) => {
this.loadProjectGraph(id);
});
this.graph.renderTimes$.subscribe(
(renderTime) => (this.debuggerPanel.renderTime = renderTime)
);
}
}
}

View File

@ -1,59 +0,0 @@
import { Subject } from 'rxjs';
import { GraphPerfReport } from './graph';
import { ProjectGraphList } from './models';
import { removeChildrenFromContainer } from './util';
export class DebuggerPanel {
set renderTime(renderTime: GraphPerfReport) {
this.renderReportElement.innerHTML = `Last render took ${renderTime.renderTime}ms: <b class="font-mono text-medium">${renderTime.numNodes} nodes</b> | <b class="font-mono text-medium">${renderTime.numEdges} edges</b>.`;
}
private selectProjectSubject = new Subject<string>();
selectProject$ = this.selectProjectSubject.asObservable();
private renderReportElement: HTMLElement;
constructor(
private container: HTMLElement,
private projectGraphs: ProjectGraphList[],
private initialSelectedGraph: string
) {
this.render();
}
private render() {
removeChildrenFromContainer(this.container);
const header = document.createElement('h4');
header.className = 'text-lg font-bold mr-4';
header.innerText = `Debugger`;
const select = document.createElement('select');
select.className =
'w-auto flex items-center px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white';
this.projectGraphs.forEach((projectGraph) => {
const option = document.createElement('option');
option.value = projectGraph.id;
option.innerText = projectGraph.label;
select.appendChild(option);
});
select.value = this.initialSelectedGraph;
select.dataset['cy'] = 'project-select';
select.onchange = (event) =>
this.selectProjectSubject.next(
(event.currentTarget as HTMLSelectElement).value
);
this.renderReportElement = document.createElement('p');
this.renderReportElement.className = 'text-sm';
this.container.appendChild(header);
this.container.appendChild(select);
this.container.appendChild(this.renderReportElement);
}
}

View File

@ -1,176 +0,0 @@
import { useGraphService } from '../graph.service';
import { useDepGraphService } from '../machines/dep-graph.service';
import { DepGraphSend } from '../machines/interfaces';
import { removeChildrenFromContainer } from '../util';
export class DisplayOptionsPanel {
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.contains('hidden')
) {
this.affectedButtonElement.classList.remove('hidden');
} else if (
state.context.affectedProjects.length === 0 &&
!this.affectedButtonElement.classList.contains('hidden')
) {
this.affectedButtonElement.classList.add('hidden');
}
this.searchDepthDisplay.innerText = state.context.searchDepth.toString();
if (
this.groupByFolderCheckboxElement.checked !==
state.context.groupByFolder
) {
this.groupByFolderCheckboxElement.checked = state.context.groupByFolder;
}
});
}
private static renderHtmlTemplate(): HTMLElement {
const render = document.createElement('template');
render.innerHTML = `
<div>
<div class="mt-8 px-4">
<button type="button" class="w-full flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" data-cy="selectAllButton">
<svg xmlns="http://www.w3.org/2000/svg" class="-ml-1 mr-2 h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Show all projects
</button>
<button type="button" class="mt-3 w-full flex items-center px-4 py-2 border border-red-300 rounded-md shadow-sm text-sm font-medium text-red-500 bg-white hover:bg-red-50 hidden" data-cy="affectedButton">
<svg xmlns="http://www.w3.org/2000/svg" class="-ml-1 mr-2 h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Show affected projects
</button>
<button type="button" class="mt-3 w-full flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" data-cy="deselectAllButton">
<svg xmlns="http://www.w3.org/2000/svg" class="-ml-1 mr-2 h-5 w-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
Hide all projects
</button>
</div>
<div class="mt-8 px-4">
<div class="flex items-start">
<div class="flex items-center h-5">
<input id="displayOptions" name="displayOptions" value="groupByFolder" type="checkbox" class="h-4 w-4 border-gray-300 rounded">
</div>
<div class="ml-3 text-sm">
<label for="displayOptions" class="cursor-pointer font-medium text-gray-700">Group by folder</label>
<p class="text-gray-500">Visually arrange libraries by folders with different colors.</p>
</div>
</div>
</div>
<div class="mt-4 px-4">
<div class="mt-4 flex items-start">
<div class="flex items-center h-5">
<input id="depthFilter" name="depthFilter" value="groupByFolder" type="checkbox" class="h-4 w-4 border-gray-300 rounded">
</div>
<div class="ml-3 text-sm">
<label for="depthFilter" class="cursor-pointer font-medium text-gray-700">Activate proximity</label>
<p class="text-gray-500">Explore connected libraries step by step.</p>
</div>
</div>
<div class="mt-3 px-10">
<div class="flex rounded-md shadow-sm text-gray-500">
<button id="depthFilterDecrement" title="Remove ancestor level" class="inline-flex items-center py-2 px-4 rounded-l-md border border-gray-300 bg-gray-50 text-gray-500 hover:bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H4" />
</svg>
</button>
<span id="depthFilterValue" class="p-1.5 bg-white flex-1 block w-full rounded-none border-t border-b border-gray-300 text-center font-mono">1</span>
<button id="depthFilterIncrement" title="Add ancestor level" class="inline-flex items-center py-2 px-4 rounded-r-md border border-gray-300 bg-gray-50 text-gray-500 hover:bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
</div>
</div>
</div>
</div>
`.trim();
return render.content.firstChild as HTMLElement;
}
private render() {
removeChildrenFromContainer(this.container);
const element = DisplayOptionsPanel.renderHtmlTemplate();
this.affectedButtonElement = element.querySelector(
'[data-cy="affectedButton"]'
);
this.affectedButtonElement.addEventListener('click', () =>
this.send({ type: 'selectAffected' })
);
const selectAllButtonElement: HTMLElement = element.querySelector(
'[data-cy="selectAllButton"]'
);
selectAllButtonElement.addEventListener('click', () => {
this.send({ type: 'selectAll' });
});
const deselectAllButtonElement: HTMLElement = element.querySelector(
'[data-cy="deselectAllButton"]'
);
deselectAllButtonElement.addEventListener('click', () => {
this.send({ type: 'deselectAll' });
});
this.groupByFolderCheckboxElement =
element.querySelector('#displayOptions');
this.groupByFolderCheckboxElement.addEventListener(
'change',
(event: InputEvent) =>
this.send({
type: 'setGroupByFolder',
groupByFolder: (event.target as HTMLInputElement).checked,
})
);
this.searchDepthDisplay = element.querySelector('#depthFilterValue');
const incrementButtonElement: HTMLInputElement = element.querySelector(
'#depthFilterIncrement'
);
const decrementButtonElement: HTMLInputElement = element.querySelector(
'#depthFilterDecrement'
);
const searchDepthEnabledElement: HTMLInputElement =
element.querySelector('#depthFilter');
incrementButtonElement.addEventListener('click', () => {
this.send({ type: 'incrementSearchDepth' });
});
decrementButtonElement.addEventListener('click', () => {
this.send({ type: 'decrementSearchDepth' });
});
searchDepthEnabledElement.addEventListener('change', (event: InputEvent) =>
this.send({
type: 'setSearchDepthEnabled',
searchDepthEnabled: (<HTMLInputElement>event.target).checked,
})
);
this.container.appendChild(element);
}
}

View File

@ -1,67 +0,0 @@
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 send: DepGraphSend;
constructor(private container: HTMLElement) {
const [state$, send] = useDepGraphService();
this.send = send;
state$
.pipe(map(({ context }) => context.focusedProject))
.subscribe((focusedProject) => this.render(focusedProject));
}
private static renderHtmlTemplate(): HTMLElement {
const render = document.createElement('template');
render.innerHTML = `
<div class="mt-10 px-4">
<div class="p-2 shadow-sm bg-green-nx-base text-gray-50 border border-gray-200 rounded-md flex items-center group relative cursor-pointer overflow-hidden" data-cy="unfocusButton">
<p class="truncate transition duration-200 ease-in-out group-hover:opacity-60">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline -mt-1 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span id="focused-project-name">e2e-some-other-very-long-project-name</span>
</p>
<div class="absolute right-2 flex transition-all translate-x-32 transition duration-200 ease-in-out group-hover:translate-x-0 pl-2 rounded-md text-gray-700 items-center text-sm font-medium bg-white shadow-sm ring-1 ring-gray-500">
Reset
<span class="p-1 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
</div>
</div>
</div>
`.trim();
return render.content.firstChild as HTMLElement;
}
private render(projectName?: string) {
removeChildrenFromContainer(this.container);
const element = FocusedProjectPanel.renderHtmlTemplate();
const projectNameElement: HTMLElement = element.querySelector(
'#focused-project-name'
);
const unfocusButtonElement = element.querySelector(
'[data-cy="unfocusButton"]'
);
if (projectName && projectName !== '') {
projectNameElement.innerText = `Focused on ${projectName}`;
this.container.hidden = false;
} else {
this.container.hidden = true;
}
unfocusButtonElement.addEventListener('click', () =>
this.send({ type: 'unfocusProject' })
);
this.container.appendChild(element);
}
}

View File

@ -1,227 +0,0 @@
import type { ProjectGraphNode } from '@nrwl/devkit';
import { useDepGraphService } from '../machines/dep-graph.service';
import { DepGraphSend } from '../machines/interfaces';
import {
parseParentDirectoriesFromPilePath,
removeChildrenFromContainer,
} from '../util';
export class ProjectList {
private projectItems: Record<string, HTMLElement> = {};
private send: DepGraphSend;
constructor(private container: HTMLElement) {
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 {
const render = document.createElement('template');
render.innerHTML = `
<li class="text-xs text-gray-600 block cursor-default select-none relative py-1 pl-3 pr-9">
<div class="flex items-center">
<button type="button" class="flex rounded-md" title="Focus on this library">
<span class="p-1 rounded-md flex items-center font-medium bg-white transition hover:bg-gray-50 shadow-sm ring-1 ring-gray-200">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2h-1.528A6 6 0 004 9.528V4z" />
<path fill-rule="evenodd" d="M8 10a4 4 0 00-3.446 6.032l-1.261 1.26a1 1 0 101.414 1.415l1.261-1.261A4 4 0 108 10zm-2 4a2 2 0 114 0 2 2 0 01-4 0z" clip-rule="evenodd" />
</svg>
</span>
</button>
<label class="font-mono font-normal ml-3 p-2 transition hover:bg-gray-50 cursor-pointer block rounded-md truncate w-full" data-project="project-name" data-active="false">
project-name
</label>
</div>
<span role="selection-icon" title="This library is visible" class="text-green-nx-base absolute inset-y-0 right-0 flex items-center cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</span>
</li>
`.trim();
return render.content.firstChild as HTMLElement;
}
setSelectedProjects(selectedProjects: string[]) {
Object.keys(this.projectItems).forEach((projectName) => {
this.projectItems[projectName].dataset['active'] = selectedProjects
.includes(projectName)
.toString();
this.projectItems[projectName].dispatchEvent(new CustomEvent('change'));
});
}
checkAllProjects() {
this.send({ type: 'selectAll' });
}
uncheckAllProjects() {
this.send({ type: 'deselectAll' });
}
uncheckProject(projectName: string) {
this.send({ type: 'deselectProject', projectName });
}
private render(
projects: ProjectGraphNode[],
workspaceLayout: { appsDir: string; libsDir: string }
) {
removeChildrenFromContainer(this.container);
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();
const sortedE2EDirectories = Object.keys(e2eDirectoryGroups).sort();
const appsHeader = document.createElement('h2');
appsHeader.className =
'mt-8 text-lg font-bold border-b border-gray-50 border-solid';
appsHeader.textContent = 'App projects';
this.container.append(appsHeader);
sortedAppDirectories.forEach((directoryName) => {
this.createProjectList(directoryName, appDirectoryGroups[directoryName]);
});
const e2eHeader = document.createElement('h2');
e2eHeader.className =
'mt-8 text-lg font-bold border-b border-gray-50 border-solid';
e2eHeader.textContent = 'E2E projects';
this.container.append(e2eHeader);
sortedE2EDirectories.forEach((directoryName) => {
this.createProjectList(directoryName, e2eDirectoryGroups[directoryName]);
});
const libHeader = document.createElement('h2');
libHeader.className =
'mt-8 text-lg font-bold border-b border-gray-50 border-solid';
libHeader.textContent = 'Lib projects';
this.container.append(libHeader);
sortedLibDirectories.forEach((directoryName) => {
this.createProjectList(directoryName, libDirectoryGroups[directoryName]);
});
}
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[],
workspaceLayout: { appsDir: string; libsDir: string }
) {
let groups = {};
projects.forEach((project) => {
const workspaceRoot =
project.type === 'app' || project.type === 'e2e'
? workspaceLayout.appsDir
: workspaceLayout.libsDir;
const directories = parseParentDirectoriesFromPilePath(
project.data.root,
workspaceRoot
);
const directory = directories.join('/');
if (!groups.hasOwnProperty(directory)) {
groups[directory] = [];
}
groups[directory].push(project);
});
return groups;
}
private createProjectList(headerText, projects) {
const header = document.createElement('h3');
header.className =
'mt-4 py-2 uppercase tracking-wide font-semibold text-sm lg:text-xs text-gray-900 cursor-text';
header.textContent = headerText;
const formGroup = document.createElement('ul');
formGroup.className = 'mt-2 -ml-3';
let sortedProjects = [...projects];
sortedProjects.sort((a, b) => {
return a.name.localeCompare(b.name);
});
projects.forEach((project) => {
const element = ProjectList.renderHtmlItemTemplate();
const selectedIconElement: HTMLElement = element.querySelector(
'span[role="selection-icon"]'
);
const focusButtonElement: HTMLElement = element.querySelector('button');
focusButtonElement.addEventListener('click', () =>
this.send({ type: 'focusProject', projectName: project.name })
);
const projectNameElement: HTMLElement = element.querySelector('label');
projectNameElement.innerText = project.name;
projectNameElement.dataset['project'] = project.name;
projectNameElement.dataset['active'] = 'false';
selectedIconElement.classList.add('hidden');
projectNameElement.addEventListener('click', (event) => {
const el = event.target as HTMLElement;
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') {
selectedIconElement.classList.add('hidden');
} else selectedIconElement.classList.remove('hidden');
});
selectedIconElement.addEventListener('click', () => {
projectNameElement.dispatchEvent(new Event('click'));
});
this.projectItems[project.name] = projectNameElement;
formGroup.append(element);
});
this.container.append(header);
this.container.append(formGroup);
}
}

View File

@ -1,37 +0,0 @@
import { DisplayOptionsPanel } from './display-options-panel';
import { FocusedProjectPanel } from './focused-project-panel';
import { ProjectList } from './project-list';
import { TextFilterPanel } from './text-filter-panel';
declare var ResizeObserver;
export class SidebarComponent {
private displayOptionsPanel: DisplayOptionsPanel;
private focusedProjectPanel: FocusedProjectPanel;
private textFilterPanel: TextFilterPanel;
private projectList: ProjectList;
constructor() {
const displayOptionsPanelContainer = document.getElementById(
'display-options-panel'
);
this.displayOptionsPanel = new DisplayOptionsPanel(
displayOptionsPanelContainer
);
const focusedProjectPanelContainer =
document.getElementById('focused-project');
this.focusedProjectPanel = new FocusedProjectPanel(
focusedProjectPanelContainer
);
const textFilterPanelContainer =
document.getElementById('text-filter-panel');
this.textFilterPanel = new TextFilterPanel(textFilterPanelContainer);
const projectListContainer = document.getElementById('project-lists');
this.projectList = new ProjectList(projectListContainer);
}
}

View File

@ -1,109 +0,0 @@
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;
includeInPath: boolean;
}
export class TextFilterPanel {
private textInput: HTMLInputElement;
private includeInPathCheckbox: HTMLInputElement;
private send: DepGraphSend;
constructor(private container: HTMLElement) {
const [_, send] = useDepGraphService();
this.send = send;
this.render();
}
private static renderHtmlTemplate(): HTMLElement {
const render = document.createElement('template');
render.innerHTML = `
<div>
<div class="mt-10 px-4">
<form class="flex rounded-md shadow-sm relative" onSubmit="return false">
<span class="inline-flex items-center p-2 rounded-l-md border border-r-0 border-gray-300 bg-gray-50 text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
</span>
<input type="text" class="p-1.5 bg-white text-gray-600 flex-1 block w-full rounded-none rounded-r-md border border-gray-300" placeholder="lib name, other lib name" data-cy="textFilterInput" name="filter">
<button id="textFilterReset" type="reset" class="p-1 top-1 right-1 absolute bg-white inline-block rounded-md text-gray-500">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2M3 12l6.414 6.414a2 2 0 001.414.586H19a2 2 0 002-2V7a2 2 0 00-2-2h-8.172a2 2 0 00-1.414.586L3 12z" />
</svg>
</button>
</form>
</div>
<div class="mt-4 px-4">
<div class="flex items-start">
<div class="flex items-center h-5">
<input disabled id="includeInPath" name="textFilterCheckbox" type="checkbox" value="includeInPath" class="h-4 w-4 border-gray-300 rounded">
</div>
<div class="ml-3 text-sm">
<label for="includeInPath" class="font-medium text-gray-700 cursor-pointer">Include related libraries</label>
<p class="text-gray-500">Show libraries that are related to your search.</p>
</div>
</div>
</div>
</div>
`.trim();
return render.content.firstChild as HTMLElement;
}
private render() {
removeChildrenFromContainer(this.container);
const element = TextFilterPanel.renderHtmlTemplate();
const resetInputElement: HTMLElement =
element.querySelector('#textFilterReset');
resetInputElement.classList.add('hidden');
this.textInput = element.querySelector('input[type="text"]');
this.textInput.addEventListener('keyup', (event) => {
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;
} else {
resetInputElement.classList.add('hidden');
this.includeInPathCheckbox.disabled = true;
}
});
fromEvent(this.textInput, 'keyup')
.pipe(
filter((event: KeyboardEvent) => event.key !== 'Enter'),
debounceTime(500),
map(() =>
this.send({ type: 'filterByText', search: this.textInput.value })
)
)
.subscribe();
this.includeInPathCheckbox = element.querySelector('#includeInPath');
this.includeInPathCheckbox.addEventListener('change', () =>
this.send({
type: 'setIncludeProjectsByPath',
includeProjectsByPath: this.includeInPathCheckbox.checked,
})
);
resetInputElement.addEventListener('click', () => {
this.textInput.value = '';
this.includeInPathCheckbox.checked = false;
this.includeInPathCheckbox.disabled = true;
resetInputElement.classList.add('hidden');
this.send([{ type: 'clearTextFilter' }]);
});
this.container.appendChild(element);
}
}

Some files were not shown because too many files have changed in this diff Show More