diff --git a/graph/client/src/app/external-api-impl.ts b/graph/client/src/app/external-api-impl.ts new file mode 100644 index 0000000000..a68a1499ad --- /dev/null +++ b/graph/client/src/app/external-api-impl.ts @@ -0,0 +1,133 @@ +import { ExternalApi, getExternalApiService } from '@nx/graph/shared'; +import { getRouter } from './get-router'; +import { getProjectGraphService } from './machines/get-services'; +import { getGraphService } from './machines/graph.service'; + +export class ExternalApiImpl extends ExternalApi { + _projectGraphService = getProjectGraphService(); + _graphIsReady = new Promise((resolve) => { + this._projectGraphService.subscribe((state) => { + if (!state.matches('idle')) { + resolve(); + } + }); + }); + _graphService = getGraphService(); + + router = getRouter(); + externalApiService = getExternalApiService(); + + constructor() { + super(); + this.externalApiService.subscribe( + ({ type, payload }: { type: string; payload: any }) => { + if (!this.graphInteractionEventListener) { + console.log('graphInteractionEventListener not registered.'); + return; + } + if (type === 'file-click') { + const url = `${payload.sourceRoot}/${payload.file}`; + this.graphInteractionEventListener({ + type: 'file-click', + payload: { url }, + }); + } else if (type === 'open-project-config') { + this.graphInteractionEventListener({ + type: 'open-project-config', + payload, + }); + } else if (type === 'run-task') { + this.graphInteractionEventListener({ + type: 'run-task', + payload, + }); + } else if (type === 'open-project-graph') { + this.graphInteractionEventListener({ + type: 'open-project-graph', + payload, + }); + } else if (type === 'open-task-graph') { + this.graphInteractionEventListener({ + type: 'open-task-graph', + payload, + }); + } else if (type === 'override-target') { + this.graphInteractionEventListener({ + type: 'override-target', + payload, + }); + } else { + console.log('unhandled event', type, payload); + } + } + ); + + // make sure properties set before are taken into account again + if (window.externalApi?.loadProjectGraph) { + this.loadProjectGraph = window.externalApi.loadProjectGraph; + } + if (window.externalApi?.loadTaskGraph) { + this.loadTaskGraph = window.externalApi.loadTaskGraph; + } + if (window.externalApi?.loadExpandedTaskInputs) { + this.loadExpandedTaskInputs = window.externalApi.loadExpandedTaskInputs; + } + if (window.externalApi?.loadSourceMaps) { + this.loadSourceMaps = window.externalApi.loadSourceMaps; + } + if (window.externalApi?.graphInteractionEventListener) { + this.graphInteractionEventListener = + window.externalApi.graphInteractionEventListener; + } + } + + focusProject(projectName: string) { + this.router.navigate(`/projects/${encodeURIComponent(projectName)}`); + } + + toggleSelectProject(projectName: string) { + this._graphIsReady.then(() => { + const projectSelected = this._projectGraphService + .getSnapshot() + .context.selectedProjects.find((p) => p === projectName); + if (!projectSelected) { + this._projectGraphService.send({ type: 'selectProject', projectName }); + } else { + this._projectGraphService.send({ + type: 'deselectProject', + projectName, + }); + } + }); + } + + selectAllProjects() { + this.router.navigate(`/projects/all`); + } + + showAffectedProjects() { + this.router.navigate(`/projects/affected`); + } + + focusTarget(projectName: string, targetName: string) { + this.router.navigate( + `/tasks/${encodeURIComponent(targetName)}?projects=${encodeURIComponent( + projectName + )}` + ); + } + + selectAllTargetsByName(targetName: string) { + this.router.navigate(`/tasks/${encodeURIComponent(targetName)}/all`); + } + + enableExperimentalFeatures() { + localStorage.setItem('showExperimentalFeatures', 'true'); + window.appConfig.showExperimentalFeatures = true; + } + + disableExperimentalFeatures() { + localStorage.setItem('showExperimentalFeatures', 'false'); + window.appConfig.showExperimentalFeatures = false; + } +} diff --git a/graph/client/src/app/external-api.ts b/graph/client/src/app/external-api.ts deleted file mode 100644 index 0a02689800..0000000000 --- a/graph/client/src/app/external-api.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { getRouter } from './get-router'; -import { getProjectGraphService } from './machines/get-services'; -import { ProjectGraphMachineEvents } from './feature-projects/machines/interfaces'; -import { getGraphService } from './machines/graph.service'; - -export class ExternalApi { - _projectGraphService = getProjectGraphService(); - _graphIsReady = new Promise((resolve) => { - this._projectGraphService.subscribe((state) => { - if (!state.matches('idle')) { - resolve(); - } - }); - }); - - router = getRouter(); - graphService = getGraphService(); - - projectGraphService = { - send: (event: ProjectGraphMachineEvents) => { - this.handleLegacyProjectGraphEvent(event); - }, - }; - - private fileClickCallbackListeners: ((url: string) => void)[] = []; - private openProjectConfigCallbackListeners: (( - projectName: string - ) => void)[] = []; - private runTaskCallbackListeners: ((taskId: string) => void)[] = []; - - get depGraphService() { - return this.projectGraphService; - } - - constructor() { - this.graphService.listen((event) => { - if (event.type === 'FileLinkClick') { - const url = `${event.sourceRoot}/${event.file}`; - this.fileClickCallbackListeners.forEach((cb) => cb(url)); - } - if (event.type === 'ProjectOpenConfigClick') { - this.openProjectConfigCallbackListeners.forEach((cb) => - cb(event.projectName) - ); - } - if (event.type === 'RunTaskClick') { - this.runTaskCallbackListeners.forEach((cb) => cb(event.taskId)); - } - }); - } - - focusProject(projectName: string) { - this.router.navigate(`/projects/${encodeURIComponent(projectName)}`); - } - - selectAllProjects() { - this.router.navigate(`/projects/all`); - } - - enableExperimentalFeatures() { - localStorage.setItem('showExperimentalFeatures', 'true'); - window.appConfig.showExperimentalFeatures = true; - } - - disableExperimentalFeatures() { - localStorage.setItem('showExperimentalFeatures', 'false'); - window.appConfig.showExperimentalFeatures = false; - } - - registerFileClickCallback(callback: (url: string) => void) { - this.fileClickCallbackListeners.push(callback); - } - registerOpenProjectConfigCallback(callback: (projectName: string) => void) { - this.openProjectConfigCallbackListeners.push(callback); - } - registerRunTaskCallback(callback: (taskId: string) => void) { - this.runTaskCallbackListeners.push(callback); - } - - private handleLegacyProjectGraphEvent(event: ProjectGraphMachineEvents) { - switch (event.type) { - case 'focusProject': - this.focusProject(event.projectName); - break; - case 'selectAll': - this.selectAllProjects(); - break; - default: - this._graphIsReady.then(() => this._projectGraphService.send(event)); - break; - } - } -} diff --git a/graph/client/src/app/feature-projects/machines/project-graph.spec.ts b/graph/client/src/app/feature-projects/machines/project-graph.spec.ts index 519c3cdd2d..1a4a47bdd5 100644 --- a/graph/client/src/app/feature-projects/machines/project-graph.spec.ts +++ b/graph/client/src/app/feature-projects/machines/project-graph.spec.ts @@ -7,7 +7,7 @@ import type { /* eslint-enable @nx/enforce-module-boundaries */ import { interpret } from 'xstate'; import { projectGraphMachine } from './project-graph.machine'; -import { AppConfig } from '../../interfaces'; +import { AppConfig } from '@nx/graph/shared'; export const mockProjects: ProjectGraphProjectNode[] = [ { diff --git a/graph/client/src/app/feature-projects/project-list.tsx b/graph/client/src/app/feature-projects/project-list.tsx index ca1c3560b3..f6c1f82f3f 100644 --- a/graph/client/src/app/feature-projects/project-list.tsx +++ b/graph/client/src/app/feature-projects/project-list.tsx @@ -15,15 +15,12 @@ import { selectedProjectNamesSelector, workspaceLayoutSelector, } from './machines/selectors'; -import { - getProjectsByType, - parseParentDirectoriesFromFilePath, - useRouteConstructor, -} from '../util'; +import { getProjectsByType, parseParentDirectoriesFromFilePath } from '../util'; import { ExperimentalFeature } from '../ui-components/experimental-feature'; import { TracingAlgorithmType } from './machines/interfaces'; import { getProjectGraphService } from '../machines/get-services'; import { Link, useNavigate } from 'react-router-dom'; +import { useRouteConstructor } from '@nx/graph/shared'; interface SidebarProject { projectGraphNode: ProjectGraphNode; diff --git a/graph/client/src/app/feature-projects/projects-sidebar.tsx b/graph/client/src/app/feature-projects/projects-sidebar.tsx index fc8e54521e..ba9e665315 100644 --- a/graph/client/src/app/feature-projects/projects-sidebar.tsx +++ b/graph/client/src/app/feature-projects/projects-sidebar.tsx @@ -1,6 +1,11 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { useIntervalWhen } from '../hooks/use-interval-when'; +import { getProjectGraphService } from '../machines/get-services'; import { ExperimentalFeature } from '../ui-components/experimental-feature'; +import { FocusedPanel } from '../ui-components/focused-panel'; +import { ShowHideAll } from '../ui-components/show-hide-all'; import { useProjectGraphSelector } from './hooks/use-project-graph-selector'; +import { TracingAlgorithmType } from './machines/interfaces'; import { collapseEdgesSelector, focusedProjectNameSelector, @@ -12,21 +17,17 @@ import { textFilterSelector, } from './machines/selectors'; import { CollapseEdgesPanel } from './panels/collapse-edges-panel'; -import { FocusedPanel } from '../ui-components/focused-panel'; import { GroupByFolderPanel } from './panels/group-by-folder-panel'; -import { ProjectList } from './project-list'; import { SearchDepth } from './panels/search-depth'; -import { ShowHideAll } from '../ui-components/show-hide-all'; import { TextFilterPanel } from './panels/text-filter-panel'; import { TracingPanel } from './panels/tracing-panel'; -import { useEnvironmentConfig } from '../hooks/use-environment-config'; -import { TracingAlgorithmType } from './machines/interfaces'; -import { getProjectGraphService } from '../machines/get-services'; -import { useIntervalWhen } from '../hooks/use-interval-when'; +import { ProjectList } from './project-list'; /* eslint-disable @nx/enforce-module-boundaries */ // nx-ignore-next-line import { ProjectGraphClientResponse } from 'nx/src/command-line/graph/graph'; /* eslint-enable @nx/enforce-module-boundaries */ +import { useFloating } from '@floating-ui/react'; +import { useEnvironmentConfig, useRouteConstructor } from '@nx/graph/shared'; import { useNavigate, useParams, @@ -35,7 +36,7 @@ import { } from 'react-router-dom'; import { getProjectGraphDataService } from '../hooks/get-project-graph-data-service'; import { useCurrentPath } from '../hooks/use-current-path'; -import { useRouteConstructor } from '../util'; +import { ProjectDetailsModal } from '../ui-components/project-details-modal'; export function ProjectsSidebar(): JSX.Element { const environmentConfig = useEnvironmentConfig(); @@ -329,6 +330,8 @@ export function ProjectsSidebar(): JSX.Element { return ( <> + + {focusedProject ? ( { const [lastLocation, setLastLocation] = useState(); diff --git a/graph/client/src/app/interfaces.ts b/graph/client/src/app/interfaces.ts index 38fe32b8c3..6b4fc69873 100644 --- a/graph/client/src/app/interfaces.ts +++ b/graph/client/src/app/interfaces.ts @@ -6,15 +6,6 @@ import type { } from 'nx/src/command-line/graph/graph'; /* eslint-enable @nx/enforce-module-boundaries */ -export interface WorkspaceData { - id: string; - label: string; - projectGraphUrl: string; - taskGraphUrl: string; - taskInputsUrl: string; - sourceMapsUrl: string; -} - export interface WorkspaceLayout { libsDir: string; appsDir: string; @@ -35,13 +26,6 @@ export interface Environment { environment: 'dev' | 'watch' | 'release'; } -export interface AppConfig { - showDebugger: boolean; - showExperimentalFeatures: boolean; - workspaces: WorkspaceData[]; - defaultWorkspaceId: string; -} - export interface GraphPerfReport { renderTime: number; numNodes: number; diff --git a/graph/client/src/app/machines/graph.service.ts b/graph/client/src/app/machines/graph.service.ts index 3706a807e8..b067b5158e 100644 --- a/graph/client/src/app/machines/graph.service.ts +++ b/graph/client/src/app/machines/graph.service.ts @@ -1,7 +1,7 @@ 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'; +import { getEnvironmentConfig } from '@nx/graph/shared'; let graphService: GraphService; diff --git a/graph/client/src/app/nx-console-project-graph-service.ts b/graph/client/src/app/nx-console-project-graph-service.ts new file mode 100644 index 0000000000..d359240257 --- /dev/null +++ b/graph/client/src/app/nx-console-project-graph-service.ts @@ -0,0 +1,34 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import type { + ProjectGraphClientResponse, + TaskGraphClientResponse, +} from 'nx/src/command-line/graph/graph'; +import { ProjectGraphService } from './interfaces'; + +export class NxConsoleProjectGraphService implements ProjectGraphService { + async getHash(): Promise { + return new Promise((resolve) => resolve('some-hash')); + } + + async getProjectGraph(url: string): Promise { + return await window.externalApi.loadProjectGraph?.(url); + } + + async getTaskGraph(url: string): Promise { + return await window.externalApi.loadTaskGraph?.(url); + } + + async getExpandedTaskInputs( + taskId: string + ): Promise> { + const res = await window.externalApi.loadExpandedTaskInputs?.(taskId); + return res ? res[taskId] : {}; + } + + async getSourceMaps( + url: string + ): Promise>> { + return await window.externalApi.loadSourceMaps?.(url); + } +} diff --git a/graph/client/src/app/routes.tsx b/graph/client/src/app/routes.tsx index 4da0b5d46a..2e492e929c 100644 --- a/graph/client/src/app/routes.tsx +++ b/graph/client/src/app/routes.tsx @@ -1,15 +1,15 @@ -import { Shell } from './shell'; import { redirect, RouteObject } from 'react-router-dom'; import { ProjectsSidebar } from './feature-projects/projects-sidebar'; import { TasksSidebar } from './feature-tasks/tasks-sidebar'; -import { getEnvironmentConfig } from './hooks/use-environment-config'; +import { Shell } from './shell'; /* eslint-disable @nx/enforce-module-boundaries */ // nx-ignore-next-line import { ProjectGraphClientResponse } from 'nx/src/command-line/graph/graph'; /* eslint-enable @nx/enforce-module-boundaries */ -import { getProjectGraphDataService } from './hooks/get-project-graph-data-service'; +import { ProjectDetailsPage } from '@nx/graph/project-details'; +import { getEnvironmentConfig } from '@nx/graph/shared'; import { TasksSidebarErrorBoundary } from './feature-tasks/tasks-sidebar-error-boundary'; -import { ProjectDetails } from '@nx/graph/project-details'; +import { getProjectGraphDataService } from './hooks/get-project-graph-data-service'; const { appConfig } = getEnvironmentConfig(); const projectGraphDataService = getProjectGraphDataService(); @@ -44,7 +44,9 @@ const workspaceDataLoader = async (selectedWorkspaceId: string) => { const targets = Array.from(targetsSet).sort((a, b) => a.localeCompare(b)); - return { ...projectGraph, targets }; + const sourceMaps = await sourceMapsLoader(selectedWorkspaceId); + + return { ...projectGraph, targets, sourceMaps }; }; const taskDataLoader = async (selectedWorkspaceId: string) => { @@ -176,7 +178,7 @@ export const devRoutes: RouteObject[] = [ { path: ':selectedWorkspaceId/project-details/:projectName', id: 'selectedProjectDetails', - element: , + element: , loader: async ({ request, params }) => { const projectName = params.projectName; return projectDetailsLoader(params.selectedWorkspaceId, projectName); @@ -213,7 +215,7 @@ export const releaseRoutes: RouteObject[] = [ { path: 'project-details/:projectName', id: 'selectedProjectDetails', - element: , + element: , loader: async ({ request, params }) => { const projectName = params.projectName; return projectDetailsLoader(appConfig.defaultWorkspaceId, projectName); diff --git a/graph/client/src/app/shell.tsx b/graph/client/src/app/shell.tsx index 4c6c91dd90..66aa63a6c1 100644 --- a/graph/client/src/app/shell.tsx +++ b/graph/client/src/app/shell.tsx @@ -5,7 +5,6 @@ import { } from '@heroicons/react/24/outline'; import classNames from 'classnames'; import { DebuggerPanel } from './ui-components/debugger-panel'; -import { useEnvironmentConfig } from './hooks/use-environment-config'; import { getGraphService } from './machines/graph.service'; import { Outlet, useNavigate, useParams } from 'react-router-dom'; import { ThemePanel } from './feature-projects/panels/theme-panel'; @@ -17,6 +16,7 @@ import { getProjectGraphService } from './machines/get-services'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; import { Tooltip } from '@nx/graph/ui-tooltips'; import { TooltipDisplay } from './ui-tooltips/graph-tooltip-display'; +import { useEnvironmentConfig } from '@nx/graph/shared'; export function Shell(): JSX.Element { const projectGraphService = getProjectGraphService(); diff --git a/graph/client/src/app/ui-components/debugger-panel.tsx b/graph/client/src/app/ui-components/debugger-panel.tsx index 2b043402b5..40ff6d76f0 100644 --- a/graph/client/src/app/ui-components/debugger-panel.tsx +++ b/graph/client/src/app/ui-components/debugger-panel.tsx @@ -1,6 +1,7 @@ import { memo } from 'react'; -import { WorkspaceData, GraphPerfReport } from '../interfaces'; +import { GraphPerfReport } from '../interfaces'; import { Dropdown } from '@nx/graph/ui-components'; +import type { WorkspaceData } from '@nx/graph/shared'; export interface DebuggerPanelProps { projects: WorkspaceData[]; diff --git a/graph/client/src/app/ui-components/experimental-feature.tsx b/graph/client/src/app/ui-components/experimental-feature.tsx index 89574c0428..a1c262e3db 100644 --- a/graph/client/src/app/ui-components/experimental-feature.tsx +++ b/graph/client/src/app/ui-components/experimental-feature.tsx @@ -1,4 +1,4 @@ -import { useEnvironmentConfig } from '../hooks/use-environment-config'; +import { useEnvironmentConfig } from '@nx/graph/shared'; import { Children, cloneElement } from 'react'; export function ExperimentalFeature(props) { diff --git a/graph/client/src/app/ui-components/project-details-modal.tsx b/graph/client/src/app/ui-components/project-details-modal.tsx new file mode 100644 index 0000000000..c2ee74de7d --- /dev/null +++ b/graph/client/src/app/ui-components/project-details-modal.tsx @@ -0,0 +1,67 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import { useFloating } from '@floating-ui/react'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { ProjectDetails } from '@nx/graph/project-details'; +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import { ProjectGraphClientResponse } from 'nx/src/command-line/graph/graph'; +import { useEffect, useState } from 'react'; +import { useRouteLoaderData, useSearchParams } from 'react-router-dom'; + +export function ProjectDetailsModal() { + const workspaceData = useRouteLoaderData( + 'selectedWorkspace' + ) as ProjectGraphClientResponse & { sourceMaps: string[] }; + const [project, setProject] = useState(null); + const [sourceMap, setSourceMap] = useState(null); + + const [searchParams, setSearchParams] = useSearchParams(); + + const [isOpen, setIsOpen] = useState(false); + const { refs } = useFloating({ + open: isOpen, + strategy: 'fixed', + placement: 'right', + }); + + useEffect(() => { + if (searchParams.has('projectDetails')) { + const projectName = searchParams.get('projectDetails'); + const project = workspaceData.projects.find( + (project) => project.name === projectName + ); + if (!project) { + return; + } + const sourceMap = workspaceData.sourceMaps[project.data.root]; + setProject(project); + setSourceMap(sourceMap); + setIsOpen(true); + } + }, [searchParams, workspaceData]); + + function onClose() { + searchParams.delete('projectDetails'); + setSearchParams(searchParams); + setIsOpen(false); + } + return ( + isOpen && ( +
+
+ +
+ +
+
+
+ ) + ); +} diff --git a/graph/client/src/app/ui-tooltips/project-node-actions.tsx b/graph/client/src/app/ui-tooltips/project-node-actions.tsx index aa4931fe84..08ec3e4c4e 100644 --- a/graph/client/src/app/ui-tooltips/project-node-actions.tsx +++ b/graph/client/src/app/ui-tooltips/project-node-actions.tsx @@ -1,9 +1,9 @@ import { ProjectNodeToolTipProps } from '@nx/graph/ui-tooltips'; import { getProjectGraphService } from '../machines/get-services'; -import { useRouteConstructor } from '../util'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { TooltipButton, TooltipLinkButton } from '@nx/graph/ui-tooltips'; import { FlagIcon, MapPinIcon } from '@heroicons/react/24/solid'; +import { useRouteConstructor } from '@nx/graph/shared'; export function ProjectNodeActions({ id }: ProjectNodeToolTipProps) { const projectGraphService = getProjectGraphService(); @@ -12,7 +12,11 @@ export function ProjectNodeActions({ id }: ProjectNodeToolTipProps) { const routeConstructor = useRouteConstructor(); const navigate = useNavigate(); const encodedId = encodeURIComponent(id); + const [searchParams, setSearchParams] = useSearchParams(); + function onProjectDetails() { + setSearchParams({ projectDetails: id }); + } function onExclude() { projectGraphService.send({ type: 'deselectProject', @@ -36,6 +40,7 @@ export function ProjectNodeActions({ id }: ProjectNodeToolTipProps) { return (
+ {/* Project Details */} Focus diff --git a/graph/client/src/app/util.ts b/graph/client/src/app/util.ts index d0ec09a1f2..a3d71d40f9 100644 --- a/graph/client/src/app/util.ts +++ b/graph/client/src/app/util.ts @@ -1,49 +1,8 @@ /* eslint-disable @nx/enforce-module-boundaries */ // nx-ignore-next-line import { ProjectGraphDependency, ProjectGraphProjectNode } from '@nx/devkit'; +import { getEnvironmentConfig } from '@nx/graph/shared'; /* eslint-enable @nx/enforce-module-boundaries */ -import { getEnvironmentConfig } from './hooks/use-environment-config'; -import { To, useParams, useSearchParams } from 'react-router-dom'; - -export const useRouteConstructor = (): (( - to: To, - retainSearchParams: boolean -) => To) => { - const { environment } = getEnvironmentConfig(); - const { selectedWorkspaceId } = useParams(); - const [searchParams] = useSearchParams(); - - return (to: To, retainSearchParams: true) => { - let pathname = ''; - - if (typeof to === 'object') { - if (environment === 'dev') { - pathname = `/${selectedWorkspaceId}${to.pathname}`; - } else { - pathname = to.pathname; - } - return { - ...to, - pathname, - search: to.search - ? to.search.toString() - : retainSearchParams - ? searchParams.toString() - : '', - }; - } else if (typeof to === 'string') { - if (environment === 'dev') { - pathname = `/${selectedWorkspaceId}${to}`; - } else { - pathname = to; - } - return { - pathname, - search: retainSearchParams ? searchParams.toString() : '', - }; - } - }; -}; export function parseParentDirectoriesFromFilePath( path: string, diff --git a/graph/client/src/globals.d.ts b/graph/client/src/globals.d.ts index 5b33b45525..8d7552d1cb 100644 --- a/graph/client/src/globals.d.ts +++ b/graph/client/src/globals.d.ts @@ -5,29 +5,23 @@ import type { ProjectGraphClientResponse, TaskGraphClientResponse, } from 'nx/src/command-line/graph/graph'; -/* eslint-enable @nx/enforce-module-boundaries */ -import { AppConfig } from './app/interfaces'; -import { ExternalApi } from './app/external-api'; -/* eslint-disable @nx/enforce-module-boundaries */ -// nx-ignore-next-line -import { ConfigurationSourceMaps } from '../../project-graph/utils/project-configuration-utils'; +import { AppConfig, ExternalApi } from '@nx/graph/shared'; export declare global { - export interface Window { + interface Window { exclude: string[]; watch: boolean; localMode: 'serve' | 'build'; projectGraphResponse?: ProjectGraphClientResponse; taskGraphResponse?: TaskGraphClientResponse; expandedTaskInputsResponse?: ExpandedTaskInputsReponse; - sourceMapsResponse?: ConfigurationSourceMaps; + sourceMapsResponse?: Record>; environment: 'dev' | 'watch' | 'release' | 'nx-console'; appConfig: AppConfig; useXstateInspect: boolean; externalApi?: ExternalApi; } } - declare module 'cytoscape' { interface Core { anywherePanning: Function; diff --git a/graph/client/src/main.tsx b/graph/client/src/main.tsx index 953f6887c3..57ab842a11 100644 --- a/graph/client/src/main.tsx +++ b/graph/client/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react'; import { inspect } from '@xstate/inspect'; import { App } from './app/app'; -import { ExternalApi } from './app/external-api'; +import { ExternalApiImpl } from './app/external-api-impl'; import { render } from 'preact'; if (window.useXstateInspect === true) { @@ -11,7 +11,7 @@ if (window.useXstateInspect === true) { }); } -window.externalApi = new ExternalApi(); +window.externalApi = new ExternalApiImpl(); const container = document.getElementById('app'); if (!window.appConfig) { diff --git a/graph/project-details/src/index.ts b/graph/project-details/src/index.ts index cbfecfc451..8bb12e02f3 100644 --- a/graph/project-details/src/index.ts +++ b/graph/project-details/src/index.ts @@ -1 +1,2 @@ export * from './lib/project-details'; +export * from './lib/project-details-page'; diff --git a/graph/project-details/src/lib/json-line-renderer.tsx b/graph/project-details/src/lib/json-line-renderer.tsx new file mode 100644 index 0000000000..ecd89a0da4 --- /dev/null +++ b/graph/project-details/src/lib/json-line-renderer.tsx @@ -0,0 +1,288 @@ +import { + ChevronDownIcon, + ChevronRightIcon, + EyeIcon, + PlayIcon, +} from '@heroicons/react/24/outline'; +import { getSourceInformation } from './get-source-information'; +import useMapState from './use-map-state'; +import { + getExternalApiService, + useEnvironmentConfig, + useRouteConstructor, +} from '@nx/graph/shared'; +import { useNavigate } from 'react-router-dom'; +import { get } from 'http'; +import { useEffect } from 'react'; + +interface JsonLineRendererProps { + jsonData: any; + sourceMap: Record; +} + +export function JsonLineRenderer(props: JsonLineRendererProps) { + let collapsibleSections = new Map(); + let lines: [string, number][] = []; + let currentLine = 0; + let lineToPropertyPathMap = new Map(); + let lineToInteractionMap = new Map< + number, + { target: string; configuration?: string } + >(); + + const [getCollapsed, setCollapsed] = useMapState(); + const { environment } = useEnvironmentConfig(); + const externalApiService = getExternalApiService(); + const navigate = useNavigate(); + const routeContructor = useRouteConstructor(); + + function add(value: string, depth: number) { + if (lines.length === currentLine) { + lines.push(['', depth]); + } + lines[currentLine] = [lines[currentLine][0] + value, depth]; + } + + function processJson( + jsonData: any, + depth = 0, + propertyPath = '', + isLast = false + ) { + if (Array.isArray(jsonData)) { + const sectionStart = currentLine; + add('[', depth); + currentLine++; + + jsonData.forEach((value, index) => { + const newPropertyPath = `${ + propertyPath ? propertyPath + '.' : '' + }${value}`; + lineToPropertyPathMap.set(currentLine, newPropertyPath); + + processJson( + value, + depth + 1, + newPropertyPath, + index === jsonData.length - 1 + ); + }); + + add(']', depth); + if (!isLast) { + add(',', depth); + } + const sectionEnd = currentLine; + collapsibleSections.set(sectionStart, sectionEnd); + currentLine++; + } else if (jsonData && typeof jsonData === 'object') { + const sectionStart = currentLine; + add('{', depth); + currentLine++; + + Object.entries(jsonData).forEach(([key, value], index, array) => { + // skip empty objects + if ( + Object.keys(value as any).length === 0 && + typeof value === 'object' + ) { + return; + } + + // skip certain root properties + if ( + depth === 0 && + (key === 'sourceRoot' || + key === 'name' || + key === '$schema' || + key === 'tags') + ) { + return; + } + + add(`"${key}": `, depth); + + if (propertyPath === 'targets') { + lineToInteractionMap.set(currentLine, { target: key }); + } + if (propertyPath.match(/^targets\..*configurations$/)) { + lineToInteractionMap.set(currentLine, { + target: propertyPath.split('.')[1], + configuration: key, + }); + } + + const newPropertyPath = `${ + propertyPath ? propertyPath + '.' : '' + }${key}`; + lineToPropertyPathMap.set(currentLine, newPropertyPath); + + processJson( + value, + depth + 1, + newPropertyPath, + index === array.length - 1 + ); + }); + + add('}', depth); + if (!isLast) { + add(',', depth); + } + const sectionEnd = currentLine; + collapsibleSections.set(sectionStart, sectionEnd); + currentLine++; + } else { + add(`"${jsonData}"`, depth); + if (!isLast) { + add(',', depth); + } + currentLine++; + } + } + + processJson(props.jsonData); + + console.log(lineToInteractionMap); + // start off with all targets & configurations collapsed~ + useEffect(() => { + for (const line of lineToInteractionMap.keys()) { + if (!getCollapsed(line)) { + setCollapsed(line, true); + } + } + }, []); + + function toggleCollapsed(index: number) { + setCollapsed(index, !getCollapsed(index)); + } + + function lineIsCollapsed(index: number) { + for (const [start, end] of collapsibleSections) { + if (index > start && index < end) { + if (getCollapsed(start)) { + return true; + } + } + } + return false; + } + + function runTarget({ + target, + configuration, + }: { + target: string; + configuration?: string; + }) { + const projectName = props.jsonData.name; + + externalApiService.postEvent({ + type: 'run-task', + payload: { taskId: `${projectName}:${target}` }, + }); + } + + function viewInTaskGraph({ + target, + configuration, + }: { + target: string; + configuration?: string; + }) { + const projectName = props.jsonData.name; + if (environment === 'nx-console') { + externalApiService.postEvent({ + type: 'open-task-graph', + payload: { + projectName: projectName, + targetName: target, + }, + }); + } else { + navigate( + routeContructor( + { + pathname: `/tasks/${encodeURIComponent(target)}`, + search: `?projects=${encodeURIComponent(projectName)}`, + }, + true + ) + ); + } + } + + return ( +
+
+ {lines.map(([text, indentation], index) => { + if ( + lineIsCollapsed(index) || + index === 0 || + index === lines.length - 1 + ) { + return null; + } + const canCollapse = + collapsibleSections.has(index) && + collapsibleSections.get(index)! - index > 1; + const interaction = lineToInteractionMap.get(index); + return ( +
+ {interaction?.target && !interaction?.configuration && ( + viewInTaskGraph(interaction!)} + /> + )} + {environment === 'nx-console' && interaction?.target && ( + runTarget(interaction!)} + /> + )} + + {canCollapse && ( +
toggleCollapsed(index)} className="h-4 w-4"> + {getCollapsed(index) ? ( + + ) : ( + + )} +
+ )} +
+ ); + })} +
+
+ {lines.map(([text, indentation], index) => { + if ( + lineIsCollapsed(index) || + index === 0 || + index === lines.length - 1 + ) { + return null; + } + const propertyPathAtLine = lineToPropertyPathMap.get(index); + const sourceInformation = propertyPathAtLine + ? getSourceInformation(props.sourceMap, propertyPathAtLine) + : ''; + return ( +
+              {text}
+              {getCollapsed(index) ? '...' : ''}
+
+              
+                {sourceInformation}
+              
+            
+ ); + })} +
+
+ ); +} diff --git a/graph/project-details/src/lib/project-details-page.tsx b/graph/project-details/src/lib/project-details-page.tsx new file mode 100644 index 0000000000..c1e66eb829 --- /dev/null +++ b/graph/project-details/src/lib/project-details-page.tsx @@ -0,0 +1,16 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import { ProjectGraphProjectNode } from '@nx/devkit'; +import { useRouteLoaderData } from 'react-router-dom'; +import ProjectDetails from './project-details'; + +export function ProjectDetailsPage() { + const { project, sourceMap } = useRouteLoaderData( + 'selectedProjectDetails' + ) as { + project: ProjectGraphProjectNode; + sourceMap: Record; + }; + + return ProjectDetails({ project, sourceMap }); +} diff --git a/graph/project-details/src/lib/project-details.tsx b/graph/project-details/src/lib/project-details.tsx index 181aef9b3b..354c4983c2 100644 --- a/graph/project-details/src/lib/project-details.tsx +++ b/graph/project-details/src/lib/project-details.tsx @@ -1,29 +1,86 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars -import styles from './app.module.css'; -import Target from './target'; -import PropertyRenderer from './property-renderer'; -import { useRouteLoaderData } from 'react-router-dom'; +import { useNavigate, useRouteLoaderData } from 'react-router-dom'; /* eslint-disable @nx/enforce-module-boundaries */ // nx-ignore-next-line import { ProjectGraphProjectNode } from '@nx/devkit'; -export function ProjectDetails() { - const { - project: { - name, - data: { targets, root, ...projectData }, - }, - sourceMap, - } = useRouteLoaderData('selectedProjectDetails') as { - project: ProjectGraphProjectNode; - sourceMap: Record; +import { + getExternalApiService, + useEnvironmentConfig, + useRouteConstructor, +} from '@nx/graph/shared'; +import { JsonLineRenderer } from './json-line-renderer'; +import { EyeIcon } from '@heroicons/react/24/outline'; +import PropertyRenderer from './property-renderer'; +import Target from './target'; + +export interface ProjectDetailsProps { + project: ProjectGraphProjectNode; + sourceMap: Record; +} + +export function ProjectDetails({ + project: { + name, + data: { root, ...projectData }, + }, + sourceMap, +}: ProjectDetailsProps) { + const { environment } = useEnvironmentConfig(); + const externalApiService = getExternalApiService(); + const navigate = useNavigate(); + const routeContructor = useRouteConstructor(); + + const viewInProjectGraph = () => { + if (environment === 'nx-console') { + externalApiService.postEvent({ + type: 'open-project-graph', + payload: { + projectName: name, + }, + }); + } else { + navigate(routeContructor(`/projects/${encodeURIComponent(name)}`, true)); + } }; + // const projectDataSorted = sortObjectWithTargetsFirst(projectData); + // return ( + //
+ //
+ //
+ // + //
+ //
+ //

+ // {name} + //

+ //
+ // {root} + + // {projectData.tags?.map((tag) => ( + //
+ // {tag} + //
+ // ))} + //
+ //
+ //
+ // {JsonLineRenderer({ jsonData: projectDataSorted, sourceMap })} + //
+ // ); + return (
-

{name}

+

+ {name}{' '} + +

{root}{' '} {projectData.tags?.map((tag) => ( @@ -33,12 +90,14 @@ export function ProjectDetails() {

Targets

- {Object.entries(targets ?? {}).map(([targetName, target]) => - Target({ - targetName: targetName, - targetConfiguration: target, - sourceMap, - }) + {Object.entries(projectData.targets ?? {}).map( + ([targetName, target]) => + Target({ + projectName: name, + targetName: targetName, + targetConfiguration: target, + sourceMap, + }) )}
{Object.entries(projectData).map(([key, value]) => { @@ -48,7 +107,8 @@ export function ProjectDetails() { key === 'name' || key === '$schema' || key === 'tags' || - key === 'files' + key === 'files' || + key === 'sourceRoot' ) return undefined; @@ -63,4 +123,22 @@ export function ProjectDetails() { ); } +// function sortObjectWithTargetsFirst(obj: any) { +// let sortedObj: any = {}; + +// // If 'targets' exists, set it as the first property +// if (obj.hasOwnProperty('targets')) { +// sortedObj.targets = obj.targets; +// } + +// // Copy the rest of the properties +// for (let key in obj) { +// if (key !== 'targets') { +// sortedObj[key] = obj[key]; +// } +// } + +// return sortedObj; +// } + export default ProjectDetails; diff --git a/graph/project-details/src/lib/property-renderer.tsx b/graph/project-details/src/lib/property-renderer.tsx index e868561dad..d35c0034c6 100644 --- a/graph/project-details/src/lib/property-renderer.tsx +++ b/graph/project-details/src/lib/property-renderer.tsx @@ -20,14 +20,23 @@ export function PropertyRenderer(props: PropertyRendererProps) { }; return ( -
- {isCollapsible && ( - - )} - {propertyKey}:{' '} - {renderOpening(propertyValue)} +
+ + {isCollapsible && ( + + )} + + {propertyKey} +
+
+ : {renderOpening(propertyValue)} +
+ {!isCollapsed || !isCollapsible ? ( ) : ( diff --git a/graph/project-details/src/lib/target.tsx b/graph/project-details/src/lib/target.tsx index 996c7e701c..305c7c4b8f 100644 --- a/graph/project-details/src/lib/target.tsx +++ b/graph/project-details/src/lib/target.tsx @@ -1,22 +1,123 @@ /* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import { + EyeIcon, + PencilSquareIcon, + PlayIcon, +} from '@heroicons/react/24/outline'; + // nx-ignore-next-line import { TargetConfiguration } from '@nx/devkit'; +import { + getExternalApiService, + useEnvironmentConfig, + useRouteConstructor, +} from '@nx/graph/shared'; +import { useNavigate } from 'react-router-dom'; import PropertyRenderer from './property-renderer'; -import { useState } from 'react'; /* eslint-disable-next-line */ export interface TargetProps { + projectName: string; targetName: string; targetConfiguration: TargetConfiguration; sourceMap: Record; } export function Target(props: TargetProps) { + const { environment } = useEnvironmentConfig(); + const externalApiService = getExternalApiService(); + const navigate = useNavigate(); + const routeContructor = useRouteConstructor(); + + const runTarget = () => { + externalApiService.postEvent({ + type: 'run-task', + payload: { taskId: `${props.projectName}:${props.targetName}` }, + }); + }; + + const viewInTaskGraph = () => { + if (environment === 'nx-console') { + externalApiService.postEvent({ + type: 'open-task-graph', + payload: { + projectName: props.projectName, + targetName: props.targetName, + }, + }); + } else { + navigate( + routeContructor( + { + pathname: `/tasks/${encodeURIComponent(props.targetName)}`, + search: `?projects=${encodeURIComponent(props.projectName)}`, + }, + true + ) + ); + } + }; + + const overrideTarget = () => { + externalApiService.postEvent({ + type: 'override-target', + payload: { + projectName: props.projectName, + targetName: props.targetName, + targetConfigString: JSON.stringify(props.targetConfiguration), + }, + }); + }; + + const shouldDisplayOverrideTarget = () => { + return ( + environment === 'nx-console' && + Object.entries(props.sourceMap ?? {}) + .filter(([key]) => key.startsWith(`targets.${props.targetName}`)) + .every(([, value]) => value[1] !== 'nx-core-build-project-json-nodes') + ); + }; + + const targetConfigurationSortedAndFiltered = Object.entries( + props.targetConfiguration + ) + .filter(([, value]) => { + return ( + value && + (Array.isArray(value) ? value.length : true) && + (typeof value === 'object' ? Object.keys(value).length : true) + ); + }) + .sort(([a], [b]) => { + const order = ['executor', 'inputs', 'outputs']; + const indexA = order.indexOf(a); + const indexB = order.indexOf(b); + + if (indexA !== -1 && indexB !== -1) { + return indexA - indexB; + } else if (indexA !== -1) { + return -1; + } else if (indexB !== -1) { + return 1; + } else { + return a.localeCompare(b); + } + }); return (
-

{props.targetName}

+

+ {props.targetName}{' '} + {environment === 'nx-console' && ( + + )} + + {shouldDisplayOverrideTarget() && ( + + )} +

- {Object.entries(props.targetConfiguration).map(([key, value]) => + {targetConfigurationSortedAndFiltered.map(([key, value]) => PropertyRenderer({ propertyKey: key, propertyValue: value, diff --git a/graph/project-details/src/lib/use-map-state.ts b/graph/project-details/src/lib/use-map-state.ts new file mode 100644 index 0000000000..00668485b0 --- /dev/null +++ b/graph/project-details/src/lib/use-map-state.ts @@ -0,0 +1,21 @@ +import { useState, useCallback } from 'react'; + +function useMapState(initialMap: Map = new Map()) { + const [map, setMap] = useState(new Map(initialMap)); + + // Function to set a key-value pair in the map + const setKey = useCallback((key: K, value: V) => { + setMap((prevMap) => { + const newMap = new Map(prevMap); + newMap.set(key, value); + return newMap; + }); + }, []); + + // Function to get a value by key from the map + const getKey = useCallback((key: K) => map.get(key), [map]); + + return [getKey, setKey] as const; +} + +export default useMapState; diff --git a/graph/project-details/tsconfig.lib.json b/graph/project-details/tsconfig.lib.json index cfc4843293..578a3fb7dd 100644 --- a/graph/project-details/tsconfig.lib.json +++ b/graph/project-details/tsconfig.lib.json @@ -4,7 +4,6 @@ "outDir": "../../dist/out-tsc", "types": [ "node", - "@nx/react/typings/cssmodule.d.ts", "@nx/react/typings/image.d.ts" ] diff --git a/graph/shared/.babelrc b/graph/shared/.babelrc new file mode 100644 index 0000000000..1ea870ead4 --- /dev/null +++ b/graph/shared/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/graph/shared/.eslintrc.json b/graph/shared/.eslintrc.json new file mode 100644 index 0000000000..a39ac5d057 --- /dev/null +++ b/graph/shared/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/graph/shared/README.md b/graph/shared/README.md new file mode 100644 index 0000000000..e11e47550c --- /dev/null +++ b/graph/shared/README.md @@ -0,0 +1,7 @@ +# graph-shared + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test graph-shared` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/graph/shared/jest.config.ts b/graph/shared/jest.config.ts new file mode 100644 index 0000000000..9ba82f79a6 --- /dev/null +++ b/graph/shared/jest.config.ts @@ -0,0 +1,9 @@ +/* eslint-disable */ +export default { + displayName: 'graph-shared', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]sx?$': 'babel-jest', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], +}; diff --git a/graph/shared/project.json b/graph/shared/project.json new file mode 100644 index 0000000000..be1a1a59ea --- /dev/null +++ b/graph/shared/project.json @@ -0,0 +1,21 @@ +{ + "name": "graph-shared", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "graph/shared/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"] + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "graph/shared/jest.config.ts", + "passWithNoTests": true + } + } + } +} diff --git a/graph/shared/src/globals.d.ts b/graph/shared/src/globals.d.ts new file mode 100644 index 0000000000..1f1dc6d954 --- /dev/null +++ b/graph/shared/src/globals.d.ts @@ -0,0 +1,25 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import type { + ExpandedTaskInputsReponse, + ProjectGraphClientResponse, + TaskGraphClientResponse, +} from 'nx/src/command-line/graph/graph'; +import { AppConfig } from './lib/app-config'; +import { ExternalApi } from './lib/external-api'; + +export declare global { + interface Window { + exclude: string[]; + watch: boolean; + localMode: 'serve' | 'build'; + projectGraphResponse?: ProjectGraphClientResponse; + taskGraphResponse?: TaskGraphClientResponse; + expandedTaskInputsResponse?: ExpandedTaskInputsReponse; + sourceMapsResponse?: Record>; + environment: 'dev' | 'watch' | 'release' | 'nx-console'; + appConfig: AppConfig; + useXstateInspect: boolean; + externalApi?: ExternalApi; + } +} diff --git a/graph/shared/src/index.ts b/graph/shared/src/index.ts new file mode 100644 index 0000000000..93220b4e20 --- /dev/null +++ b/graph/shared/src/index.ts @@ -0,0 +1,5 @@ +export * from './lib/external-api'; +export * from './lib/external-api-service'; +export * from './lib/use-environment-config'; +export * from './lib/app-config'; +export * from './lib/use-route-constructor'; diff --git a/graph/shared/src/lib/app-config.ts b/graph/shared/src/lib/app-config.ts new file mode 100644 index 0000000000..b9cf26140e --- /dev/null +++ b/graph/shared/src/lib/app-config.ts @@ -0,0 +1,15 @@ +export interface AppConfig { + showDebugger: boolean; + showExperimentalFeatures: boolean; + workspaces: WorkspaceData[]; + defaultWorkspaceId: string; +} + +export interface WorkspaceData { + id: string; + label: string; + projectGraphUrl: string; + taskGraphUrl: string; + taskInputsUrl: string; + sourceMapsUrl: string; +} diff --git a/graph/shared/src/lib/external-api-service.ts b/graph/shared/src/lib/external-api-service.ts new file mode 100644 index 0000000000..0ad21108f1 --- /dev/null +++ b/graph/shared/src/lib/external-api-service.ts @@ -0,0 +1,24 @@ +let externalApiService: ExternalApiService | null = null; + +export function getExternalApiService() { + if (!externalApiService) { + externalApiService = new ExternalApiService(); + } + + return externalApiService; +} + +export class ExternalApiService { + private subscribers: Set<(event: { type: string; payload: any }) => void> = + new Set(); + + postEvent(event: { type: string; payload: any }) { + this.subscribers.forEach((subscriber) => { + subscriber(event); + }); + } + + subscribe(callback: (event: { type: string; payload: any }) => void) { + this.subscribers.add(callback); + } +} diff --git a/graph/shared/src/lib/external-api.ts b/graph/shared/src/lib/external-api.ts new file mode 100644 index 0000000000..a7e87cb277 --- /dev/null +++ b/graph/shared/src/lib/external-api.ts @@ -0,0 +1,40 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import type { + ProjectGraphClientResponse, + TaskGraphClientResponse, +} from 'nx/src/command-line/graph/graph'; + +export abstract class ExternalApi { + abstract focusProject(projectName: string): void; + + abstract toggleSelectProject(projectName: string): void; + + abstract selectAllProjects(): void; + + abstract showAffectedProjects(): void; + + abstract focusTarget(projectName: string, targetName: string): void; + + abstract selectAllTargetsByName(targetName: string): void; + + abstract enableExperimentalFeatures(): void; + + abstract disableExperimentalFeatures(): void; + + loadProjectGraph: + | ((url: string) => Promise) + | null = null; + loadTaskGraph: ((url: string) => Promise) | null = + null; + loadExpandedTaskInputs: + | ((taskId: string) => Promise>>) + | null = null; + loadSourceMaps: + | ((url: string) => Promise>>) + | null = null; + + graphInteractionEventListener: + | ((event: { type: string; payload: any }) => void | undefined) + | null = null; +} diff --git a/graph/client/src/app/hooks/use-environment-config.ts b/graph/shared/src/lib/use-environment-config.ts similarity index 96% rename from graph/client/src/app/hooks/use-environment-config.ts rename to graph/shared/src/lib/use-environment-config.ts index 373204a3c9..1991ed3bb3 100644 --- a/graph/client/src/app/hooks/use-environment-config.ts +++ b/graph/shared/src/lib/use-environment-config.ts @@ -3,7 +3,7 @@ import type { ProjectGraphClientResponse } from 'nx/src/command-line/graph/graph'; /* eslint-enable @nx/enforce-module-boundaries */ import { useRef } from 'react'; -import { AppConfig } from '../interfaces'; +import { AppConfig } from './app-config'; export function useEnvironmentConfig(): { exclude: string[]; diff --git a/graph/shared/src/lib/use-route-constructor.ts b/graph/shared/src/lib/use-route-constructor.ts new file mode 100644 index 0000000000..2bbcdb252c --- /dev/null +++ b/graph/shared/src/lib/use-route-constructor.ts @@ -0,0 +1,42 @@ +import { To, useParams, useSearchParams } from 'react-router-dom'; +import { getEnvironmentConfig } from './use-environment-config'; + +export const useRouteConstructor = (): (( + to: To, + retainSearchParams: boolean +) => To) => { + const { environment } = getEnvironmentConfig(); + const { selectedWorkspaceId } = useParams(); + const [searchParams] = useSearchParams(); + + return (to: To, retainSearchParams: true) => { + let pathname = ''; + + if (typeof to === 'object') { + if (environment === 'dev') { + pathname = `/${selectedWorkspaceId}${to.pathname}`; + } else { + pathname = to.pathname; + } + return { + ...to, + pathname, + search: to.search + ? to.search.toString() + : retainSearchParams + ? searchParams.toString() + : '', + }; + } else if (typeof to === 'string') { + if (environment === 'dev') { + pathname = `/${selectedWorkspaceId}${to}`; + } else { + pathname = to; + } + return { + pathname, + search: retainSearchParams ? searchParams.toString() : '', + }; + } + }; +}; diff --git a/graph/shared/tsconfig.json b/graph/shared/tsconfig.json new file mode 100644 index 0000000000..31245daec2 --- /dev/null +++ b/graph/shared/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/graph/shared/tsconfig.lib.json b/graph/shared/tsconfig.lib.json new file mode 100644 index 0000000000..3b824cf3a5 --- /dev/null +++ b/graph/shared/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ], + "lib": ["dom"] + }, + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/graph/shared/tsconfig.spec.json b/graph/shared/tsconfig.spec.json new file mode 100644 index 0000000000..26ef046ac5 --- /dev/null +++ b/graph/shared/tsconfig.spec.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/graph/ui-graph/src/lib/graph-interaction-events.ts b/graph/ui-graph/src/lib/graph-interaction-events.ts index 3b4a6cc299..77a0b92d38 100644 --- a/graph/ui-graph/src/lib/graph-interaction-events.ts +++ b/graph/ui-graph/src/lib/graph-interaction-events.ts @@ -32,28 +32,9 @@ interface BackgroundClickEvent { type: 'BackgroundClick'; } -interface FileLinkClickEvent { - type: 'FileLinkClick'; - sourceRoot: string; - file: string; -} - -interface ProjectOpenConfigClickEvent { - type: 'ProjectOpenConfigClick'; - projectName: string; -} - -interface RunTaskClickEvent { - type: 'RunTaskClick'; - taskId: string; -} - export type GraphInteractionEvents = | ProjectNodeClickEvent | EdgeClickEvent | GraphRegeneratedEvent | TaskNodeClickEvent - | BackgroundClickEvent - | FileLinkClickEvent - | ProjectOpenConfigClickEvent - | RunTaskClickEvent; + | BackgroundClickEvent; diff --git a/graph/ui-graph/src/lib/tooltip-service.ts b/graph/ui-graph/src/lib/tooltip-service.ts index f9c604a125..c58187113d 100644 --- a/graph/ui-graph/src/lib/tooltip-service.ts +++ b/graph/ui-graph/src/lib/tooltip-service.ts @@ -7,9 +7,11 @@ import { } from '@nx/graph/ui-tooltips'; import { TooltipEvent } from './interfaces'; import { GraphInteractionEvents } from './graph-interaction-events'; +import { getExternalApiService } from '@nx/graph/shared'; export class GraphTooltipService { private subscribers: Set = new Set(); + private externalApiService = getExternalApiService(); constructor(graph: GraphService) { graph.listen((event: GraphInteractionEvents) => { @@ -24,9 +26,11 @@ export class GraphTooltipService { const openConfigCallback = graph.renderMode === 'nx-console' ? () => - graph.broadcast({ - type: 'ProjectOpenConfigClick', - projectName: event.data.id, + this.externalApiService.postEvent({ + type: 'open-project-config', + payload: { + projectName: event.data.id, + }, }) : undefined; this.openProjectNodeToolTip(event.ref, { @@ -41,9 +45,11 @@ export class GraphTooltipService { const runTaskCallback = graph.renderMode === 'nx-console' ? () => - graph.broadcast({ - type: 'RunTaskClick', - taskId: event.data.id, + this.externalApiService.postEvent({ + type: 'run-task', + payload: { + taskId: event.data.id, + }, }) : undefined; this.openTaskNodeTooltip(event.ref, { @@ -69,10 +75,12 @@ export class GraphTooltipService { const callback = graph.renderMode === 'nx-console' ? (url) => - graph.broadcast({ - type: 'FileLinkClick', - sourceRoot: event.data.sourceRoot, - file: url, + this.externalApiService.postEvent({ + type: 'file-click', + payload: { + sourceRoot: event.data.sourceRoot, + file: url, + }, }) : undefined; this.openEdgeToolTip(event.ref, { diff --git a/packages/nx/src/command-line/graph/graph.ts b/packages/nx/src/command-line/graph/graph.ts index ef9f629f3c..0eae86fc30 100644 --- a/packages/nx/src/command-line/graph/graph.ts +++ b/packages/nx/src/command-line/graph/graph.ts @@ -523,6 +523,8 @@ async function startServer( currentProjectGraphClientResponse.groupByFolder = groupByFolder; currentProjectGraphClientResponse.exclude = exclude; + currentSourceMapsClientResponse = sourceMapResponse; + const app = http.createServer(async (req, res) => { // parse URL const parsedUrl = new URL(req.url, `http://${host}:${port}`); @@ -531,6 +533,8 @@ async function startServer( // e.g curl --path-as-is http://localhost:9000/../fileInDanger.txt // by limiting the path to current directory only + res.setHeader('Access-Control-Allow-Origin', '*'); + const sanitizePath = basename(parsedUrl.pathname); if (sanitizePath === 'project-graph.json') { res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -660,7 +664,8 @@ function createFileWatcher() { if ( projectGraphClientResponse.hash !== - currentProjectGraphClientResponse.hash + currentProjectGraphClientResponse.hash && + sourceMapResponse ) { output.note({ title: 'Graph changes updated.' }); @@ -695,7 +700,7 @@ async function createProjectGraphAndSourceMapClientResponse( const dependencies = graph.dependencies; const hasher = createHash('sha256'); - hasher.update(JSON.stringify({ layout, projects, dependencies })); + hasher.update(JSON.stringify({ layout, projects, dependencies, sourceMaps })); const hash = hasher.digest('hex'); diff --git a/tsconfig.base.json b/tsconfig.base.json index cdd99a81f1..e28134d1b4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -36,6 +36,7 @@ "@nx/expo/*": ["packages/expo/*"], "@nx/express": ["packages/express"], "@nx/graph/project-details": ["graph/project-details/src/index.ts"], + "@nx/graph/shared": ["graph/shared/src/index.ts"], "@nx/graph/ui-components": ["graph/ui-components/src/index.ts"], "@nx/graph/ui-graph": ["graph/ui-graph/src/index.ts"], "@nx/graph/ui-tooltips": ["graph/ui-tooltips/src/index.ts"],