feat(graph): display expanded task inputs (#19597)

This commit is contained in:
MaxKless 2023-10-16 22:01:34 +02:00 committed by GitHub
parent 92e05003cc
commit c727a22530
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 24589 additions and 66 deletions

1
.gitignore vendored
View File

@ -18,6 +18,7 @@ jest.debug.config.js
/graph/client/src/assets/dev/environment.js /graph/client/src/assets/dev/environment.js
/graph/client/src/assets/generated-project-graphs /graph/client/src/assets/generated-project-graphs
/graph/client/src/assets/generated-task-graphs /graph/client/src/assets/generated-task-graphs
/graph/client/src/assets/generated-task-inputs
/nx-dev/nx-dev/public/documentation /nx-dev/nx-dev/public/documentation
/nx-dev/nx-dev/public/images/open-graph /nx-dev/nx-dev/public/images/open-graph

View File

@ -1,3 +1,4 @@
import { parseJson } from '@nx/devkit';
import { import {
checkFilesExist, checkFilesExist,
cleanupProject, cleanupProject,
@ -8,6 +9,7 @@ import {
setMaxWorkers, setMaxWorkers,
uniq, uniq,
updateFile, updateFile,
readFile,
updateJson, updateJson,
} from '@nx/e2e/utils'; } from '@nx/e2e/utils';
import { join } from 'path'; import { join } from 'path';
@ -321,4 +323,108 @@ describe('Extra Nx Misc Tests', () => {
expect(unitTestsOutput).toContain('Successfully ran target test'); expect(unitTestsOutput).toContain('Successfully ran target test');
}); });
}); });
describe('task graph inputs', () => {
const readExpandedTaskInputResponse = (): Record<
string,
Record<string, string[]>
> =>
parseJson(
readFile('static/environment.js').match(
/window\.expandedTaskInputsResponse\s*=\s*(.*?);/
)[1]
);
const baseLib = 'lib-base-123';
beforeAll(() => {
runCLI(`generate @nx/js:lib ${baseLib}`);
});
it('should correctly expand default task inputs', () => {
runCLI('graph --file=graph.html');
expect(readExpandedTaskInputResponse()[`${baseLib}:build`])
.toMatchInlineSnapshot(`
{
"external": [
"npm:@nx/js",
"npm:tslib",
],
"general": [
".gitignore",
"nx.json",
],
"lib-base-123": [
"libs/lib-base-123/README.md",
"libs/lib-base-123/package.json",
"libs/lib-base-123/project.json",
"libs/lib-base-123/src/index.ts",
"libs/lib-base-123/src/lib/lib-base-123.ts",
"libs/lib-base-123/tsconfig.json",
"libs/lib-base-123/tsconfig.lib.json",
],
}
`);
});
it('should correctly expand dependent task inputs', () => {
const dependentLib = 'lib-dependent-123';
runCLI(`generate @nx/js:lib ${dependentLib}`);
updateJson(join('libs', baseLib, 'project.json'), (config) => {
config.targets['build'].inputs = ['default', '^default'];
config.implicitDependencies = [dependentLib];
return config;
});
updateJson('nx.json', (json) => {
json.namedInputs = {
...json.namedInputs,
default: ['{projectRoot}/**/*'],
};
return json;
});
runCLI('graph --file=graph.html');
expect(readExpandedTaskInputResponse()[`${baseLib}:build`])
.toMatchInlineSnapshot(`
{
"external": [
"npm:@nx/js",
"npm:tslib",
],
"general": [
".gitignore",
"nx.json",
],
"lib-base-123": [
"libs/lib-base-123/.eslintrc.json",
"libs/lib-base-123/README.md",
"libs/lib-base-123/jest.config.ts",
"libs/lib-base-123/package.json",
"libs/lib-base-123/project.json",
"libs/lib-base-123/src/index.ts",
"libs/lib-base-123/src/lib/lib-base-123.spec.ts",
"libs/lib-base-123/src/lib/lib-base-123.ts",
"libs/lib-base-123/tsconfig.json",
"libs/lib-base-123/tsconfig.lib.json",
"libs/lib-base-123/tsconfig.spec.json",
],
"lib-dependent-123": [
"libs/lib-dependent-123/.eslintrc.json",
"libs/lib-dependent-123/README.md",
"libs/lib-dependent-123/jest.config.ts",
"libs/lib-dependent-123/package.json",
"libs/lib-dependent-123/project.json",
"libs/lib-dependent-123/src/index.ts",
"libs/lib-dependent-123/src/lib/lib-dependent-123.spec.ts",
"libs/lib-dependent-123/src/lib/lib-dependent-123.ts",
"libs/lib-dependent-123/tsconfig.json",
"libs/lib-dependent-123/tsconfig.lib.json",
"libs/lib-dependent-123/tsconfig.spec.json",
],
}
`);
});
});
}); });

View File

@ -0,0 +1 @@
Cr24

View File

@ -18,11 +18,12 @@ import {
getToggleAllButtonForFolder, getToggleAllButtonForFolder,
getUncheckedProjectItems, getUncheckedProjectItems,
getUnfocusProjectButton, getUnfocusProjectButton,
openTooltipForNode,
} from '../support/app.po'; } from '../support/app.po';
import * as affectedJson from '../fixtures/affected.json'; import * as affectedJson from '../fixtures/affected.json';
import { testProjectsRoutes, testTaskRoutes } from '../support/routing-tests'; import { testProjectsRoutes, testTaskRoutes } from '../support/routing-tests';
import * as nxExamplesJson from '../fixtures/nx-examples-project-graph.json'; import * as nxExamplesTaskInputs from '../fixtures/nx-examples-task-inputs.json';
describe('dev mode - task graph', () => { describe('dev mode - task graph', () => {
before(() => { before(() => {
@ -183,4 +184,54 @@ describe('dev mode - task graph', () => {
// and also new /projects route // and also new /projects route
testTaskRoutes('browser', ['/e2e/tasks']); testTaskRoutes('browser', ['/e2e/tasks']);
}); });
describe('file inputs', () => {
beforeEach(() => {
cy.intercept(
{
method: 'GET',
url: '/task-inputs.json*',
},
async (req) => {
// Extract the desired query parameter
const taskId = req.url.split('taskId=')[1];
// Load the fixture data and find the property based on the query parameter
const expandedInputs = nxExamplesTaskInputs[taskId];
// Reply with the selected property
req.reply({
body: expandedInputs,
});
}
).as('getTaskInputs');
});
it('should display input files', () => {
getSelectTargetDropdown().select('build', { force: true });
cy.get('[data-project="cart"]').click({
force: true,
});
openTooltipForNode('cart:build');
cy.get('[data-cy="inputs-accordion"]').click();
cy.get('[data-cy="input-list-entry"]').should('have.length', 18);
const expectedSections = [
'cart-cart-page',
'shared-assets',
'shared-header',
'shared-styles',
'External Inputs',
];
cy.get('[data-cy="input-section-entry"]').each((el, idx) => {
expect(el.text()).to.equal(expectedSections[idx]);
});
const sharedHeaderSelector =
'[data-cy="input-section-entry"]:contains(shared-header)';
cy.get(sharedHeaderSelector).click();
cy.get(sharedHeaderSelector)
.nextAll('[data-cy="input-list-entry"]')
.should('have.length', 9);
});
});
}); });

File diff suppressed because it is too large Load Diff

View File

@ -45,3 +45,12 @@ export const getToggleAllButtonForFolder = (folderName: string) =>
export const getSelectTargetDropdown = () => export const getSelectTargetDropdown = () =>
cy.get('[data-cy=selected-target-dropdown]'); cy.get('[data-cy=selected-target-dropdown]');
export const openTooltipForNode = (nodeId: string) =>
cy.window().then((window) => {
// @ts-ignore - we will access private methods only in this e2e test
const pos = window.externalApi.graphService.renderGraph.cy
.$(nodeId)
.renderedPosition();
cy.get('#cytoscape-graph').click(pos.x, pos.y);
});

View File

@ -67,6 +67,7 @@
"graph/client/src/assets/task-graphs/", "graph/client/src/assets/task-graphs/",
"graph/client/src/assets/generated-project-graphs/", "graph/client/src/assets/generated-project-graphs/",
"graph/client/src/assets/generated-task-graphs/", "graph/client/src/assets/generated-task-graphs/",
"graph/client/src/assets/generated-task-inputs/",
{ {
"input": "graph/client/src/assets/dev", "input": "graph/client/src/assets/dev",
"output": "/", "output": "/",
@ -79,6 +80,7 @@
"graph/client/src/favicon.ico", "graph/client/src/favicon.ico",
"graph/client/src/assets/project-graphs/", "graph/client/src/assets/project-graphs/",
"graph/client/src/assets/task-graphs/", "graph/client/src/assets/task-graphs/",
"graph/client/src/assets/task-inputs/",
{ {
"input": "graph/client/src/assets/dev-e2e", "input": "graph/client/src/assets/dev-e2e",
"output": "/", "output": "/",

View File

@ -250,7 +250,6 @@ export const projectGraphMachine = createMachine<
setGraph: assign((ctx, event) => { setGraph: assign((ctx, event) => {
if (event.type !== 'setProjects' && event.type !== 'updateGraph') if (event.type !== 'setProjects' && event.type !== 'updateGraph')
return; return;
ctx.projects = event.projects; ctx.projects = event.projects;
ctx.dependencies = event.dependencies; ctx.dependencies = event.dependencies;
ctx.fileMap = event.fileMap; ctx.fileMap = event.fileMap;

View File

@ -114,6 +114,7 @@ const mockAppConfig: AppConfig = {
describe('dep-graph machine', () => { describe('dep-graph machine', () => {
beforeEach(() => { beforeEach(() => {
window.appConfig = mockAppConfig; window.appConfig = mockAppConfig;
window.environment = 'release';
}); });
describe('initGraph', () => { describe('initGraph', () => {
it('should set projects, dependencies, and workspaceLayout', () => { it('should set projects, dependencies, and workspaceLayout', () => {

View File

@ -305,7 +305,6 @@ export function ProjectsSidebar(): JSX.Element {
await projectGraphDataService.getProjectGraph( await projectGraphDataService.getProjectGraph(
projectInfo.projectGraphUrl projectInfo.projectGraphUrl
); );
projectGraphService.send({ projectGraphService.send({
type: 'updateGraph', type: 'updateGraph',
projects: response.projects, projects: response.projects,

View File

@ -1,10 +1,10 @@
import { TaskList } from './task-list';
import { import {
useNavigate, useNavigate,
useParams, useParams,
useRouteLoaderData, useRouteLoaderData,
useSearchParams, useSearchParams,
} from 'react-router-dom'; } from 'react-router-dom';
import { TaskList } from './task-list';
/* eslint-disable @nx/enforce-module-boundaries */ /* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line // nx-ignore-next-line
import type { import type {
@ -12,14 +12,16 @@ import type {
TaskGraphClientResponse, TaskGraphClientResponse,
} from 'nx/src/command-line/graph/graph'; } from 'nx/src/command-line/graph/graph';
/* eslint-enable @nx/enforce-module-boundaries */ /* eslint-enable @nx/enforce-module-boundaries */
import { getGraphService } from '../machines/graph.service';
import { useEffect, useMemo } from 'react'; import { useEffect, useMemo } from 'react';
import { getGraphService } from '../machines/graph.service';
import { CheckboxPanel } from '../ui-components/checkbox-panel'; import { CheckboxPanel } from '../ui-components/checkbox-panel';
import { Dropdown } from '@nx/graph/ui-components'; import { Dropdown } from '@nx/graph/ui-components';
import { ShowHideAll } from '../ui-components/show-hide-all';
import { useCurrentPath } from '../hooks/use-current-path'; import { useCurrentPath } from '../hooks/use-current-path';
import { ShowHideAll } from '../ui-components/show-hide-all';
import { createTaskName, useRouteConstructor } from '../util'; import { createTaskName, useRouteConstructor } from '../util';
import { GraphInteractionEvents } from '@nx/graph/ui-graph';
import { getProjectGraphDataService } from '../hooks/get-project-graph-data-service';
export function TasksSidebar() { export function TasksSidebar() {
const graphService = getGraphService(); const graphService = getGraphService();

View File

@ -8,6 +8,8 @@ import type {
import { ProjectGraphService } from './interfaces'; import { ProjectGraphService } from './interfaces';
export class FetchProjectGraphService implements ProjectGraphService { export class FetchProjectGraphService implements ProjectGraphService {
private taskInputsUrl: string;
async getHash(): Promise<string> { async getHash(): Promise<string> {
const request = new Request('currentHash', { mode: 'no-cors' }); const request = new Request('currentHash', { mode: 'no-cors' });
@ -31,4 +33,22 @@ export class FetchProjectGraphService implements ProjectGraphService {
return response.json(); return response.json();
} }
setTaskInputsUrl(url: string) {
this.taskInputsUrl = url;
}
async getExpandedTaskInputs(
taskId: string
): Promise<Record<string, string[]>> {
if (!this.taskInputsUrl) {
return {};
}
const request = new Request(`${this.taskInputsUrl}?taskId=${taskId}`, {
mode: 'no-cors',
});
const response = await fetch(request);
return (await response.json())[taskId];
}
} }

View File

@ -11,6 +11,7 @@ export interface WorkspaceData {
label: string; label: string;
projectGraphUrl: string; projectGraphUrl: string;
taskGraphUrl: string; taskGraphUrl: string;
taskInputsUrl: string;
} }
export interface WorkspaceLayout { export interface WorkspaceLayout {
@ -22,6 +23,8 @@ export interface ProjectGraphService {
getHash: () => Promise<string>; getHash: () => Promise<string>;
getProjectGraph: (url: string) => Promise<ProjectGraphClientResponse>; getProjectGraph: (url: string) => Promise<ProjectGraphClientResponse>;
getTaskGraph: (url: string) => Promise<TaskGraphClientResponse>; getTaskGraph: (url: string) => Promise<TaskGraphClientResponse>;
setTaskInputsUrl?: (url: string) => void;
getExpandedTaskInputs?: (taskId: string) => Promise<Record<string, string[]>>;
} }
export interface Environment { export interface Environment {

View File

@ -19,4 +19,12 @@ export class LocalProjectGraphService implements ProjectGraphService {
async getTaskGraph(url: string): Promise<TaskGraphClientResponse> { async getTaskGraph(url: string): Promise<TaskGraphClientResponse> {
return new Promise((resolve) => resolve(window.taskGraphResponse)); return new Promise((resolve) => resolve(window.taskGraphResponse));
} }
async getExpandedTaskInputs(
taskId: string
): Promise<Record<string, string[]>> {
return new Promise((resolve) =>
resolve(window.expandedTaskInputsResponse[taskId])
);
}
} }

View File

@ -1,16 +1,21 @@
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'; import { getEnvironmentConfig } from '../hooks/use-environment-config';
import { getProjectGraphDataService } from '../hooks/get-project-graph-data-service';
let graphService: GraphService; let graphService: GraphService;
export function getGraphService(): GraphService { export function getGraphService(): GraphService {
const environment = getEnvironmentConfig(); const environment = getEnvironmentConfig();
if (!graphService) { if (!graphService) {
const projectDataService = getProjectGraphDataService();
graphService = new GraphService( graphService = new GraphService(
'cytoscape-graph', 'cytoscape-graph',
selectValueByThemeStatic('dark', 'light'), selectValueByThemeStatic('dark', 'light'),
environment.environment === 'nx-console' ? 'nx-console' : undefined environment.environment === 'nx-console' ? 'nx-console' : undefined,
'TB',
(taskId: string) => projectDataService.getExpandedTaskInputs(taskId)
); );
} }

View File

@ -26,6 +26,8 @@ const workspaceDataLoader = async (selectedWorkspaceId: string) => {
(graph) => graph.id === selectedWorkspaceId (graph) => graph.id === selectedWorkspaceId
); );
projectGraphDataService.setTaskInputsUrl?.(workspaceInfo.taskInputsUrl);
const projectGraph: ProjectGraphClientResponse = const projectGraph: ProjectGraphClientResponse =
await projectGraphDataService.getProjectGraph( await projectGraphDataService.getProjectGraph(
workspaceInfo.projectGraphUrl workspaceInfo.projectGraphUrl

View File

@ -7,6 +7,7 @@ import {
Tooltip, Tooltip,
} from '@nx/graph/ui-tooltips'; } from '@nx/graph/ui-tooltips';
import { ProjectNodeActions } from './project-node-actions'; import { ProjectNodeActions } from './project-node-actions';
import { TaskNodeActions } from './task-node-actions';
const tooltipService = getTooltipService(); const tooltipService = getTooltipService();
@ -29,7 +30,11 @@ export function TooltipDisplay() {
tooltipToRender = <ProjectEdgeNodeTooltip {...currentTooltip.props} />; tooltipToRender = <ProjectEdgeNodeTooltip {...currentTooltip.props} />;
break; break;
case 'taskNode': case 'taskNode':
tooltipToRender = <TaskNodeTooltip {...currentTooltip.props} />; tooltipToRender = (
<TaskNodeTooltip {...currentTooltip.props}>
<TaskNodeActions {...currentTooltip.props} />
</TaskNodeTooltip>
);
break; break;
} }
} }

View File

@ -0,0 +1,126 @@
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import { TaskNodeTooltipProps } from '@nx/graph/ui-tooltips';
import { useEffect, useState } from 'react';
export function TaskNodeActions(props: TaskNodeTooltipProps) {
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
setIsOpen(false);
}, [props.id]);
const project = props.id.split(':')[0];
return (
<div className="overflow-auto w-full min-w-[350px] max-w-full rounded-md border border-slate-200 dark:border-slate-800 w-full">
<div
className="flex justify-between items-center w-full bg-slate-50 px-4 py-2 text-xs font-medium uppercase text-slate-500 dark:bg-slate-800 dark:text-slate-400"
onClick={() => setIsOpen(!isOpen)}
data-cy="inputs-accordion"
>
<span>Inputs</span>
<span>
{isOpen ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</span>
</div>
<ul
className={`max-h-[300px] divide-y divide-slate-200 overflow-auto dark:divide-slate-800 ${
!isOpen && 'hidden'
}`}
>
{Object.entries(props.inputs ?? {})
.sort(compareInputSectionKeys(project))
.map(([key, inputs]) => {
if (!inputs.length) return undefined;
if (key === 'general' || key === project) {
return renderInputs(inputs);
}
if (key === 'external') {
return InputAccordion({ section: 'External Inputs', inputs });
}
return InputAccordion({ section: key, inputs });
})}
</ul>
</div>
);
}
function InputAccordion({ section, inputs }) {
const [isOpen, setIsOpen] = useState(false);
return [
<li
key={section}
className="flex justify-between items-center whitespace-nowrap px-4 py-2 text-sm font-medium text-slate-800 dark:text-slate-300"
onClick={() => setIsOpen(!isOpen)}
data-cy="input-section-entry"
>
<span className="block truncate font-normal font-bold">{section}</span>
<span>
{isOpen ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</span>
</li>,
isOpen ? renderInputs(inputs) : undefined,
];
}
function renderInputs(inputs: string[]) {
return inputs.map((input) => (
<li
key={input}
className="whitespace-nowrap px-4 py-2 text-sm font-medium text-slate-800 dark:text-slate-300"
title={input}
data-cy="input-list-entry"
>
<span className="block truncate font-normal">{input}</span>
</li>
));
}
function compareInputSectionKeys(project: string) {
return ([keya]: [string, string[]], [keyb]: [string, string[]]) => {
const first = 'general';
const second = project;
const last = 'external';
// Check if 'keya' and/or 'keyb' are one of the special strings
if (
keya === first ||
keya === second ||
keya === last ||
keyb === first ||
keyb === second ||
keyb === last
) {
// If 'keya' is 'general', 'keya' should always be first
if (keya === first) return -1;
// If 'keyb' is 'general', 'keyb' should always be first
if (keyb === first) return 1;
// At this point, we know neither 'keya' nor 'keyb' are 'general'
// If 'keya' is project, 'keya' should be second (i.e., before 'keyb' unless 'keyb' is 'general')
if (keya === second) return -1;
// If 'keyb' is project, 'keyb' should be second (i.e., before 'keya')
if (keyb === second) return 1;
// At this point, we know neither 'keya' nor 'keyb' are 'general' or project
// If 'keya' is 'external', 'keya' should be last (i.e., after 'keyb')
if (keya === last) return 1;
// If 'keyb' is 'external', 'keyb' should be last (i.e., after 'keya')
if (keyb === last) return -1;
}
// If neither 'keya' nor 'b' are one of the special strings, sort alphabetically
if (keya < keyb) {
return -1;
}
if (keya > keyb) {
return 1;
}
return 0;
};
}

View File

@ -12,12 +12,14 @@ window.appConfig = {
label: 'e2e', label: 'e2e',
projectGraphUrl: 'assets/project-graphs/e2e.json', projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json', taskGraphUrl: 'assets/task-graphs/e2e.json',
taskInputsUrl: 'assets/task-inputs/e2e.json',
}, },
{ {
id: 'affected', id: 'affected',
label: 'affected', label: 'affected',
projectGraphUrl: 'assets/project-graphs/affected.json', projectGraphUrl: 'assets/project-graphs/affected.json',
taskGraphUrl: 'assets/task-graphs/affected.json', taskGraphUrl: 'assets/task-graphs/affected.json',
taskInputsUrl: 'assets/task-inputs/affected.json',
}, },
], ],
defaultWorkspaceId: 'e2e', defaultWorkspaceId: 'e2e',

View File

@ -12,6 +12,7 @@ window.appConfig = {
label: 'local', label: 'local',
projectGraphUrl: 'assets/project-graphs/e2e.json', projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json', taskGraphUrl: 'assets/task-graphs/e2e.json',
taskInputsUrl: 'assets/task-inputs/e2e.json',
}, },
], ],
defaultWorkspaceId: 'local', defaultWorkspaceId: 'local',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
/* eslint-disable @nx/enforce-module-boundaries */ /* eslint-disable @nx/enforce-module-boundaries */
// nx-ignore-next-line // nx-ignore-next-line
import type { import type {
ExpandedTaskInputsReponse,
ProjectGraphClientResponse, ProjectGraphClientResponse,
TaskGraphClientResponse, TaskGraphClientResponse,
} from 'nx/src/command-line/graph/graph'; } from 'nx/src/command-line/graph/graph';
@ -15,6 +16,7 @@ export declare global {
localMode: 'serve' | 'build'; localMode: 'serve' | 'build';
projectGraphResponse?: ProjectGraphClientResponse; projectGraphResponse?: ProjectGraphClientResponse;
taskGraphResponse?: TaskGraphClientResponse; taskGraphResponse?: TaskGraphClientResponse;
expandedTaskInputsResponse?: ExpandedTaskInputsReponse;
environment: 'dev' | 'watch' | 'release' | 'nx-console'; environment: 'dev' | 'watch' | 'release' | 'nx-console';
appConfig: AppConfig; appConfig: AppConfig;
useXstateInspect: boolean; useXstateInspect: boolean;

View File

@ -32,7 +32,10 @@ export class GraphService {
container: string | HTMLElement, container: string | HTMLElement,
theme: 'light' | 'dark', theme: 'light' | 'dark',
public renderMode?: 'nx-console' | 'nx-docs', public renderMode?: 'nx-console' | 'nx-docs',
rankDir: 'TB' | 'LR' = 'TB' rankDir: 'TB' | 'LR' = 'TB',
public getTaskInputs: (
taskId: string
) => Promise<Record<string, string[]>> = undefined
) { ) {
use(cytoscapeDagre); use(cytoscapeDagre);
use(popper); use(popper);

View File

@ -6,12 +6,13 @@ import {
ProjectEdgeNodeTooltipProps, ProjectEdgeNodeTooltipProps,
} from '@nx/graph/ui-tooltips'; } from '@nx/graph/ui-tooltips';
import { TooltipEvent } from './interfaces'; import { TooltipEvent } from './interfaces';
import { GraphInteractionEvents } from './graph-interaction-events';
export class GraphTooltipService { export class GraphTooltipService {
private subscribers: Set<Function> = new Set(); private subscribers: Set<Function> = new Set();
constructor(graph: GraphService) { constructor(graph: GraphService) {
graph.listen((event) => { graph.listen((event: GraphInteractionEvents) => {
switch (event.type) { switch (event.type) {
case 'GraphRegenerated': case 'GraphRegenerated':
this.hideAll(); this.hideAll();
@ -49,6 +50,20 @@ export class GraphTooltipService {
...event.data, ...event.data,
runTaskCallback, runTaskCallback,
}); });
if (graph.getTaskInputs) {
graph.getTaskInputs(event.data.id).then((inputs) => {
if (
this.currentTooltip.type === 'taskNode' &&
this.currentTooltip.props.id === event.data.id
) {
this.openTaskNodeTooltip(event.ref, {
...event.data,
runTaskCallback,
inputs,
});
}
});
}
break; break;
case 'EdgeClick': case 'EdgeClick':
const callback = const callback =

View File

@ -1,11 +1,15 @@
import { PlayIcon } from '@heroicons/react/24/outline'; import { PlayIcon } from '@heroicons/react/24/outline';
import { Tag } from '@nx/graph/ui-components'; import { Tag } from '@nx/graph/ui-components';
import { ReactNode } from 'react';
export interface TaskNodeTooltipProps { export interface TaskNodeTooltipProps {
id: string; id: string;
executor: string; executor: string;
runTaskCallback?: () => void; runTaskCallback?: () => void;
description?: string; description?: string;
inputs?: Record<string, string[]>;
children?: ReactNode | ReactNode[];
} }
export function TaskNodeTooltip({ export function TaskNodeTooltip({
@ -13,10 +17,11 @@ export function TaskNodeTooltip({
executor, executor,
description, description,
runTaskCallback: runTargetCallback, runTaskCallback: runTargetCallback,
children,
}: TaskNodeTooltipProps) { }: TaskNodeTooltipProps) {
return ( return (
<div className="text-sm text-slate-700 dark:text-slate-400"> <div className="text-sm text-slate-700 dark:text-slate-400">
<h4 className="flex justify-between items-center gap-4"> <h4 className="flex justify-between items-center gap-4 mb-3">
<div className="flex items-center"> <div className="flex items-center">
<Tag className="mr-3">{executor}</Tag> <Tag className="mr-3">{executor}</Tag>
<span className="font-mono">{id}</span> <span className="font-mono">{id}</span>
@ -31,8 +36,8 @@ export function TaskNodeTooltip({
</button> </button>
) : undefined} ) : undefined}
</h4> </h4>
<h4></h4>
{description ? <p className="mt-4">{description}</p> : null} {description ? <p className="mt-4">{description}</p> : null}
{children}
</div> </div>
); );
} }

View File

@ -87,21 +87,15 @@ export function PackageSchemaSubList({
</p> </p>
{vm.type === 'document' ? ( {vm.type === 'document' ? (
<> <DocumentList documents={vm.package.documents} />
<DocumentList documents={vm.package.documents} />
</>
) : null} ) : null}
{vm.type === 'executor' ? ( {vm.type === 'executor' ? (
<> <SchemaList files={vm.package.executors} type={'executor'} />
<SchemaList files={vm.package.executors} type={'executor'} />
</>
) : null} ) : null}
{vm.type === 'generator' ? ( {vm.type === 'generator' ? (
<> <SchemaList files={vm.package.generators} type={'generator'} />
<SchemaList files={vm.package.generators} type={'generator'} />
</>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@ -16,17 +16,15 @@ export function DocumentList({
documents: DocumentMetadata[]; documents: DocumentMetadata[];
}): JSX.Element { }): JSX.Element {
return ( return (
<> <ul className="divide-y divide-slate-100 dark:divide-slate-800">
<ul className="divide-y divide-slate-100 dark:divide-slate-800"> {!!documents.length ? (
{!!documents.length ? ( documents.map((guide) => (
documents.map((guide) => ( <DocumentListItem key={guide.id} document={guide} />
<DocumentListItem key={guide.id} document={guide} /> ))
)) ) : (
) : ( <EmptyList type="document" />
<EmptyList type="document" /> )}
)} </ul>
</ul>
</>
); );
} }
@ -63,17 +61,15 @@ export function SchemaList({
type: 'executor' | 'generator'; type: 'executor' | 'generator';
}): JSX.Element { }): JSX.Element {
return ( return (
<> <ul className="divide-y divide-slate-100 dark:divide-slate-800">
<ul className="divide-y divide-slate-100 dark:divide-slate-800"> {!!files.length ? (
{!!files.length ? ( files.map((schema) => (
files.map((schema) => ( <SchemaListItem key={schema.name} file={schema} />
<SchemaListItem key={schema.name} file={schema} /> ))
)) ) : (
) : ( <EmptyList type={type} />
<EmptyList type={type} /> )}
)} </ul>
</ul>
</>
); );
} }

View File

@ -7,6 +7,6 @@ import { useLayoutEffect as ReactUseLayoutEffect } from 'react';
* *
* See: https://reactjs.org/docs/hooks-reference.html#uselayouteffect * See: https://reactjs.org/docs/hooks-reference.html#uselayouteffect
*/ */
export const useLayoutEffect = (<any>globalThis)?.document export const useLayoutEffect = (globalThis as any)?.document
? ReactUseLayoutEffect ? ReactUseLayoutEffect
: () => void 0; : () => void 0;

View File

@ -1,36 +1,51 @@
import { workspaceRoot } from '../../utils/workspace-root';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'; import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
import { copySync, ensureDirSync } from 'fs-extra'; import { copySync, ensureDirSync } from 'fs-extra';
import * as http from 'http'; import * as http from 'http';
import * as open from 'open'; import * as minimatch from 'minimatch';
import { basename, dirname, extname, isAbsolute, join, parse } from 'path';
import { performance } from 'perf_hooks';
import { URL } from 'node:url'; import { URL } from 'node:url';
import * as open from 'open';
import {
basename,
dirname,
extname,
isAbsolute,
join,
parse,
relative,
} from 'path';
import { performance } from 'perf_hooks';
import { readNxJson, workspaceLayout } from '../../config/configuration'; import { readNxJson, workspaceLayout } from '../../config/configuration';
import { output } from '../../utils/output';
import { writeJsonFile } from '../../utils/fileutils';
import { import {
ProjectFileMap, ProjectFileMap,
ProjectGraph, ProjectGraph,
ProjectGraphDependency, ProjectGraphDependency,
ProjectGraphProjectNode, ProjectGraphProjectNode,
} from '../../config/project-graph'; } from '../../config/project-graph';
import { writeJsonFile } from '../../utils/fileutils';
import { output } from '../../utils/output';
import { workspaceRoot } from '../../utils/workspace-root';
import { Server } from 'net';
import { FileData } from '../../config/project-graph';
import { TaskGraph } from '../../config/task-graph';
import { daemonClient } from '../../daemon/client/client';
import { filterUsingGlobPatterns } from '../../hasher/task-hasher';
import { getRootTsConfigPath } from '../../plugins/js/utils/typescript';
import { pruneExternalNodes } from '../../project-graph/operators'; import { pruneExternalNodes } from '../../project-graph/operators';
import { createProjectGraphAsync } from '../../project-graph/project-graph'; import { createProjectGraphAsync } from '../../project-graph/project-graph';
import { import {
createTaskGraph, createTaskGraph,
mapTargetDefaultsToDependencies, mapTargetDefaultsToDependencies,
} from '../../tasks-runner/create-task-graph'; } from '../../tasks-runner/create-task-graph';
import { TaskGraph } from '../../config/task-graph'; import { allFileData } from '../../utils/all-file-data';
import { daemonClient } from '../../daemon/client/client';
import { Server } from 'net';
import { readFileMapCache } from '../../project-graph/nx-deps-cache';
import { getAffectedGraphNodes } from '../affected/affected';
import { splitArgsIntoNxArgsAndOverrides } from '../../utils/command-line-utils'; import { splitArgsIntoNxArgsAndOverrides } from '../../utils/command-line-utils';
import { NxJsonConfiguration } from '../../config/nx-json'; import { NxJsonConfiguration } from '../../config/nx-json';
import { HashPlanner } from '../../native'; import { HashPlanner } from '../../native';
import { transformProjectGraphForRust } from '../../native/transform-objects'; import { transformProjectGraphForRust } from '../../native/transform-objects';
import { getAffectedGraphNodes } from '../affected/affected';
import { readFileMapCache } from '../../project-graph/nx-deps-cache';
export interface ProjectGraphClientResponse { export interface ProjectGraphClientResponse {
hash: string; hash: string;
@ -50,6 +65,10 @@ export interface TaskGraphClientResponse {
errors: Record<string, string>; errors: Record<string, string>;
} }
export interface ExpandedTaskInputsReponse {
[taskId: string]: Record<string, string[]>;
}
// maps file extention to MIME types // maps file extention to MIME types
const mimeType = { const mimeType = {
'.ico': 'image/x-icon', '.ico': 'image/x-icon',
@ -73,7 +92,8 @@ function buildEnvironmentJs(
watchMode: boolean, watchMode: boolean,
localMode: 'build' | 'serve', localMode: 'build' | 'serve',
depGraphClientResponse?: ProjectGraphClientResponse, depGraphClientResponse?: ProjectGraphClientResponse,
taskGraphClientResponse?: TaskGraphClientResponse taskGraphClientResponse?: TaskGraphClientResponse,
expandedTaskInputsReponse?: ExpandedTaskInputsReponse
) { ) {
let environmentJs = `window.exclude = ${JSON.stringify(exclude)}; let environmentJs = `window.exclude = ${JSON.stringify(exclude)};
window.watch = ${!!watchMode}; window.watch = ${!!watchMode};
@ -88,7 +108,8 @@ function buildEnvironmentJs(
id: 'local', id: 'local',
label: 'local', label: 'local',
projectGraphUrl: 'project-graph.json', projectGraphUrl: 'project-graph.json',
taskGraphUrl: 'task-graph.json' taskGraphUrl: 'task-graph.json',
taskInputsUrl: 'task-inputs.json',
} }
], ],
defaultWorkspaceId: 'local', defaultWorkspaceId: 'local',
@ -105,9 +126,13 @@ function buildEnvironmentJs(
taskGraphClientResponse taskGraphClientResponse
)}; )};
`; `;
environmentJs += `window.expandedTaskInputsResponse = ${JSON.stringify(
expandedTaskInputsReponse
)};`;
} else { } else {
environmentJs += `window.projectGraphResponse = null;`; environmentJs += `window.projectGraphResponse = null;`;
environmentJs += `window.taskGraphResponse = null;`; environmentJs += `window.taskGraphResponse = null;`;
environmentJs += `window.expandedTaskInputsResponse = null;`;
} }
return environmentJs; return environmentJs;
@ -318,13 +343,18 @@ export async function generateGraph(
); );
const taskGraphClientResponse = await createTaskGraphClientResponse(); const taskGraphClientResponse = await createTaskGraphClientResponse();
const taskInputsReponse = await createExpandedTaskInputResponse(
taskGraphClientResponse,
depGraphClientResponse
);
const environmentJs = buildEnvironmentJs( const environmentJs = buildEnvironmentJs(
args.exclude || [], args.exclude || [],
args.watch, args.watch,
!!args.file && args.file.endsWith('html') ? 'build' : 'serve', !!args.file && args.file.endsWith('html') ? 'build' : 'serve',
depGraphClientResponse, depGraphClientResponse,
taskGraphClientResponse taskGraphClientResponse,
taskInputsReponse
); );
html = html.replace(/src="/g, 'src="static/'); html = html.replace(/src="/g, 'src="static/');
html = html.replace(/href="styles/g, 'href="static/styles'); html = html.replace(/href="styles/g, 'href="static/styles');
@ -455,7 +485,6 @@ async function startServer(
// by limiting the path to current directory only // by limiting the path to current directory only
const sanitizePath = basename(parsedUrl.pathname); const sanitizePath = basename(parsedUrl.pathname);
if (sanitizePath === 'project-graph.json') { if (sanitizePath === 'project-graph.json') {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(currentDepGraphClientResponse)); res.end(JSON.stringify(currentDepGraphClientResponse));
@ -468,6 +497,23 @@ async function startServer(
return; return;
} }
if (sanitizePath === 'task-inputs.json') {
performance.mark('task input generation:start');
const taskId = parsedUrl.searchParams.get('taskId');
res.writeHead(200, { 'Content-Type': 'application/json' });
const inputs = await getExpandedTaskInputs(taskId);
performance.mark('task input generation:end');
res.end(JSON.stringify({ [taskId]: inputs }));
performance.measure(
'task input generation',
'task input generation:start',
'task input generation:end'
);
return;
}
if (sanitizePath === 'currentHash') { if (sanitizePath === 'currentHash') {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ hash: currentDepGraphClientResponse.hash })); res.end(JSON.stringify({ hash: currentDepGraphClientResponse.hash }));
@ -481,7 +527,6 @@ async function startServer(
} }
let pathname = join(__dirname, '../../core/graph/', sanitizePath); let pathname = join(__dirname, '../../core/graph/', sanitizePath);
// if the file is not found or is a directory, return index.html // if the file is not found or is a directory, return index.html
if (!existsSync(pathname) || statSync(pathname).isDirectory()) { if (!existsSync(pathname) || statSync(pathname).isDirectory()) {
res.writeHead(200, { 'Content-Type': 'text/html' }); res.writeHead(200, { 'Content-Type': 'text/html' });
@ -618,10 +663,17 @@ async function createDepGraphClientResponse(
}; };
} }
async function createTaskGraphClientResponse(): Promise<TaskGraphClientResponse> { async function createTaskGraphClientResponse(
let graph = pruneExternalNodes( pruneExternal: boolean = false
await createProjectGraphAsync({ exitOnError: true }) ): Promise<TaskGraphClientResponse> {
); let graph: ProjectGraph;
if (pruneExternal) {
graph = pruneExternalNodes(
await createProjectGraphAsync({ exitOnError: true })
);
} else {
graph = await createProjectGraphAsync({ exitOnError: true });
}
const nxJson = readNxJson(); const nxJson = readNxJson();
@ -667,6 +719,36 @@ async function createTaskGraphClientResponse(): Promise<TaskGraphClientResponse>
}; };
} }
async function createExpandedTaskInputResponse(
taskGraphClientResponse: TaskGraphClientResponse,
depGraphClientResponse: ProjectGraphClientResponse
): Promise<ExpandedTaskInputsReponse> {
performance.mark('task input static generation:start');
const allWorkspaceFiles = await allFileData();
const response: Record<string, Record<string, string[]>> = {};
Object.entries(taskGraphClientResponse.plans).forEach(([key, inputs]) => {
const [project] = key.split(':');
const expandedInputs = expandInputs(
inputs,
depGraphClientResponse.projects.find((p) => p.name === project),
allWorkspaceFiles,
depGraphClientResponse
);
response[key] = expandedInputs;
});
performance.mark('task input static generation:end');
performance.measure(
'task input static generation',
'task input static generation:start',
'task input static generation:end'
);
return response;
}
function getAllTaskGraphsForWorkspace( function getAllTaskGraphsForWorkspace(
nxJson: NxJsonConfiguration, nxJson: NxJsonConfiguration,
projectGraph: ProjectGraph projectGraph: ProjectGraph
@ -684,7 +766,7 @@ function getAllTaskGraphsForWorkspace(
// TODO(cammisuli): improve performance here. Cache results or something. // TODO(cammisuli): improve performance here. Cache results or something.
for (const projectName in projectGraph.nodes) { for (const projectName in projectGraph.nodes) {
const project = projectGraph.nodes[projectName]; const project = projectGraph.nodes[projectName];
const targets = Object.keys(project.data.targets); const targets = Object.keys(project.data.targets ?? {});
targets.forEach((target) => { targets.forEach((target) => {
const taskId = createTaskId(projectName, target); const taskId = createTaskId(projectName, target);
@ -752,6 +834,130 @@ function createTaskId(
} }
} }
async function getExpandedTaskInputs(
taskId: string
): Promise<Record<string, string[]>> {
const [project] = taskId.split(':');
const taskGraphResponse = await createTaskGraphClientResponse(false);
const allWorkspaceFiles = await allFileData();
const inputs = taskGraphResponse.plans[taskId];
if (inputs) {
return expandInputs(
inputs,
currentDepGraphClientResponse.projects.find((p) => p.name === project),
allWorkspaceFiles,
currentDepGraphClientResponse
);
}
return {};
}
function expandInputs(
inputs: string[],
project: ProjectGraphProjectNode,
allWorkspaceFiles: FileData[],
depGraphClientResponse: ProjectGraphClientResponse
): Record<string, string[]> {
const projectNames = depGraphClientResponse.projects.map((p) => p.name);
const workspaceRootInputs: string[] = [];
const projectRootInputs: string[] = [];
const externalInputs: string[] = [];
const otherInputs: string[] = [];
inputs.forEach((input) => {
if (input.startsWith('{workspaceRoot}')) {
workspaceRootInputs.push(input);
return;
}
const maybeProjectName = input.split(':')[0];
if (projectNames.includes(maybeProjectName)) {
projectRootInputs.push(input);
return;
}
if (
input === 'ProjectConfiguration' ||
input === 'TsConfig' ||
input === 'AllExternalDependencies'
) {
otherInputs.push(input);
return;
}
// there shouldn't be any other imports in here, but external ones are always going to have a modifier in front
if (input.includes(':')) {
externalInputs.push(input);
return;
}
});
const workspaceRootsExpanded: string[] = workspaceRootInputs.flatMap(
(input) => {
const matches = [];
const withoutWorkspaceRoot = input.substring(16);
const matchingFile = allWorkspaceFiles.find(
(t) => t.file === withoutWorkspaceRoot
);
if (matchingFile) {
matches.push(matchingFile.file);
} else {
allWorkspaceFiles
.filter((f) => minimatch(f.file, withoutWorkspaceRoot))
.forEach((f) => {
matches.push(f.file);
});
}
return matches;
}
);
const otherInputsExpanded = otherInputs.map((input) => {
if (input === 'TsConfig') {
return relative(workspaceRoot, getRootTsConfigPath());
}
if (input === 'ProjectConfiguration') {
return depGraphClientResponse.fileMap[project.name].find(
(file) =>
file.file === `${project.data.root}/project.json` ||
file.file === `${project.data.root}/package.json`
).file;
}
return input;
});
const projectRootsExpanded = projectRootInputs
.map((input) => {
const fileSetProjectName = input.split(':')[0];
const fileSetProject = depGraphClientResponse.projects.find(
(p) => p.name === fileSetProjectName
);
const fileSets = input.replace(`${fileSetProjectName}:`, '').split(',');
const projectInputExpanded = {
[fileSetProject.name]: filterUsingGlobPatterns(
fileSetProject.data.root,
depGraphClientResponse.fileMap[fileSetProject.name],
fileSets
).map((f) => f.file),
};
return projectInputExpanded;
})
.reduce((curr, acc) => {
for (let key in curr) {
acc[key] = curr[key];
}
return acc;
}, {});
return {
general: [...workspaceRootsExpanded, ...otherInputsExpanded],
...projectRootsExpanded,
external: externalInputs,
};
}
interface GraphJsonResponse { interface GraphJsonResponse {
tasks?: TaskGraph; tasks?: TaskGraph;
graph: ProjectGraph; graph: ProjectGraph;

View File

@ -15,7 +15,7 @@ export function transformProjectGraphForRust(
for (const [projectName, projectNode] of Object.entries(graph.nodes)) { for (const [projectName, projectNode] of Object.entries(graph.nodes)) {
const targets: Record<string, Target> = {}; const targets: Record<string, Target> = {};
for (const [targetName, targetConfig] of Object.entries( for (const [targetName, targetConfig] of Object.entries(
projectNode.data.targets projectNode.data.targets ?? {}
)) { )) {
targets[targetName] = { targets[targetName] = {
executor: targetConfig.executor, executor: targetConfig.executor,

View File

@ -33,6 +33,7 @@ function writeFile() {
label: id, label: id,
projectGraphUrl: join('assets/generated-project-graphs/', filename), projectGraphUrl: join('assets/generated-project-graphs/', filename),
taskGraphUrl: join('assets/generated-task-graphs/', filename), taskGraphUrl: join('assets/generated-task-graphs/', filename),
taskInputsUrl: join('assets/generated-task-inputs/', filename),
}; };
}); });
} catch { } catch {
@ -50,6 +51,7 @@ function writeFile() {
label: id, label: id,
projectGraphUrl: join('assets/project-graphs/', filename), projectGraphUrl: join('assets/project-graphs/', filename),
taskGraphUrl: join('assets/task-graphs/', filename), taskGraphUrl: join('assets/task-graphs/', filename),
taskInputsUrl: join('assets/task-inputs/', filename),
}; };
}); });
} catch { } catch {

View File

@ -32,12 +32,19 @@ async function generateGraph(directory: string, name: string) {
/window.taskGraphResponse = (.*?);/ /window.taskGraphResponse = (.*?);/
); );
const expandedTaskInputsReponse = environmentJs.match(
/window.expandedTaskInputsResponse = (.*?);/
);
ensureDirSync( ensureDirSync(
join(__dirname, '../graph/client/src/assets/generated-project-graphs/') join(__dirname, '../graph/client/src/assets/generated-project-graphs/')
); );
ensureDirSync( ensureDirSync(
join(__dirname, '../graph/client/src/assets/generated-task-graphs/') join(__dirname, '../graph/client/src/assets/generated-task-graphs/')
); );
ensureDirSync(
join(__dirname, '../graph/client/src/assets/generated-task-inputs/')
);
writeFileSync( writeFileSync(
join( join(
@ -56,6 +63,15 @@ async function generateGraph(directory: string, name: string) {
), ),
taskGraphResponse[1] taskGraphResponse[1]
); );
writeFileSync(
join(
__dirname,
'../graph/client/src/assets/generated-task-inputs/',
`${name}.json`
),
expandedTaskInputsReponse[1]
);
} }
(async () => { (async () => {