feat(graph): add clickable project edge file links in nx console (#18113)
This commit is contained in:
parent
7aee21afd1
commit
f8068b7cf6
@ -1,6 +1,7 @@
|
|||||||
import { getRouter } from './get-router';
|
import { getRouter } from './get-router';
|
||||||
import { getProjectGraphService } from './machines/get-services';
|
import { getProjectGraphService } from './machines/get-services';
|
||||||
import { ProjectGraphMachineEvents } from './feature-projects/machines/interfaces';
|
import { ProjectGraphMachineEvents } from './feature-projects/machines/interfaces';
|
||||||
|
import { getGraphService } from './machines/graph.service';
|
||||||
|
|
||||||
export class ExternalApi {
|
export class ExternalApi {
|
||||||
_projectGraphService = getProjectGraphService();
|
_projectGraphService = getProjectGraphService();
|
||||||
@ -13,6 +14,7 @@ export class ExternalApi {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router = getRouter();
|
router = getRouter();
|
||||||
|
graphService = getGraphService();
|
||||||
|
|
||||||
projectGraphService = {
|
projectGraphService = {
|
||||||
send: (event: ProjectGraphMachineEvents) => {
|
send: (event: ProjectGraphMachineEvents) => {
|
||||||
@ -20,10 +22,21 @@ export class ExternalApi {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private fileClickCallbackListeners: ((url: string) => void)[] = [];
|
||||||
|
|
||||||
get depGraphService() {
|
get depGraphService() {
|
||||||
return this.projectGraphService;
|
return this.projectGraphService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.graphService.listen((event) => {
|
||||||
|
if (event.type === 'FileLinkClick') {
|
||||||
|
const url = `${event.sourceRoot}/${event.file}`;
|
||||||
|
this.fileClickCallbackListeners.forEach((cb) => cb(url));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
focusProject(projectName: string) {
|
focusProject(projectName: string) {
|
||||||
this.router.navigate(`/projects/${encodeURIComponent(projectName)}`);
|
this.router.navigate(`/projects/${encodeURIComponent(projectName)}`);
|
||||||
}
|
}
|
||||||
@ -42,6 +55,10 @@ export class ExternalApi {
|
|||||||
window.appConfig.showExperimentalFeatures = false;
|
window.appConfig.showExperimentalFeatures = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerFileClickCallback(callback: (url: string) => void) {
|
||||||
|
this.fileClickCallbackListeners.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
private handleLegacyProjectGraphEvent(event: ProjectGraphMachineEvents) {
|
private handleLegacyProjectGraphEvent(event: ProjectGraphMachineEvents) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'focusProject':
|
case 'focusProject':
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import type {
|
|||||||
/* eslint-enable @nx/enforce-module-boundaries */
|
/* eslint-enable @nx/enforce-module-boundaries */
|
||||||
import { interpret } from 'xstate';
|
import { interpret } from 'xstate';
|
||||||
import { projectGraphMachine } from './project-graph.machine';
|
import { projectGraphMachine } from './project-graph.machine';
|
||||||
|
import { AppConfig } from '../../interfaces';
|
||||||
|
|
||||||
export const mockProjects: ProjectGraphProjectNode[] = [
|
export const mockProjects: ProjectGraphProjectNode[] = [
|
||||||
{
|
{
|
||||||
@ -96,7 +97,24 @@ export const mockDependencies: Record<string, ProjectGraphDependency[]> = {
|
|||||||
'auth-lib': [],
|
'auth-lib': [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockAppConfig: AppConfig = {
|
||||||
|
showDebugger: false,
|
||||||
|
showExperimentalFeatures: false,
|
||||||
|
workspaces: [
|
||||||
|
{
|
||||||
|
id: 'local',
|
||||||
|
label: 'local',
|
||||||
|
projectGraphUrl: 'assets/project-graphs/e2e.json',
|
||||||
|
taskGraphUrl: 'assets/task-graphs/e2e.json',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultWorkspaceId: 'local',
|
||||||
|
};
|
||||||
|
|
||||||
describe('dep-graph machine', () => {
|
describe('dep-graph machine', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.appConfig = mockAppConfig;
|
||||||
|
});
|
||||||
describe('initGraph', () => {
|
describe('initGraph', () => {
|
||||||
it('should set projects, dependencies, and workspaceLayout', () => {
|
it('should set projects, dependencies, and workspaceLayout', () => {
|
||||||
const result = projectGraphMachine.transition(
|
const result = projectGraphMachine.transition(
|
||||||
|
|||||||
@ -7,11 +7,14 @@ let projectGraphService: ProjectGraphService;
|
|||||||
|
|
||||||
export function getProjectGraphDataService() {
|
export function getProjectGraphDataService() {
|
||||||
if (projectGraphService === undefined) {
|
if (projectGraphService === undefined) {
|
||||||
if (window.environment === 'dev' || window.environment === 'nx-console') {
|
if (window.environment === 'dev') {
|
||||||
projectGraphService = new FetchProjectGraphService();
|
projectGraphService = new FetchProjectGraphService();
|
||||||
} else if (window.environment === 'watch') {
|
} else if (window.environment === 'watch') {
|
||||||
projectGraphService = new MockProjectGraphService();
|
projectGraphService = new MockProjectGraphService();
|
||||||
} else if (window.environment === 'release') {
|
} else if (
|
||||||
|
window.environment === 'release' ||
|
||||||
|
window.environment === 'nx-console'
|
||||||
|
) {
|
||||||
if (window.localMode === 'build') {
|
if (window.localMode === 'build') {
|
||||||
projectGraphService = new LocalProjectGraphService();
|
projectGraphService = new LocalProjectGraphService();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
import { GraphService } from '@nx/graph/ui-graph';
|
import { GraphService } from '@nx/graph/ui-graph';
|
||||||
import { selectValueByThemeStatic } from '../theme-resolver';
|
import { selectValueByThemeStatic } from '../theme-resolver';
|
||||||
|
import { getEnvironmentConfig } from '../hooks/use-environment-config';
|
||||||
|
|
||||||
let graphService: GraphService;
|
let graphService: GraphService;
|
||||||
|
|
||||||
export function getGraphService(): GraphService {
|
export function getGraphService(): GraphService {
|
||||||
|
const environment = getEnvironmentConfig();
|
||||||
if (!graphService) {
|
if (!graphService) {
|
||||||
const darkModeEnabled = selectValueByThemeStatic(true, false);
|
|
||||||
graphService = new GraphService(
|
graphService = new GraphService(
|
||||||
'cytoscape-graph',
|
'cytoscape-graph',
|
||||||
selectValueByThemeStatic('dark', 'light')
|
selectValueByThemeStatic('dark', 'light'),
|
||||||
|
environment.environment === 'nx-console' ? 'nx-console' : undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -32,9 +32,16 @@ interface BackgroundClickEvent {
|
|||||||
type: 'BackgroundClick';
|
type: 'BackgroundClick';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface FileLinkClickEvent {
|
||||||
|
type: 'FileLinkClick';
|
||||||
|
sourceRoot: string;
|
||||||
|
file: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type GraphInteractionEvents =
|
export type GraphInteractionEvents =
|
||||||
| ProjectNodeClickEvent
|
| ProjectNodeClickEvent
|
||||||
| EdgeClickEvent
|
| EdgeClickEvent
|
||||||
| GraphRegeneratedEvent
|
| GraphRegeneratedEvent
|
||||||
| TaskNodeClickEvent
|
| TaskNodeClickEvent
|
||||||
| BackgroundClickEvent;
|
| BackgroundClickEvent
|
||||||
|
| FileLinkClickEvent;
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export class GraphService {
|
|||||||
constructor(
|
constructor(
|
||||||
container: string | HTMLElement,
|
container: string | HTMLElement,
|
||||||
theme: 'light' | 'dark',
|
theme: 'light' | 'dark',
|
||||||
renderMode?: 'nx-console' | 'nx-docs',
|
public renderMode?: 'nx-console' | 'nx-docs',
|
||||||
rankDir: 'TB' | 'LR' = 'TB'
|
rankDir: 'TB' | 'LR' = 'TB'
|
||||||
) {
|
) {
|
||||||
use(cytoscapeDagre);
|
use(cytoscapeDagre);
|
||||||
|
|||||||
@ -33,11 +33,21 @@ export class GraphTooltipService {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case 'EdgeClick':
|
case 'EdgeClick':
|
||||||
|
const callback =
|
||||||
|
graph.renderMode === 'nx-console'
|
||||||
|
? (url) =>
|
||||||
|
graph.broadcast({
|
||||||
|
type: 'FileLinkClick',
|
||||||
|
sourceRoot: event.data.sourceRoot,
|
||||||
|
file: url,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
this.openEdgeToolTip(event.ref, {
|
this.openEdgeToolTip(event.ref, {
|
||||||
type: event.data.type,
|
type: event.data.type,
|
||||||
target: event.data.target,
|
target: event.data.target,
|
||||||
source: event.data.source,
|
source: event.data.source,
|
||||||
fileDependencies: event.data.fileDependencies,
|
fileDependencies: event.data.fileDependencies,
|
||||||
|
fileClickCallback: callback,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -57,7 +67,11 @@ export class GraphTooltipService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openEdgeToolTip(ref: VirtualElement, props: ProjectEdgeNodeTooltipProps) {
|
openEdgeToolTip(ref: VirtualElement, props: ProjectEdgeNodeTooltipProps) {
|
||||||
this.currentTooltip = { type: 'projectEdge', ref, props };
|
this.currentTooltip = {
|
||||||
|
type: 'projectEdge',
|
||||||
|
ref,
|
||||||
|
props,
|
||||||
|
};
|
||||||
this.broadcastChange();
|
this.broadcastChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -310,7 +310,6 @@ export class ProjectTraversalGraph {
|
|||||||
projectNode.affected = affectedProjectIds.includes(project.name);
|
projectNode.affected = affectedProjectIds.includes(project.name);
|
||||||
|
|
||||||
projectNodes.push(projectNode);
|
projectNodes.push(projectNode);
|
||||||
|
|
||||||
dependencies[project.name].forEach((dep) => {
|
dependencies[project.name].forEach((dep) => {
|
||||||
if (filteredProjectNames.includes(dep.target)) {
|
if (filteredProjectNames.includes(dep.target)) {
|
||||||
const edge = new ProjectEdge(dep);
|
const edge = new ProjectEdge(dep);
|
||||||
|
|||||||
@ -276,6 +276,7 @@ export class RenderGraph {
|
|||||||
type: edge.data('type'),
|
type: edge.data('type'),
|
||||||
source: edge.source().id(),
|
source: edge.source().id(),
|
||||||
target: edge.target().id(),
|
target: edge.target().id(),
|
||||||
|
sourceRoot: edge.source().data('root'),
|
||||||
fileDependencies:
|
fileDependencies:
|
||||||
edge
|
edge
|
||||||
.source()
|
.source()
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export interface ProjectEdgeNodeTooltipProps {
|
|||||||
target: string;
|
target: string;
|
||||||
fileDependencies: Array<{ fileName: string }>;
|
fileDependencies: Array<{ fileName: string }>;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
fileClickCallback: (fileName: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectEdgeNodeTooltip({
|
export function ProjectEdgeNodeTooltip({
|
||||||
@ -14,6 +15,7 @@ export function ProjectEdgeNodeTooltip({
|
|||||||
target,
|
target,
|
||||||
fileDependencies,
|
fileDependencies,
|
||||||
description,
|
description,
|
||||||
|
fileClickCallback,
|
||||||
}: ProjectEdgeNodeTooltipProps) {
|
}: ProjectEdgeNodeTooltipProps) {
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-slate-700 dark:text-slate-400">
|
<div className="text-sm text-slate-700 dark:text-slate-400">
|
||||||
@ -33,7 +35,16 @@ export function ProjectEdgeNodeTooltip({
|
|||||||
{fileDependencies.map((fileDep) => (
|
{fileDependencies.map((fileDep) => (
|
||||||
<li
|
<li
|
||||||
key={fileDep.fileName}
|
key={fileDep.fileName}
|
||||||
className="whitespace-nowrap px-4 py-2 text-sm font-medium text-slate-800 dark:text-slate-300"
|
className={`whitespace-nowrap px-4 py-2 text-sm font-medium text-slate-800 dark:text-slate-300 ${
|
||||||
|
fileClickCallback !== undefined
|
||||||
|
? 'hover:underline hover:cursor-pointer'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onClick={
|
||||||
|
fileClickCallback !== undefined
|
||||||
|
? () => fileClickCallback(fileDep.fileName)
|
||||||
|
: () => {}
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<span className="block truncate font-normal">
|
<span className="block truncate font-normal">
|
||||||
{fileDep.fileName}
|
{fileDep.fileName}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user