chore(docs): add docs graph component (#12929)

This commit is contained in:
Philip Fulcher 2022-11-02 09:23:08 -06:00 committed by GitHub
parent d103cd9d51
commit b23d8e911b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1121 additions and 180 deletions

View File

@ -150,3 +150,77 @@ src="https://www.youtube.com/embed/rNImFxo9gYs"
title="Nx Console Run UI Form" title="Nx Console Run UI Form"
width="100%" /%} width="100%" /%}
``` ```
#### Graph
Embed an Nx Graph visualization that can be panned by the user.
````markdown
{% graph height="450px" %}
```json
{
"projects": [
{
"type": "app",
"name": "app-changed",
"data": {
"tags": ["scope:cart"]
}
},
{
"type": "lib",
"name": "lib",
"data": {
"tags": ["scope:cart"]
}
},
{
"type": "lib",
"name": "lib2",
"data": {
"tags": ["scope:cart"]
}
},
{
"type": "lib",
"name": "lib3",
"data": {
"tags": ["scope:cart"]
}
}
],
"groupByFolder": false,
"workspaceLayout": {
"appsDir": "apps",
"libsDir": "libs"
},
"dependencies": {
"app-changed": [
{
"target": "lib",
"source": "app-changed",
"type": "direct"
}
],
"lib": [
{
"target": "lib2",
"source": "lib",
"type": "implicit"
},
{
"target": "lib3",
"source": "lib",
"type": "direct"
}
],
"lib2": [],
"lib3": []
},
"affectedProjectIds": []
}
```
{% /graph %}
````

View File

@ -1,6 +1,9 @@
# Explore the Graph # Explore the Graph
For Nx to run tasks quickly and correctly, it creates a graph of the dependencies between all the projects in the repository. Exploring this graph visually can be useful to understand why Nx is behaving in a certain way and to get a high level view of your code architecture. For Nx to run tasks quickly and correctly, it creates a graph of the dependencies between all the projects in the
repository. Exploring this graph visually can be useful
to understand why Nx is behaving in a certain way and to get a
high level view of your code architecture.
To launch the project graph visualization run: To launch the project graph visualization run:
@ -8,12 +11,16 @@ To launch the project graph visualization run:
nx graph nx graph
``` ```
This will open a browser window with an interactive representation of the project graph of your current codebase. Viewing the entire graph can be unmanageable even for smaller repositories, so there are several ways to narrow the focus of the visualization down to the most useful part of the graph at the moment. This will open a browser window with an interactive representation of the project graph of your current codebase.
Viewing the entire graph can be unmanageable even for smaller repositories, so there are several ways to narrow the
focus of the visualization down to the most useful part of the graph at the moment.
1. Focus on a specific project and then use the proximity and group by folder controls to modify the graph around that project. 1. Focus on a specific project and then use the proximity and group by folder controls to modify the graph around that
project.
2. Use the search bar to find all projects with names that contain a certain string. 2. Use the search bar to find all projects with names that contain a certain string.
3. Manually hide or show projects in the sidebar 3. Manually hide or show projects in the sidebar
Once the graph is displayed, you can click on an individual dependency link to find out what specific file(s) created that dependency. Once the graph is displayed, you can click on an individual dependency link to find out what specific file(s) created
that dependency.
![Project Graph screenshot](../images/project-graph.png) ![Project Graph screenshot](../images/project-graph.png)

View File

@ -1,7 +1,7 @@
import Tag from './ui-components/tag'; import Tag from './ui-components/tag';
export interface EdgeNodeTooltipProps { export interface EdgeNodeTooltipProps {
type: 'static' | 'dynamic' | 'implicit'; type: string;
source: string; source: string;
target: string; target: string;
fileDependencies: Array<{ fileName: string }>; fileDependencies: Array<{ fileName: string }>;

View File

@ -1,11 +1,15 @@
import { getTooltipService } from '../tooltip-service'; import { GraphService } from '@nrwl/graph/ui-graph';
import { GraphService } from './graph'; import { selectValueByThemeStatic } from '../theme-resolver';
let graphService: GraphService; let graphService: GraphService;
export function getGraphService(): GraphService { export function getGraphService(): GraphService {
if (!graphService) { if (!graphService) {
graphService = new GraphService(getTooltipService(), 'cytoscape-graph'); const darkModeEnabled = selectValueByThemeStatic(true, false);
graphService = new GraphService(
'cytoscape-graph',
selectValueByThemeStatic('dark', 'light')
);
} }
return graphService; return graphService;

View File

@ -13,7 +13,7 @@ export function rankDirInit() {
export function rankDirResolver(rankDir: RankDir) { export function rankDirResolver(rankDir: RankDir) {
currentRankDir = rankDir; currentRankDir = rankDir;
localStorage.setItem(localStorageRankDirKey, rankDir); localStorage.setItem(localStorageRankDirKey, rankDir);
getGraphService().setRankDir(currentRankDir); getGraphService().rankDir = currentRankDir;
} }
export function selectValueByRankDirDynamic<T>( export function selectValueByRankDirDynamic<T>(

View File

@ -1,28 +0,0 @@
import { NodeSingular } from 'cytoscape';
export class LabelWidthCalculator {
private cache: Map<string, number> = new Map<string, number>();
private ctx: CanvasRenderingContext2D;
calculateWidth(node: NodeSingular) {
if (!this.ctx) {
this.ctx = document.createElement('canvas').getContext('2d');
const fStyle = node.style('font-style');
const size = node.style('font-size');
const family = node.style('font-family');
const weight = node.style('font-weight');
this.ctx.font = fStyle + ' ' + weight + ' ' + size + ' ' + family;
}
const label = node.data('id');
if (this.cache.has(label)) {
return this.cache.get(label);
} else {
const width = this.ctx.measureText(node.data('id')).width;
this.cache.set(label, width);
return width;
}
}
}

View File

@ -36,7 +36,8 @@ export function themeResolver(theme: Theme) {
} }
localStorage.setItem(localStorageThemeKey, theme); localStorage.setItem(localStorageThemeKey, theme);
getGraphService().evaluateStyles();
getGraphService().theme = currentTheme;
} }
export function selectValueByThemeDynamic<T>( export function selectValueByThemeDynamic<T>(

View File

@ -1,10 +1,38 @@
import { getGraphService } from './machines/graph.service';
import { VirtualElement } from '@popperjs/core'; import { VirtualElement } from '@popperjs/core';
import { ProjectNodeToolTipProps } from './project-node-tooltip'; import { ProjectNodeToolTipProps } from './project-node-tooltip';
import { EdgeNodeTooltipProps } from './edge-tooltip'; import { EdgeNodeTooltipProps } from './edge-tooltip';
import { GraphInteractionEvents, GraphService } from '@nrwl/graph/ui-graph';
export class GraphTooltipService { export class GraphTooltipService {
private subscribers: Set<Function> = new Set(); private subscribers: Set<Function> = new Set();
constructor(graph: GraphService) {
graph.listen((event) => {
switch (event.type) {
case 'GraphRegenerated':
this.hideAll();
break;
case 'NodeClick':
this.openProjectNodeToolTip(event.ref, {
id: event.data.id,
tags: event.data.tags,
type: event.data.type,
});
break;
case 'EdgeClick':
this.openEdgeToolTip(event.ref, {
type: event.data.type,
target: event.data.target,
source: event.data.source,
fileDependencies: event.data.fileDependencies,
});
break;
}
});
}
currentTooltip: currentTooltip:
| { ref: VirtualElement; type: 'node'; props: ProjectNodeToolTipProps } | { ref: VirtualElement; type: 'node'; props: ProjectNodeToolTipProps }
| { ref: VirtualElement; type: 'edge'; props: EdgeNodeTooltipProps }; | { ref: VirtualElement; type: 'edge'; props: EdgeNodeTooltipProps };
@ -41,7 +69,8 @@ let tooltipService: GraphTooltipService;
export function getTooltipService(): GraphTooltipService { export function getTooltipService(): GraphTooltipService {
if (!tooltipService) { if (!tooltipService) {
tooltipService = new GraphTooltipService(); const graph = getGraphService();
tooltipService = new GraphTooltipService(graph);
} }
return tooltipService; return tooltipService;

12
graph/ui-graph/.babelrc Normal file
View File

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

View File

@ -0,0 +1,18 @@
{
"extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@ -0,0 +1,24 @@
const rootMain = require('../../../.storybook/main');
module.exports = {
...rootMain,
core: { ...rootMain.core, builder: 'webpack5' },
stories: [
...rootMain.stories,
'../src/lib/**/*.stories.mdx',
'../src/lib/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [...rootMain.addons, '@nrwl/react/plugins/storybook'],
webpackFinal: async (config, { configType }) => {
// apply any global webpack configs that might have been specified in .storybook/main.js
if (rootMain.webpackFinal) {
config = await rootMain.webpackFinal(config, { configType });
}
// add your own webpack tweaks if needed
return config;
},
};

View File

View File

@ -0,0 +1,27 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"emitDecoratorMetadata": true,
"outDir": ""
},
"files": [
"../../../node_modules/@nrwl/react/typings/styled-jsx.d.ts",
"../../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../../node_modules/@nrwl/react/typings/image.d.ts"
],
"exclude": [
"../**/*.spec.ts",
"../**/*.spec.js",
"../**/*.spec.tsx",
"../**/*.spec.jsx"
],
"include": [
"../src/**/*.stories.ts",
"../src/**/*.stories.js",
"../src/**/*.stories.jsx",
"../src/**/*.stories.tsx",
"../src/**/*.stories.mdx",
"*.js"
]
}

7
graph/ui-graph/README.md Normal file
View File

@ -0,0 +1,7 @@
# graph-ui-graph
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test graph-ui-graph` to execute the unit tests via [Jest](https://jestjs.io).

View File

@ -0,0 +1,10 @@
/* eslint-disable */
export default {
displayName: 'graph-ui-graph',
preset: '../../jest.preset.js',
transform: {
'^.+\\.[tj]sx?$': 'babel-jest',
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/graph/ui-graph',
};

View File

@ -0,0 +1,55 @@
{
"name": "graph-ui-graph",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "graph/ui-graph/src",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nrwl/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["graph/ui-graph/**/*.{ts,tsx,js,jsx}"]
}
},
"test": {
"executor": "@nrwl/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "graph/ui-graph/jest.config.ts",
"passWithNoTests": true
}
},
"storybook": {
"executor": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/react",
"port": 4400,
"config": {
"configFolder": "graph/ui-graph/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@nrwl/storybook:build",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/react",
"outputPath": "dist/storybook/graph-ui-graph",
"config": {
"configFolder": "graph/ui-graph/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
}

View File

@ -0,0 +1,3 @@
export * from './lib/nx-graph-viz';
export * from './lib/graph';
export * from './lib/graph-interaction-events';

View File

@ -0,0 +1,31 @@
import { VirtualElement } from '@popperjs/core';
import { NodeDataDefinition } from './util-cytoscape/project-node';
import { EdgeDataDefinition } from './util-cytoscape/edge';
interface NodeClickEvent {
type: 'NodeClick';
ref: VirtualElement;
id: string;
data: NodeDataDefinition;
}
interface EdgeClickEvent {
type: 'EdgeClick';
ref: VirtualElement;
id: string;
data: {
type: string;
source: string;
target: string;
fileDependencies: { fileName: string; target: string }[];
};
}
interface GraphRegeneratedEvent {
type: 'GraphRegenerated';
}
export type GraphInteractionEvents =
| NodeClickEvent
| EdgeClickEvent
| GraphRegeneratedEvent;

View File

@ -7,18 +7,19 @@ import type { VirtualElement } from '@popperjs/core';
import cy from 'cytoscape'; import cy from 'cytoscape';
import cytoscapeDagre from 'cytoscape-dagre'; import cytoscapeDagre from 'cytoscape-dagre';
import popper from 'cytoscape-popper'; import popper from 'cytoscape-popper';
import { edgeStyles, nodeStyles } from '../styles-graph'; import { edgeStyles, nodeStyles } from './styles-graph';
import { selectValueByRankDirStatic } from '../rankdir-resolver';
import { selectValueByThemeStatic } from '../theme-resolver';
import { GraphTooltipService } from '../tooltip-service';
import { import {
CytoscapeDagreConfig, CytoscapeDagreConfig,
ParentNode, ParentNode,
ProjectEdge, ProjectEdge,
ProjectNode, ProjectNode,
} from '../util-cytoscape'; } from './util-cytoscape';
import { GraphPerfReport, GraphRenderEvents } from './interfaces'; import { GraphPerfReport, GraphRenderEvents } from './interfaces';
import { getEnvironmentConfig } from '../hooks/use-environment-config'; import {
darkModeScratchKey,
switchValueByDarkMode,
} from './styles-graph/dark-mode';
import { GraphInteractionEvents } from './graph-interaction-events';
const cytoscapeDagreConfig = { const cytoscapeDagreConfig = {
name: 'dagre', name: 'dagre',
@ -35,12 +36,72 @@ export class GraphService {
private collapseEdges = false; private collapseEdges = false;
private listeners = new Map<
number,
(event: GraphInteractionEvents) => void
>();
private _theme: 'light' | 'dark';
private _rankDir: 'TB' | 'LR' = 'TB';
constructor( constructor(
private tooltipService: GraphTooltipService, private container: string | HTMLElement,
private containerId: string
theme: 'light' | 'dark',
private renderMode?: 'nx-console' | 'nx-docs',
rankDir: 'TB' | 'LR' = 'TB'
) { ) {
cy.use(cytoscapeDagre); cy.use(cytoscapeDagre);
cy.use(popper); cy.use(popper);
this._theme = theme;
this._rankDir = rankDir;
}
get activeContainer() {
return typeof this.container === 'string'
? document.getElementById(this.container)
: this.container;
}
set theme(theme: 'light' | 'dark') {
this._theme = theme;
if (this.renderGraph) {
this.renderGraph.unmount();
const useDarkMode = theme === 'dark';
this.renderGraph.scratch(darkModeScratchKey, useDarkMode);
this.renderGraph.elements().scratch(darkModeScratchKey, useDarkMode);
this.renderGraph.mount(this.activeContainer);
}
}
set rankDir(rankDir: 'TB' | 'LR') {
this._rankDir = rankDir;
if (this.renderGraph) {
const elements = this.renderGraph.elements();
elements
.layout({
...cytoscapeDagreConfig,
...{ rankDir: rankDir },
} as CytoscapeDagreConfig)
.run();
}
}
listen(callback: (event: GraphInteractionEvents) => void) {
const listenerId = this.listeners.size + 1;
this.listeners.set(listenerId, callback);
return () => {
this.listeners.delete(listenerId);
};
}
broadcast(event: GraphInteractionEvents) {
this.listeners.forEach((callback) => callback(event));
} }
handleEvent(event: GraphRenderEvents): { handleEvent(event: GraphRenderEvents): {
@ -51,9 +112,10 @@ export class GraphService {
if (this.renderGraph && event.type !== 'notifyGraphUpdateGraph') { if (this.renderGraph && event.type !== 'notifyGraphUpdateGraph') {
this.renderGraph.nodes('.focused').removeClass('focused'); this.renderGraph.nodes('.focused').removeClass('focused');
this.renderGraph.unmount();
} }
this.tooltipService.hideAll(); this.broadcast({ type: 'GraphRegenerated' });
switch (event.type) { switch (event.type) {
case 'notifyGraphInitGraph': case 'notifyGraphInitGraph':
@ -141,7 +203,7 @@ export class GraphService {
elements elements
.layout({ .layout({
...cytoscapeDagreConfig, ...cytoscapeDagreConfig,
...{ rankDir: selectValueByRankDirStatic('TB', 'LR') }, ...{ rankDir: this._rankDir },
}) })
.run(); .run();
@ -219,9 +281,7 @@ export class GraphService {
}); });
} }
const environmentConfig = getEnvironmentConfig(); if (this.renderMode === 'nx-console') {
if (environmentConfig.environment === 'nx-console') {
// when in the nx-console environment, adjust graph width and position to be to right of floating panel // when in the nx-console environment, adjust graph width and position to be to right of floating panel
// 175 is a magic number that represents the width of the floating panels divided in half plus some padding // 175 is a magic number that represents the width of the floating panels divided in half plus some padding
this.renderGraph this.renderGraph
@ -237,6 +297,13 @@ export class GraphService {
.nodes('[type!="dir"]') .nodes('[type!="dir"]')
.map((node) => node.id()); .map((node) => node.id());
this.renderGraph.scratch(darkModeScratchKey, this._theme === 'dark');
this.renderGraph
.elements()
.scratch(darkModeScratchKey, this._theme === 'dark');
this.renderGraph.mount(this.activeContainer);
const renderTime = Date.now() - time; const renderTime = Date.now() - time;
perfReport = { perfReport = {
@ -440,13 +507,14 @@ export class GraphService {
this.renderGraph.destroy(); this.renderGraph.destroy();
delete this.renderGraph; delete this.renderGraph;
} }
const container = document.getElementById(this.containerId);
this.renderGraph = cy({ this.renderGraph = cy({
container: container, headless: this.activeContainer === null,
headless: !container, container: this.activeContainer,
boxSelectionEnabled: false, boxSelectionEnabled: false,
style: [...nodeStyles, ...edgeStyles], style: [...nodeStyles, ...edgeStyles],
panningEnabled: true,
userZoomingEnabled: this.renderMode !== 'nx-docs',
}); });
this.renderGraph.add(elements); this.renderGraph.add(elements);
@ -456,7 +524,7 @@ export class GraphService {
} }
this.renderGraph.on('zoom pan', () => { this.renderGraph.on('zoom pan', () => {
this.tooltipService.hideAll(); this.broadcast({ type: 'GraphRegenerated' });
}); });
this.listenForProjectNodeClicks(); this.listenForProjectNodeClicks();
@ -504,7 +572,7 @@ export class GraphService {
collapseEdges: boolean collapseEdges: boolean
) { ) {
this.collapseEdges = collapseEdges; this.collapseEdges = collapseEdges;
this.tooltipService.hideAll(); this.broadcast({ type: 'GraphRegenerated' });
this.generateCytoscapeLayout( this.generateCytoscapeLayout(
allProjects, allProjects,
@ -609,10 +677,16 @@ export class GraphService {
let ref: VirtualElement = node.popperRef(); // used only for positioning let ref: VirtualElement = node.popperRef(); // used only for positioning
this.tooltipService.openProjectNodeToolTip(ref, { this.broadcast({
type: 'NodeClick',
ref,
id: node.id(), id: node.id(),
type: node.data('type'),
tags: node.data('tags'), data: {
id: node.id(),
type: node.data('type'),
tags: node.data('tags'),
},
}); });
}); });
} }
@ -622,20 +696,31 @@ export class GraphService {
const edge: cy.EdgeSingular = event.target; const edge: cy.EdgeSingular = event.target;
let ref: VirtualElement = edge.popperRef(); // used only for positioning let ref: VirtualElement = edge.popperRef(); // used only for positioning
this.tooltipService.openEdgeToolTip(ref, { this.broadcast({
type: edge.data('type'), type: 'EdgeClick',
source: edge.source().id(), ref,
target: edge.target().id(), id: edge.id(),
fileDependencies: edge
.source() data: {
.data('files') type: edge.data('type'),
.filter((file) => file.deps && file.deps.includes(edge.target().id())) source: edge.source().id(),
.map((file) => { target: edge.target().id(),
return { fileDependencies: edge
fileName: file.file.replace(`${edge.source().data('root')}/`, ''), .source()
target: edge.target().id(), .data('files')
}; .filter(
}), (file) => file.deps && file.deps.includes(edge.target().id())
)
.map((file) => {
return {
fileName: file.file.replace(
`${edge.source().data('root')}/`,
''
),
target: edge.target().id(),
};
}),
},
}); });
}); });
} }
@ -671,28 +756,7 @@ export class GraphService {
} }
getImage() { getImage() {
const bg = selectValueByThemeStatic('#0F172A', '#FFFFFF'); const bg = switchValueByDarkMode(this.renderGraph, '#0F172A', '#FFFFFF');
return this.renderGraph.png({ bg, full: true }); return this.renderGraph.png({ bg, full: true });
} }
evaluateStyles() {
if (this.renderGraph) {
const container = this.renderGraph.container();
this.renderGraph.unmount();
this.renderGraph.mount(container);
}
}
setRankDir(rankDir: 'TB' | 'LR') {
if (this.renderGraph) {
const elements = this.renderGraph.elements();
elements
.layout({
...cytoscapeDagreConfig,
...{ rankDir: rankDir },
} as CytoscapeDagreConfig)
.run();
}
}
} }

View File

@ -0,0 +1,219 @@
// nx-ignore-next-line
import type {
ProjectGraphDependency,
ProjectGraphProjectNode,
} from '@nrwl/devkit';
import { ActionObject, ActorRef, State, StateNodeConfig } from 'xstate';
// The hierarchical (recursive) schema for the states
export interface DepGraphSchema {
states: {
idle: {};
unselected: {};
focused: {};
textFiltered: {};
customSelected: {};
tracing: {};
};
}
export interface GraphPerfReport {
renderTime: number;
numNodes: number;
numEdges: number;
}
export type TracingAlgorithmType = 'shortest' | 'all';
// The events that the machine handles
export type DepGraphUIEvents =
| {
type: 'setSelectedProjectsFromGraph';
selectedProjectNames: string[];
perfReport: GraphPerfReport;
}
| { type: 'selectProject'; projectName: string }
| { type: 'deselectProject'; projectName: string }
| { type: 'selectAll' }
| { type: 'deselectAll' }
| { type: 'selectAffected' }
| { type: 'setGroupByFolder'; groupByFolder: boolean }
| { type: 'setTracingStart'; projectName: string }
| { type: 'setTracingEnd'; projectName: string }
| { type: 'clearTraceStart' }
| { type: 'clearTraceEnd' }
| { type: 'setTracingAlgorithm'; algorithm: TracingAlgorithmType }
| { type: 'setCollapseEdges'; collapseEdges: boolean }
| { type: 'setIncludeProjectsByPath'; includeProjectsByPath: boolean }
| { type: 'incrementSearchDepth' }
| { type: 'decrementSearchDepth' }
| { type: 'setSearchDepthEnabled'; searchDepthEnabled: boolean }
| { type: 'setSearchDepth'; searchDepth: number }
| { type: 'focusProject'; projectName: string }
| { type: 'unfocusProject' }
| { type: 'filterByText'; search: string }
| { type: 'clearTextFilter' }
| {
type: 'initGraph';
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
}
| {
type: 'updateGraph';
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
};
// The events that the graph actor handles
export type GraphRenderEvents =
| {
type: 'notifyGraphInitGraph';
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
groupByFolder: boolean;
collapseEdges: boolean;
}
| {
type: 'notifyGraphUpdateGraph';
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
workspaceLayout: {
libsDir: string;
appsDir: string;
};
groupByFolder: boolean;
collapseEdges: boolean;
selectedProjects: string[];
}
| {
type: 'notifyGraphFocusProject';
projectName: string;
searchDepth: number;
}
| {
type: 'notifyGraphShowProject';
projectName: string;
}
| {
type: 'notifyGraphHideProject';
projectName: string;
}
| {
type: 'notifyGraphShowAllProjects';
}
| {
type: 'notifyGraphHideAllProjects';
}
| {
type: 'notifyGraphShowAffectedProjects';
}
| {
type: 'notifyGraphFilterProjectsByText';
search: string;
includeProjectsByPath: boolean;
searchDepth: number;
}
| {
type: 'notifyGraphTracing';
start: string;
end: string;
algorithm: TracingAlgorithmType;
};
export type RouteEvents =
| {
type: 'notifyRouteFocusProject';
focusedProject: string;
}
| {
type: 'notifyRouteGroupByFolder';
groupByFolder: boolean;
}
| {
type: 'notifyRouteCollapseEdges';
collapseEdges: boolean;
}
| {
type: 'notifyRouteSearchDepth';
searchDepthEnabled: boolean;
searchDepth: number;
}
| {
type: 'notifyRouteUnfocusProject';
}
| {
type: 'notifyRouteSelectAll';
}
| {
type: 'notifyRouteSelectAffected';
}
| { type: 'notifyRouteClearSelect' }
| {
type: 'notifyRouteTracing';
start: string;
end: string;
algorithm: TracingAlgorithmType;
};
export type AllEvents = DepGraphUIEvents | GraphRenderEvents | RouteEvents;
// The context (extended state) of the machine
export interface DepGraphContext {
projects: ProjectGraphProjectNode[];
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjects: string[];
selectedProjects: string[];
focusedProject: string | null;
textFilter: string;
includePath: boolean;
searchDepth: number;
searchDepthEnabled: boolean;
groupByFolder: boolean;
collapseEdges: boolean;
workspaceLayout: {
libsDir: string;
appsDir: string;
};
graphActor: ActorRef<GraphRenderEvents>;
routeSetterActor: ActorRef<RouteEvents>;
routeListenerActor: ActorRef<DepGraphUIEvents>;
lastPerfReport: GraphPerfReport;
tracing: {
start: string;
end: string;
algorithm: TracingAlgorithmType;
};
}
export type DepGraphStateNodeConfig = StateNodeConfig<
DepGraphContext,
{},
DepGraphUIEvents,
ActionObject<DepGraphContext, DepGraphUIEvents>
>;
export type DepGraphSend = (
event: DepGraphUIEvents | DepGraphUIEvents[]
) => void;
export type DepGraphState = State<
DepGraphContext,
DepGraphUIEvents,
any,
{
value: any;
context: DepGraphContext;
}
>;

View File

@ -0,0 +1,62 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import { NxGraphViz } from './nx-graph-viz';
const Story: ComponentMeta<typeof NxGraphViz> = {
component: NxGraphViz,
title: 'NxGraphViz',
};
export default Story;
const Template: ComponentStory<typeof NxGraphViz> = (args) => (
<NxGraphViz {...args} />
);
export const Primary = Template.bind({});
Primary.args = {
projects: [
{
type: 'app',
name: 'app',
data: {
tags: ['scope:cart'],
},
},
{
type: 'lib',
name: 'lib',
data: {
tags: ['scope:cart'],
},
},
{
type: 'lib',
name: 'lib2',
data: {
root: 'libs/nested-scope/lib2',
tags: ['scope:cart'],
},
},
{
type: 'lib',
name: 'lib3',
data: {
root: 'libs/nested-scope/lib3',
tags: ['scope:cart'],
},
},
],
groupByFolder: true,
workspaceLayout: { appsDir: 'apps', libsDir: 'libs' },
dependencies: {
app: [{ target: 'lib', source: 'app', type: 'direct' }],
lib: [
{ target: 'lib2', source: 'lib', type: 'implicit' },
{ target: 'lib3', source: 'lib', type: 'direct' },
],
lib2: [],
lib3: [],
},
affectedProjectIds: [],
theme: 'light',
height: '450px',
};

View File

@ -0,0 +1,85 @@
/* nx-ignore-next-line */
import type {
ProjectGraphProjectNode,
ProjectGraphDependency,
} from 'nx/src/config/project-graph';
import { useEffect, useRef, useState } from 'react';
import { GraphService } from './graph';
type Theme = 'light' | 'dark' | 'system';
export interface GraphUiGraphProps {
projects: ProjectGraphProjectNode[];
groupByFolder: boolean;
workspaceLayout: { appsDir: string; libsDir: string };
dependencies: Record<string, ProjectGraphDependency[]>;
affectedProjectIds: string[];
theme: Theme;
height: string;
}
function resolveTheme(theme: Theme): 'dark' | 'light' {
if (theme !== 'system') {
return theme;
} else {
const darkMedia = window.matchMedia('(prefers-color-scheme: dark)');
return darkMedia.matches ? 'dark' : 'light';
}
}
export function NxGraphViz({
projects,
groupByFolder,
workspaceLayout,
dependencies,
affectedProjectIds,
theme,
height,
}: GraphUiGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [graph, setGraph] = useState<GraphService>(null);
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>();
const newlyResolvedTheme = resolveTheme(theme);
if (newlyResolvedTheme !== resolvedTheme) {
setResolvedTheme(newlyResolvedTheme);
if (graph) {
graph.theme = newlyResolvedTheme;
}
}
useEffect(() => {
if (containerRef.current !== null) {
import('./graph')
.then((module) => module.GraphService)
.then((GraphService) => {
const graph = new GraphService(
containerRef.current,
resolvedTheme,
'nx-docs',
'TB'
);
graph.handleEvent({
type: 'notifyGraphInitGraph',
projects,
groupByFolder,
workspaceLayout,
dependencies,
affectedProjects: affectedProjectIds,
collapseEdges: false,
});
graph.handleEvent({ type: 'notifyGraphShowAllProjects' });
setGraph(graph);
});
}
}, []);
return (
<div
ref={containerRef}
className="w-full"
style={{ width: '100%', height }}
></div>
);
}
export default NxGraphViz;

View File

@ -0,0 +1,15 @@
import { SingularData, Core } from 'cytoscape';
export const darkModeScratchKey = 'NX_GRAPH_DARK_MODE';
export function scratchHasDarkMode(element: SingularData | Core) {
return element.scratch(darkModeScratchKey) === true;
}
export function switchValueByDarkMode<T>(
element: SingularData | Core,
dark: T,
light: T
) {
return scratchHasDarkMode(element) ? dark : light;
}

View File

@ -1,45 +1,41 @@
import { Stylesheet } from 'cytoscape'; import { EdgeSingular, Stylesheet } from 'cytoscape';
import { selectValueByThemeDynamic } from '../theme-resolver';
import { NrwlPalette } from './palette'; import { NrwlPalette } from './palette';
import { switchValueByDarkMode } from './dark-mode';
const allEdges: Stylesheet = { const allEdges: Stylesheet = {
selector: 'edge', selector: 'edge',
style: { style: {
width: '1px', width: '1px',
'line-color': selectValueByThemeDynamic( 'line-color': (node) =>
NrwlPalette.slate_400, switchValueByDarkMode(node, NrwlPalette.slate_400, NrwlPalette.slate_500),
NrwlPalette.slate_500 'text-outline-color': (node: EdgeSingular) =>
), switchValueByDarkMode(node, NrwlPalette.slate_400, NrwlPalette.slate_500),
'text-outline-color': selectValueByThemeDynamic(
NrwlPalette.slate_400,
NrwlPalette.slate_500
),
'text-outline-width': '0px', 'text-outline-width': '0px',
color: selectValueByThemeDynamic( color: (node: EdgeSingular) =>
NrwlPalette.slate_400, switchValueByDarkMode(node, NrwlPalette.slate_400, NrwlPalette.slate_500),
NrwlPalette.slate_500
),
'curve-style': 'unbundled-bezier', 'curve-style': 'unbundled-bezier',
'target-arrow-shape': 'triangle', 'target-arrow-shape': 'triangle',
'target-arrow-fill': 'filled', 'target-arrow-fill': 'filled',
'target-arrow-color': selectValueByThemeDynamic( 'target-arrow-color': (node) =>
NrwlPalette.slate_400, switchValueByDarkMode(node, NrwlPalette.slate_400, NrwlPalette.slate_500),
NrwlPalette.slate_500
),
}, },
}; };
const affectedEdges: Stylesheet = { const affectedEdges: Stylesheet = {
selector: 'edge.affected', selector: 'edge.affected',
style: { style: {
'line-color': selectValueByThemeDynamic( 'line-color': (node) =>
NrwlPalette.fuchsia_500, switchValueByDarkMode(
NrwlPalette.pink_500 node,
), NrwlPalette.fuchsia_500,
'target-arrow-color': selectValueByThemeDynamic( NrwlPalette.pink_500
NrwlPalette.fuchsia_500, ),
NrwlPalette.pink_500 'target-arrow-color': (node) =>
), switchValueByDarkMode(
node,
NrwlPalette.fuchsia_500,
NrwlPalette.pink_500
),
'curve-style': 'unbundled-bezier', 'curve-style': 'unbundled-bezier',
}, },
}; };

View File

@ -0,0 +1,34 @@
import { NodeSingular } from 'cytoscape';
export class LabelWidthCalculator {
private cache = new Map<string, number>();
private ctx: CanvasRenderingContext2D;
constructor() {}
calculateWidth(node: NodeSingular): number {
if (!this.ctx) {
this.ctx = document
.createElement('canvas')
.getContext('2d') as CanvasRenderingContext2D;
}
const label = node.data('id');
const fStyle = node.style('font-style');
const size = node.style('font-size');
const family = node.style('font-family');
const weight = node.style('font-weight');
this.ctx.font = fStyle + ' ' + weight + ' ' + size + ' ' + family;
const cachedValue = this.cache.get(label);
if (cachedValue) {
return cachedValue;
} else {
const width = this.ctx.measureText(node.data('id')).width;
this.cache.set(label, width);
return width;
}
}
}

View File

@ -1,8 +1,8 @@
import { Stylesheet } from 'cytoscape'; import { NodeSingular, Stylesheet } from 'cytoscape';
import { selectValueByThemeDynamic } from '../theme-resolver';
import { FONTS } from './fonts'; import { FONTS } from './fonts';
import { NrwlPalette } from './palette'; import { NrwlPalette } from './palette';
import { LabelWidthCalculator } from './label-width'; import { LabelWidthCalculator } from './label-width';
import { switchValueByDarkMode } from './dark-mode';
const labelWidthCalculator = new LabelWidthCalculator(); const labelWidthCalculator = new LabelWidthCalculator();
@ -11,26 +11,19 @@ const allNodes: Stylesheet = {
style: { style: {
'font-size': '32px', 'font-size': '32px',
'font-family': FONTS, 'font-family': FONTS,
backgroundColor: selectValueByThemeDynamic( backgroundColor: (node) =>
NrwlPalette.slate_600, switchValueByDarkMode(node, NrwlPalette.slate_600, NrwlPalette.slate_200),
NrwlPalette.slate_200
),
'border-style': 'solid', 'border-style': 'solid',
'border-color': selectValueByThemeDynamic( 'border-color': (node) =>
NrwlPalette.slate_700, switchValueByDarkMode(node, NrwlPalette.slate_700, NrwlPalette.slate_300),
NrwlPalette.slate_300
),
'border-width': '1px', 'border-width': '1px',
'text-halign': 'center', 'text-halign': 'center',
'text-valign': 'center', 'text-valign': 'center',
'padding-left': '16px', 'padding-left': '16px',
color: selectValueByThemeDynamic( color: (node: NodeSingular) =>
NrwlPalette.slate_200, switchValueByDarkMode(node, NrwlPalette.slate_200, NrwlPalette.slate_600),
NrwlPalette.slate_600
),
label: 'data(id)', label: 'data(id)',
// width: (node) => node.data('id').length * 16, width: (node: NodeSingular) => labelWidthCalculator.calculateWidth(node),
width: (node) => labelWidthCalculator.calculateWidth(node),
'transition-property': 'transition-property':
'background-color, border-color, line-color, target-arrow-color', 'background-color, border-color, line-color, target-arrow-color',
'transition-duration': 250, 'transition-duration': 250,
@ -43,14 +36,10 @@ const focusedNodes: Stylesheet = {
selector: 'node.focused', selector: 'node.focused',
style: { style: {
color: NrwlPalette.white, color: NrwlPalette.white,
'border-color': selectValueByThemeDynamic( 'border-color': (node) =>
NrwlPalette.slate_700, switchValueByDarkMode(node, NrwlPalette.slate_700, NrwlPalette.slate_200),
NrwlPalette.slate_200 backgroundColor: (node) =>
), switchValueByDarkMode(node, NrwlPalette.sky_500, NrwlPalette.blue_500),
backgroundColor: selectValueByThemeDynamic(
NrwlPalette.sky_500,
NrwlPalette.blue_500
),
}, },
}; };
@ -58,29 +47,29 @@ const affectedNodes: Stylesheet = {
selector: 'node.affected', selector: 'node.affected',
style: { style: {
color: NrwlPalette.white, color: NrwlPalette.white,
'border-color': selectValueByThemeDynamic( 'border-color': (node) =>
NrwlPalette.fuchsia_800, switchValueByDarkMode(
NrwlPalette.pink_500 node,
), NrwlPalette.fuchsia_800,
backgroundColor: selectValueByThemeDynamic( NrwlPalette.pink_500
NrwlPalette.fuchsia_700, ),
NrwlPalette.pink_400 backgroundColor: (node) =>
), switchValueByDarkMode(
node,
NrwlPalette.fuchsia_700,
NrwlPalette.pink_400
),
}, },
}; };
const parentNodes: Stylesheet = { const parentNodes: Stylesheet = {
selector: ':parent', selector: ':parent',
style: { style: {
'background-opacity': selectValueByThemeDynamic(0.5, 0.8), 'background-opacity': (node) => switchValueByDarkMode(node, 0.5, 0.8),
backgroundColor: selectValueByThemeDynamic( backgroundColor: (node) =>
NrwlPalette.slate_700, switchValueByDarkMode(node, NrwlPalette.slate_700, NrwlPalette.slate_50),
NrwlPalette.slate_50 'border-color': (node) =>
), switchValueByDarkMode(node, NrwlPalette.slate_500, NrwlPalette.slate_400),
'border-color': selectValueByThemeDynamic(
NrwlPalette.slate_500,
NrwlPalette.slate_400
),
'border-style': 'dashed', 'border-style': 'dashed',
'border-width': 2, 'border-width': 2,
label: 'data(label)', label: 'data(label)',
@ -95,14 +84,10 @@ const highlightedNodes: Stylesheet = {
selector: 'node.highlight', selector: 'node.highlight',
style: { style: {
color: NrwlPalette.white, color: NrwlPalette.white,
'border-color': selectValueByThemeDynamic( 'border-color': (node) =>
NrwlPalette.sky_600, switchValueByDarkMode(node, NrwlPalette.sky_600, NrwlPalette.blue_600),
NrwlPalette.blue_600 backgroundColor: (node) =>
), switchValueByDarkMode(node, NrwlPalette.sky_500, NrwlPalette.blue_500),
backgroundColor: selectValueByThemeDynamic(
NrwlPalette.sky_500,
NrwlPalette.blue_500
),
}, },
}; };

View File

@ -1,14 +1,14 @@
import * as cy from 'cytoscape'; import { EdgeSingular, Position, BaseLayoutOptions } from 'cytoscape';
export interface CytoscapeDagreConfig extends cy.BaseLayoutOptions { export interface CytoscapeDagreConfig extends BaseLayoutOptions {
// dagre algo options, uses default value on undefined // dagre algo options, uses default value on undefined
nodeSep: number; // the separation between adjacent nodes in the same rank nodeSep: number; // the separation between adjacent nodes in the same rank
edgeSep: number; // the separation between adjacent edges in the same rank edgeSep: number; // the separation between adjacent edges in the same rank
rankSep: number; // the separation between each rank in the layout rankSep: number; // the separation between each rank in the layout
rankDir: 'TB' | 'LR'; // 'TB' for top to bottom flow, 'LR' for left to right, rankDir: 'TB' | 'LR'; // 'TB' for top to bottom flow, 'LR' for left to right,
ranker: 'network-simplex' | 'tight-tree' | 'longest-path'; // Type of algorithm to assign a rank to each node in the input graph. Possible values: 'network-simplex', 'tight-tree' or 'longest-path' ranker: 'network-simplex' | 'tight-tree' | 'longest-path'; // Type of algorithm to assign a rank to each node in the input graph. Possible values: 'network-simplex', 'tight-tree' or 'longest-path'
minLen: (edge: cy.EdgeSingular) => number; // number of ranks to keep between the source and target of the edge minLen: (edge: EdgeSingular) => number; // number of ranks to keep between the source and target of the edge
edgeWeight: (edge: cy.EdgeSingular) => number; // higher weight edges are generally made shorter and straighter than lower weight edges edgeWeight: (edge: EdgeSingular) => number; // higher weight edges are generally made shorter and straighter than lower weight edges
// general layout options // general layout options
fit: boolean; // whether to fit to viewport fit: boolean; // whether to fit to viewport
@ -22,7 +22,7 @@ export interface CytoscapeDagreConfig extends cy.BaseLayoutOptions {
boundingBox: boundingBox:
| { x1: number; y1: number; x2: number; y2: number } | { x1: number; y1: number; x2: number; y2: number }
| { x1: number; y1: number; w: number; h: number }; // constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h } | { x1: number; y1: number; w: number; h: number }; // constrain layout bounds; { x1, y1, x2, y2 } or { x1, y1, w, h }
transform: (node, pos) => cy.Position; // a function that applies a transform to the final node position transform: (node, pos) => Position; // a function that applies a transform to the final node position
ready: () => void; // on layoutready ready: () => void; // on layoutready
stop: () => void; // on layoutstop stop: () => void; // on layoutstop
} }

View File

@ -2,6 +2,13 @@
import type { ProjectGraphDependency } from '@nrwl/devkit'; import type { ProjectGraphDependency } from '@nrwl/devkit';
import * as cy from 'cytoscape'; import * as cy from 'cytoscape';
export interface EdgeDataDefinition extends cy.NodeDataDefinition {
id: string;
source: string;
target: string;
type: 'static' | 'dynamic' | 'implicit';
}
export class ProjectEdge { export class ProjectEdge {
affected = false; affected = false;

View File

@ -3,13 +3,13 @@ import type { ProjectGraphProjectNode } from '@nrwl/devkit';
import * as cy from 'cytoscape'; import * as cy from 'cytoscape';
import { parseParentDirectoriesFromFilePath } from '../util'; import { parseParentDirectoriesFromFilePath } from '../util';
interface NodeDataDefinition extends cy.NodeDataDefinition { export interface NodeDataDefinition extends cy.NodeDataDefinition {
id: string; id: string;
type: string; type: 'app' | 'lib' | 'e2e';
tags: string[]; tags: string[];
} }
interface Ancestor { export interface Ancestor {
id: string; id: string;
parentId: string; parentId: string;
label: string; label: string;

View File

@ -0,0 +1,50 @@
// nx-ignore-next-line
import { ProjectGraphDependency } from '@nrwl/devkit';
export function trimBackSlash(value: string): string {
return value.replace(/\/$/, '');
}
export function parseParentDirectoriesFromFilePath(
path: string,
workspaceRoot: string
) {
const directories = path
.replace(workspaceRoot, '')
.split('/')
.filter((directory) => directory !== '');
// last directory is the project
directories.pop();
return directories;
}
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,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
},
{
"path": "./.storybook/tsconfig.json"
}
]
}

View File

@ -0,0 +1,28 @@
{
"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": [
"jest.config.ts",
"**/*.spec.ts",
"**/*.test.ts",
"**/*.spec.tsx",
"**/*.test.tsx",
"**/*.spec.js",
"**/*.test.js",
"**/*.spec.jsx",
"**/*.test.jsx",
"**/*.stories.ts",
"**/*.stories.js",
"**/*.stories.jsx",
"**/*.stories.tsx"
],
"include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"]
}

View File

@ -0,0 +1,20 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"**/*.test.ts",
"**/*.spec.ts",
"**/*.test.tsx",
"**/*.spec.tsx",
"**/*.test.js",
"**/*.spec.js",
"**/*.test.jsx",
"**/*.spec.jsx",
"**/*.d.ts"
]
}

View File

@ -14,6 +14,8 @@ import { Card, Cards } from './lib/tags/cards.component';
import { card, cards } from './lib/tags/cards.schema'; import { card, cards } from './lib/tags/cards.schema';
import { GithubRepository } from './lib/tags/github-repository.component'; import { GithubRepository } from './lib/tags/github-repository.component';
import { githubRepository } from './lib/tags/github-repository.schema'; import { githubRepository } from './lib/tags/github-repository.schema';
import { Graph } from './lib/tags/graph.component';
import { graph } from './lib/tags/graph.schema';
import { Iframe } from './lib/tags/iframe.component'; import { Iframe } from './lib/tags/iframe.component';
import { iframe } from './lib/tags/iframe.schema'; import { iframe } from './lib/tags/iframe.schema';
import { NxCloudSection } from './lib/tags/nx-cloud-section.component'; import { NxCloudSection } from './lib/tags/nx-cloud-section.component';
@ -44,6 +46,7 @@ export const getMarkdocCustomConfig = (
card, card,
cards, cards,
'github-repository': githubRepository, 'github-repository': githubRepository,
graph,
iframe, iframe,
'install-nx-console': installNxConsole, 'install-nx-console': installNxConsole,
'nx-cloud-section': nxCloudSection, 'nx-cloud-section': nxCloudSection,
@ -62,6 +65,7 @@ export const getMarkdocCustomConfig = (
CustomLink, CustomLink,
Fence, Fence,
GithubRepository, GithubRepository,
Graph,
Heading, Heading,
Iframe, Iframe,
InstallNxConsole, InstallNxConsole,

View File

@ -0,0 +1,62 @@
import dynamic from 'next/dynamic';
import { ReactNode } from 'react';
import { useTheme } from '@nrwl/nx-dev/ui-theme';
const wrapperClassNames =
'w-full place-content-center rounded-md my-6 ring-1 ring-slate-100 dark:ring-slate-700';
export function Graph({
height,
children,
}: {
height: string;
children: ReactNode;
}): JSX.Element {
const [theme] = useTheme();
if (!children || !children.hasOwnProperty('props')) {
return (
<div className={`${wrapperClassNames} p-4`}>
<p>No JSON provided for graph</p>
</div>
);
}
let parsedProps;
try {
parsedProps = JSON.parse(children?.props.children as any);
} catch {
return (
<div className={`${wrapperClassNames} p-4`}>
<p>Could not parse JSON for graph:</p>
<pre>{children?.props.children as any}</pre>
</div>
);
}
const NxGraphViz = dynamic(
() => import('@nrwl/graph/ui-graph').then((module) => module.NxGraphViz),
{
ssr: false,
loading: () => (
<div className={wrapperClassNames} style={{ height }}>
Loading...
</div>
),
}
);
return (
<div className={wrapperClassNames}>
<NxGraphViz
height={height}
groupByFolder={false}
theme={theme}
projects={parsedProps.projects}
workspaceLayout={parsedProps.workspaceLayout}
dependencies={parsedProps.dependencies}
affectedProjectIds={parsedProps.affectedProjectIds}
></NxGraphViz>
</div>
);
}

View File

@ -0,0 +1,13 @@
import { Schema } from '@markdoc/markdoc';
export const graph: Schema = {
render: 'Graph',
children: [],
attributes: {
height: {
type: 'String',
required: true,
},
},
};

View File

@ -2,7 +2,8 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "../../dist/out-tsc", "outDir": "../../dist/out-tsc",
"types": ["node"] "types": ["node"],
"lib": ["DOM", "es2019"]
}, },
"files": [ "files": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts", "../../node_modules/@nrwl/react/typings/cssmodule.d.ts",

View File

@ -30,6 +30,7 @@
"@nrwl/eslint-plugin-nx": ["packages/eslint-plugin-nx/src"], "@nrwl/eslint-plugin-nx": ["packages/eslint-plugin-nx/src"],
"@nrwl/expo": ["packages/expo"], "@nrwl/expo": ["packages/expo"],
"@nrwl/express": ["packages/express"], "@nrwl/express": ["packages/express"],
"@nrwl/graph/ui-graph": ["graph/ui-graph/src/index.ts"],
"@nrwl/jest": ["packages/jest"], "@nrwl/jest": ["packages/jest"],
"@nrwl/jest/*": ["packages/jest/*"], "@nrwl/jest/*": ["packages/jest/*"],
"@nrwl/js": ["packages/js/src"], "@nrwl/js": ["packages/js/src"],

View File

@ -44,6 +44,7 @@
"expo": "packages/expo", "expo": "packages/expo",
"express": "packages/express", "express": "packages/express",
"graph-client": "graph/client", "graph-client": "graph/client",
"graph-ui-graph": "graph/ui-graph",
"jest": "packages/jest", "jest": "packages/jest",
"js": "packages/js", "js": "packages/js",
"linter": "packages/linter", "linter": "packages/linter",