From dc821abd5249bb2769c58e3d9f466bd67bb516fa Mon Sep 17 00:00:00 2001 From: MaxKless <34165455+MaxKless@users.noreply.github.com> Date: Fri, 13 Sep 2024 20:48:30 +0200 Subject: [PATCH] feat(graph): expose functions to render pdv & error page (#27833) we have fully isolated graph & error components with a good api but no way to access them directly from the outside (in console). This PR adds two functions to the window object so that we can render the PDV directly instead of needing the entire app with routing and everything. --------- Co-authored-by: Jack Hsu --- .../project-details.app.tsx | 94 ++++++++++++++++++ .../project-details.machine.spec.ts | 49 ++++++++++ .../project-details.machine.ts | 58 +++++++++++ .../src/app/ui-components/error-boundary.tsx | 39 ++------ .../src/app/ui-components/error-page.tsx | 49 ++++++++++ graph/client/src/globals.d.ts | 13 +++ graph/client/src/main.tsx | 96 ++++++++++++++----- .../lib/project-details/project-details.tsx | 8 +- .../src/lib/utils/group-targets.ts | 4 +- package.json | 2 +- pnpm-lock.yaml | 38 ++++---- 11 files changed, 368 insertions(+), 82 deletions(-) create mode 100644 graph/client/src/app/console-project-details/project-details.app.tsx create mode 100644 graph/client/src/app/console-project-details/project-details.machine.spec.ts create mode 100644 graph/client/src/app/console-project-details/project-details.machine.ts create mode 100644 graph/client/src/app/ui-components/error-page.tsx diff --git a/graph/client/src/app/console-project-details/project-details.app.tsx b/graph/client/src/app/console-project-details/project-details.app.tsx new file mode 100644 index 0000000000000..13c20e92f1939 --- /dev/null +++ b/graph/client/src/app/console-project-details/project-details.app.tsx @@ -0,0 +1,94 @@ +import { useCallback } from 'react'; +import { + ErrorToastUI, + ExpandedTargetsProvider, + getExternalApiService, +} from '@nx/graph/shared'; +import { useMachine, useSelector } from '@xstate/react'; +import { ProjectDetails } from '@nx/graph-internal/ui-project-details'; +import { + ProjectDetailsEvents, + ProjectDetailsState, +} from './project-details.machine'; +import { Interpreter } from 'xstate'; + +export function ProjectDetailsApp({ + service, +}: { + service: Interpreter; +}) { + const externalApiService = getExternalApiService(); + + const project = useSelector(service, (state) => state.context.project); + const sourceMap = useSelector(service, (state) => state.context.sourceMap); + const errors = useSelector(service, (state) => state.context.errors); + const connectedToCloud = useSelector( + service, + (state) => state.context.connectedToCloud + ); + + const handleViewInProjectGraph = useCallback( + (data: { projectName: string }) => { + externalApiService.postEvent({ + type: 'open-project-graph', + payload: { + projectName: data.projectName, + }, + }); + }, + [externalApiService] + ); + + const handleViewInTaskGraph = useCallback( + (data: { projectName: string; targetName: string }) => { + externalApiService.postEvent({ + type: 'open-task-graph', + payload: { + projectName: data.projectName, + targetName: data.targetName, + }, + }); + }, + [externalApiService] + ); + + const handleRunTarget = useCallback( + (data: { projectName: string; targetName: string }) => { + externalApiService.postEvent({ + type: 'run-task', + payload: { taskId: `${data.projectName}:${data.targetName}` }, + }); + }, + [externalApiService] + ); + + const handleNxConnect = useCallback( + () => + externalApiService.postEvent({ + type: 'nx-connect', + }), + [externalApiService] + ); + + if (project && sourceMap) { + return ( + <> + + + + + + ); + } else { + return null; + } +} diff --git a/graph/client/src/app/console-project-details/project-details.machine.spec.ts b/graph/client/src/app/console-project-details/project-details.machine.spec.ts new file mode 100644 index 0000000000000..f1405c0efeacb --- /dev/null +++ b/graph/client/src/app/console-project-details/project-details.machine.spec.ts @@ -0,0 +1,49 @@ +import { interpret } from 'xstate'; +import { projectDetailsMachine } from './project-details.machine'; + +describe('graphMachine', () => { + let service; + + beforeEach(() => { + service = interpret(projectDetailsMachine).start(); + }); + + afterEach(() => { + service.stop(); + }); + + it('should have initial idle state', () => { + expect(service.state.value).toEqual('idle'); + expect(service.state.context.project).toEqual(null); + expect(service.state.context.errors).toBeUndefined(); + }); + + it('should handle setting data', () => { + service.send({ + type: 'loadData', + project: { + type: 'app', + name: 'proj', + data: {}, + }, + sourceMap: { + root: ['project.json', 'nx-core-build-project-json-nodes'], + }, + errors: [{ name: 'ERROR' }], + connectedToCloud: true, + }); + + expect(service.state.value).toEqual('loaded'); + + expect(service.state.context.project).toEqual({ + type: 'app', + name: 'proj', + data: {}, + }); + expect(service.state.context.sourceMap).toEqual({ + root: ['project.json', 'nx-core-build-project-json-nodes'], + }); + expect(service.state.context.errors).toEqual([{ name: 'ERROR' }]); + expect(service.state.context.connectedToCloud).toEqual(true); + }); +}); diff --git a/graph/client/src/app/console-project-details/project-details.machine.ts b/graph/client/src/app/console-project-details/project-details.machine.ts new file mode 100644 index 0000000000000..c778ac4f56825 --- /dev/null +++ b/graph/client/src/app/console-project-details/project-details.machine.ts @@ -0,0 +1,58 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import type { ProjectGraphProjectNode } from '@nx/devkit'; +// nx-ignore-next-line +import { GraphError } from 'nx/src/command-line/graph/graph'; +/* eslint-enable @nx/enforce-module-boundaries */ +import { createMachine } from 'xstate'; +import { assign } from '@xstate/immer'; + +export interface ProjectDetailsState { + project: null | ProjectGraphProjectNode; + sourceMap: null | Record; + errors?: GraphError[]; + connectedToCloud?: boolean; +} + +export type ProjectDetailsEvents = { + type: 'loadData'; + project: ProjectGraphProjectNode; + sourceMap: Record; + connectedToCloud: boolean; + errors?: GraphError[]; +}; + +const initialContext: ProjectDetailsState = { + project: null, + sourceMap: null, +}; + +export const projectDetailsMachine = createMachine< + ProjectDetailsState, + ProjectDetailsEvents +>({ + predictableActionArguments: true, + preserveActionOrder: true, + id: 'project-view', + initial: 'idle', + context: initialContext, + states: { + idle: {}, + loaded: {}, + }, + on: { + loadData: [ + { + target: 'loaded', + actions: [ + assign((ctx, event) => { + ctx.project = event.project; + ctx.sourceMap = event.sourceMap; + ctx.connectedToCloud = event.connectedToCloud; + ctx.errors = event.errors; + }), + ], + }, + ], + }, +}); diff --git a/graph/client/src/app/ui-components/error-boundary.tsx b/graph/client/src/app/ui-components/error-boundary.tsx index e4993ca9a47e8..1c9522cc31585 100644 --- a/graph/client/src/app/ui-components/error-boundary.tsx +++ b/graph/client/src/app/ui-components/error-boundary.tsx @@ -5,12 +5,12 @@ import { useEnvironmentConfig, usePoll, } from '@nx/graph/shared'; -import { ErrorRenderer } from '@nx/graph/ui-components'; import { isRouteErrorResponse, useParams, useRouteError, } from 'react-router-dom'; +import { ErrorPage } from './error-page'; export function ErrorBoundary() { let error = useRouteError(); @@ -63,38 +63,11 @@ export function ErrorBoundary() { return (
{environment !== 'nx-console' && } -
-

Error

-
- -
- {hasErrorData && ( -
-

- Nx encountered the following issues while processing the project - graph:{' '} -

-
- -
-
- )} -
-
- ); -} - -function ErrorWithStack({ - message, - stack, -}: { - message: string | JSX.Element; - stack?: string; -}) { - return ( -
-

{message}

- {stack &&

Error message: {stack}

} +
); } diff --git a/graph/client/src/app/ui-components/error-page.tsx b/graph/client/src/app/ui-components/error-page.tsx new file mode 100644 index 0000000000000..841733d5e55f2 --- /dev/null +++ b/graph/client/src/app/ui-components/error-page.tsx @@ -0,0 +1,49 @@ +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import { ErrorRenderer } from '@nx/graph/ui-components'; +import { GraphError } from 'nx/src/command-line/graph/graph'; +/* eslint-enable @nx/enforce-module-boundaries */ +import type { JSX } from 'react'; + +export type ErrorPageProps = { + message: string | JSX.Element; + stack?: string; + errors: GraphError[]; +}; + +export function ErrorPage({ message, stack, errors }: ErrorPageProps) { + return ( +
+

Error

+
+ +
+ {errors && ( +
+

+ Nx encountered the following issues while processing the project + graph:{' '} +

+
+ +
+
+ )} +
+ ); +} + +function ErrorWithStack({ + message, + stack, +}: { + message: string | JSX.Element; + stack?: string; +}) { + return ( +
+

{message}

+ {stack &&

Error message: {stack}

} +
+ ); +} diff --git a/graph/client/src/globals.d.ts b/graph/client/src/globals.d.ts index 844bea3c3d1b3..cee748a801fbc 100644 --- a/graph/client/src/globals.d.ts +++ b/graph/client/src/globals.d.ts @@ -6,6 +6,12 @@ import type { TaskGraphClientResponse, } from 'nx/src/command-line/graph/graph'; import type { AppConfig, ExternalApi } from '@nx/graph/shared'; +import { + ProjectDetailsEvents, + projectDetailsMachine, + ProjectDetailsState, +} from './app/console/project-details/project-details.machine'; +import { Interpreter } from 'xstate'; export declare global { interface Window { @@ -20,6 +26,13 @@ export declare global { appConfig: AppConfig; useXstateInspect: boolean; externalApi?: ExternalApi; + + // using bundled graph components directly from outside the graph app + __NX_RENDER_GRAPH__?: boolean; + renderPDV?: ( + data: any + ) => Interpreter; + renderError?: (data: any) => void; } } declare module 'cytoscape' { diff --git a/graph/client/src/main.tsx b/graph/client/src/main.tsx index 7ad6c5b98f45e..6849ff6588467 100644 --- a/graph/client/src/main.tsx +++ b/graph/client/src/main.tsx @@ -4,35 +4,85 @@ if (process.env.NODE_ENV === 'development') { require('preact/debug'); } +import { projectDetailsMachine } from './app/console-project-details/project-details.machine'; +/* eslint-disable @nx/enforce-module-boundaries */ +// nx-ignore-next-line +import type { ProjectGraphProjectNode } from '@nx/devkit'; +// nx-ignore-next-line +import type { GraphError } from 'nx/src/command-line/graph/graph'; +/* eslint-enable @nx/enforce-module-boundaries */ import { StrictMode } from 'react'; import { inspect } from '@xstate/inspect'; import { App } from './app/app'; import { ExternalApiImpl } from './app/external-api-impl'; import { render } from 'preact'; +import { ErrorPage } from './app/ui-components/error-page'; +import { ProjectDetailsApp } from './app/console-project-details/project-details.app'; +import { interpret } from 'xstate'; -if (window.useXstateInspect === true) { - inspect({ - url: 'https://stately.ai/viz?inspect', - iframe: false, // open in new window - }); -} +if (window.__NX_RENDER_GRAPH__ === false) { + window.externalApi = new ExternalApiImpl(); + + window.renderPDV = (data: { + project: ProjectGraphProjectNode; + sourceMap: Record; + connectedToCloud: boolean; + errors?: GraphError[]; + }) => { + const service = interpret(projectDetailsMachine).start(); + + service.send({ + type: 'loadData', + ...data, + }); + + render( + + + , + document.getElementById('app') + ); -window.externalApi = new ExternalApiImpl(); -const container = document.getElementById('app'); - -if (!window.appConfig) { - render( -

- No environment could be found. Please run{' '} -

npx nx run graph-client:generate-dev-environment-js
. -

, - container - ); + return service; + }; + + window.renderError = (data: { + message: string; + stack?: string; + errors: GraphError[]; + }) => { + render( + + + , + document.getElementById('app') + ); + }; } else { - render( - - - , - container - ); + if (window.useXstateInspect === true) { + inspect({ + url: 'https://stately.ai/viz?inspect', + iframe: false, // open in new window + }); + } + + window.externalApi = new ExternalApiImpl(); + const container = document.getElementById('app'); + + if (!window.appConfig) { + render( +

+ No environment could be found. Please run{' '} +

npx nx run graph-client:generate-dev-environment-js
. +

, + container + ); + } else { + render( + + + , + container + ); + } } diff --git a/graph/ui-project-details/src/lib/project-details/project-details.tsx b/graph/ui-project-details/src/lib/project-details/project-details.tsx index c5bbdc8a5011b..87e5165207841 100644 --- a/graph/ui-project-details/src/lib/project-details/project-details.tsx +++ b/graph/ui-project-details/src/lib/project-details/project-details.tsx @@ -90,7 +90,7 @@ export const ProjectDetails = ({ {onViewInProjectGraph && viewInProjectGraphPosition === 'top' && ( + onClick={() => onViewInProjectGraph({ projectName: project.name }) } /> @@ -125,7 +125,7 @@ export const ProjectDetails = ({ {onViewInProjectGraph && viewInProjectGraphPosition === 'bottom' && ( + onClick={() => onViewInProjectGraph({ projectName: project.name }) } /> @@ -162,11 +162,11 @@ export const ProjectDetails = ({ export default ProjectDetails; -function ViewInProjectGraphButton({ callback }: { callback: () => void }) { +function ViewInProjectGraphButton({ onClick }: { onClick: () => void }) { return (