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/generated-project-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/images/open-graph

View File

@ -1,3 +1,4 @@
import { parseJson } from '@nx/devkit';
import {
checkFilesExist,
cleanupProject,
@ -8,6 +9,7 @@ import {
setMaxWorkers,
uniq,
updateFile,
readFile,
updateJson,
} from '@nx/e2e/utils';
import { join } from 'path';
@ -321,4 +323,108 @@ describe('Extra Nx Misc Tests', () => {
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,
getUncheckedProjectItems,
getUnfocusProjectButton,
openTooltipForNode,
} from '../support/app.po';
import * as affectedJson from '../fixtures/affected.json';
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', () => {
before(() => {
@ -183,4 +184,54 @@ describe('dev mode - task graph', () => {
// and also new /projects route
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 = () =>
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/generated-project-graphs/",
"graph/client/src/assets/generated-task-graphs/",
"graph/client/src/assets/generated-task-inputs/",
{
"input": "graph/client/src/assets/dev",
"output": "/",
@ -79,6 +80,7 @@
"graph/client/src/favicon.ico",
"graph/client/src/assets/project-graphs/",
"graph/client/src/assets/task-graphs/",
"graph/client/src/assets/task-inputs/",
{
"input": "graph/client/src/assets/dev-e2e",
"output": "/",

View File

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

View File

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

View File

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

View File

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

View File

@ -8,6 +8,8 @@ import type {
import { ProjectGraphService } from './interfaces';
export class FetchProjectGraphService implements ProjectGraphService {
private taskInputsUrl: string;
async getHash(): Promise<string> {
const request = new Request('currentHash', { mode: 'no-cors' });
@ -31,4 +33,22 @@ export class FetchProjectGraphService implements ProjectGraphService {
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;
projectGraphUrl: string;
taskGraphUrl: string;
taskInputsUrl: string;
}
export interface WorkspaceLayout {
@ -22,6 +23,8 @@ export interface ProjectGraphService {
getHash: () => Promise<string>;
getProjectGraph: (url: string) => Promise<ProjectGraphClientResponse>;
getTaskGraph: (url: string) => Promise<TaskGraphClientResponse>;
setTaskInputsUrl?: (url: string) => void;
getExpandedTaskInputs?: (taskId: string) => Promise<Record<string, string[]>>;
}
export interface Environment {

View File

@ -19,4 +19,12 @@ export class LocalProjectGraphService implements ProjectGraphService {
async getTaskGraph(url: string): Promise<TaskGraphClientResponse> {
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 { selectValueByThemeStatic } from '../theme-resolver';
import { getEnvironmentConfig } from '../hooks/use-environment-config';
import { getProjectGraphDataService } from '../hooks/get-project-graph-data-service';
let graphService: GraphService;
export function getGraphService(): GraphService {
const environment = getEnvironmentConfig();
if (!graphService) {
const projectDataService = getProjectGraphDataService();
graphService = new GraphService(
'cytoscape-graph',
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
);
projectGraphDataService.setTaskInputsUrl?.(workspaceInfo.taskInputsUrl);
const projectGraph: ProjectGraphClientResponse =
await projectGraphDataService.getProjectGraph(
workspaceInfo.projectGraphUrl

View File

@ -7,6 +7,7 @@ import {
Tooltip,
} from '@nx/graph/ui-tooltips';
import { ProjectNodeActions } from './project-node-actions';
import { TaskNodeActions } from './task-node-actions';
const tooltipService = getTooltipService();
@ -29,7 +30,11 @@ export function TooltipDisplay() {
tooltipToRender = <ProjectEdgeNodeTooltip {...currentTooltip.props} />;
break;
case 'taskNode':
tooltipToRender = <TaskNodeTooltip {...currentTooltip.props} />;
tooltipToRender = (
<TaskNodeTooltip {...currentTooltip.props}>
<TaskNodeActions {...currentTooltip.props} />
</TaskNodeTooltip>
);
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',
projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
taskInputsUrl: 'assets/task-inputs/e2e.json',
},
{
id: 'affected',
label: 'affected',
projectGraphUrl: 'assets/project-graphs/affected.json',
taskGraphUrl: 'assets/task-graphs/affected.json',
taskInputsUrl: 'assets/task-inputs/affected.json',
},
],
defaultWorkspaceId: 'e2e',

View File

@ -12,6 +12,7 @@ window.appConfig = {
label: 'local',
projectGraphUrl: 'assets/project-graphs/e2e.json',
taskGraphUrl: 'assets/task-graphs/e2e.json',
taskInputsUrl: 'assets/task-inputs/e2e.json',
},
],
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 */
// nx-ignore-next-line
import type {
ExpandedTaskInputsReponse,
ProjectGraphClientResponse,
TaskGraphClientResponse,
} from 'nx/src/command-line/graph/graph';
@ -15,6 +16,7 @@ export declare global {
localMode: 'serve' | 'build';
projectGraphResponse?: ProjectGraphClientResponse;
taskGraphResponse?: TaskGraphClientResponse;
expandedTaskInputsResponse?: ExpandedTaskInputsReponse;
environment: 'dev' | 'watch' | 'release' | 'nx-console';
appConfig: AppConfig;
useXstateInspect: boolean;

View File

@ -32,7 +32,10 @@ export class GraphService {
container: string | HTMLElement,
theme: 'light' | 'dark',
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(popper);

View File

@ -6,12 +6,13 @@ import {
ProjectEdgeNodeTooltipProps,
} from '@nx/graph/ui-tooltips';
import { TooltipEvent } from './interfaces';
import { GraphInteractionEvents } from './graph-interaction-events';
export class GraphTooltipService {
private subscribers: Set<Function> = new Set();
constructor(graph: GraphService) {
graph.listen((event) => {
graph.listen((event: GraphInteractionEvents) => {
switch (event.type) {
case 'GraphRegenerated':
this.hideAll();
@ -49,6 +50,20 @@ export class GraphTooltipService {
...event.data,
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;
case 'EdgeClick':
const callback =

View File

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

View File

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

View File

@ -16,7 +16,6 @@ export function DocumentList({
documents: DocumentMetadata[];
}): JSX.Element {
return (
<>
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
{!!documents.length ? (
documents.map((guide) => (
@ -26,7 +25,6 @@ export function DocumentList({
<EmptyList type="document" />
)}
</ul>
</>
);
}
@ -63,7 +61,6 @@ export function SchemaList({
type: 'executor' | 'generator';
}): JSX.Element {
return (
<>
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
{!!files.length ? (
files.map((schema) => (
@ -73,7 +70,6 @@ export function SchemaList({
<EmptyList type={type} />
)}
</ul>
</>
);
}

View File

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

View File

@ -1,36 +1,51 @@
import { workspaceRoot } from '../../utils/workspace-root';
import { createHash } from 'crypto';
import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
import { copySync, ensureDirSync } from 'fs-extra';
import * as http from 'http';
import * as open from 'open';
import { basename, dirname, extname, isAbsolute, join, parse } from 'path';
import { performance } from 'perf_hooks';
import * as minimatch from 'minimatch';
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 { output } from '../../utils/output';
import { writeJsonFile } from '../../utils/fileutils';
import {
ProjectFileMap,
ProjectGraph,
ProjectGraphDependency,
ProjectGraphProjectNode,
} 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 { createProjectGraphAsync } from '../../project-graph/project-graph';
import {
createTaskGraph,
mapTargetDefaultsToDependencies,
} from '../../tasks-runner/create-task-graph';
import { TaskGraph } from '../../config/task-graph';
import { daemonClient } from '../../daemon/client/client';
import { Server } from 'net';
import { readFileMapCache } from '../../project-graph/nx-deps-cache';
import { getAffectedGraphNodes } from '../affected/affected';
import { allFileData } from '../../utils/all-file-data';
import { splitArgsIntoNxArgsAndOverrides } from '../../utils/command-line-utils';
import { NxJsonConfiguration } from '../../config/nx-json';
import { HashPlanner } from '../../native';
import { transformProjectGraphForRust } from '../../native/transform-objects';
import { getAffectedGraphNodes } from '../affected/affected';
import { readFileMapCache } from '../../project-graph/nx-deps-cache';
export interface ProjectGraphClientResponse {
hash: string;
@ -50,6 +65,10 @@ export interface TaskGraphClientResponse {
errors: Record<string, string>;
}
export interface ExpandedTaskInputsReponse {
[taskId: string]: Record<string, string[]>;
}
// maps file extention to MIME types
const mimeType = {
'.ico': 'image/x-icon',
@ -73,7 +92,8 @@ function buildEnvironmentJs(
watchMode: boolean,
localMode: 'build' | 'serve',
depGraphClientResponse?: ProjectGraphClientResponse,
taskGraphClientResponse?: TaskGraphClientResponse
taskGraphClientResponse?: TaskGraphClientResponse,
expandedTaskInputsReponse?: ExpandedTaskInputsReponse
) {
let environmentJs = `window.exclude = ${JSON.stringify(exclude)};
window.watch = ${!!watchMode};
@ -88,7 +108,8 @@ function buildEnvironmentJs(
id: 'local',
label: 'local',
projectGraphUrl: 'project-graph.json',
taskGraphUrl: 'task-graph.json'
taskGraphUrl: 'task-graph.json',
taskInputsUrl: 'task-inputs.json',
}
],
defaultWorkspaceId: 'local',
@ -105,9 +126,13 @@ function buildEnvironmentJs(
taskGraphClientResponse
)};
`;
environmentJs += `window.expandedTaskInputsResponse = ${JSON.stringify(
expandedTaskInputsReponse
)};`;
} else {
environmentJs += `window.projectGraphResponse = null;`;
environmentJs += `window.taskGraphResponse = null;`;
environmentJs += `window.expandedTaskInputsResponse = null;`;
}
return environmentJs;
@ -318,13 +343,18 @@ export async function generateGraph(
);
const taskGraphClientResponse = await createTaskGraphClientResponse();
const taskInputsReponse = await createExpandedTaskInputResponse(
taskGraphClientResponse,
depGraphClientResponse
);
const environmentJs = buildEnvironmentJs(
args.exclude || [],
args.watch,
!!args.file && args.file.endsWith('html') ? 'build' : 'serve',
depGraphClientResponse,
taskGraphClientResponse
taskGraphClientResponse,
taskInputsReponse
);
html = html.replace(/src="/g, 'src="static/');
html = html.replace(/href="styles/g, 'href="static/styles');
@ -455,7 +485,6 @@ async function startServer(
// by limiting the path to current directory only
const sanitizePath = basename(parsedUrl.pathname);
if (sanitizePath === 'project-graph.json') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(currentDepGraphClientResponse));
@ -468,6 +497,23 @@ async function startServer(
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') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ hash: currentDepGraphClientResponse.hash }));
@ -481,7 +527,6 @@ async function startServer(
}
let pathname = join(__dirname, '../../core/graph/', sanitizePath);
// if the file is not found or is a directory, return index.html
if (!existsSync(pathname) || statSync(pathname).isDirectory()) {
res.writeHead(200, { 'Content-Type': 'text/html' });
@ -618,10 +663,17 @@ async function createDepGraphClientResponse(
};
}
async function createTaskGraphClientResponse(): Promise<TaskGraphClientResponse> {
let graph = pruneExternalNodes(
async function createTaskGraphClientResponse(
pruneExternal: boolean = false
): Promise<TaskGraphClientResponse> {
let graph: ProjectGraph;
if (pruneExternal) {
graph = pruneExternalNodes(
await createProjectGraphAsync({ exitOnError: true })
);
} else {
graph = await createProjectGraphAsync({ exitOnError: true });
}
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(
nxJson: NxJsonConfiguration,
projectGraph: ProjectGraph
@ -684,7 +766,7 @@ function getAllTaskGraphsForWorkspace(
// TODO(cammisuli): improve performance here. Cache results or something.
for (const projectName in projectGraph.nodes) {
const project = projectGraph.nodes[projectName];
const targets = Object.keys(project.data.targets);
const targets = Object.keys(project.data.targets ?? {});
targets.forEach((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 {
tasks?: TaskGraph;
graph: ProjectGraph;

View File

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

View File

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

View File

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