Skip to content

Commit

Permalink
feat(graph): use a state machine to allow actions from external sourc…
Browse files Browse the repository at this point in the history
…e (e.g. Console)
  • Loading branch information
jaysoo committed Sep 12, 2024
1 parent 29c76a9 commit a5bb0e9
Show file tree
Hide file tree
Showing 7 changed files with 322 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -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<ProjectDetailsState, any, ProjectDetailsEvents>;
}) {
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 (
<>
<ExpandedTargetsProvider>
<ProjectDetails
project={project}
sourceMap={sourceMap}
onViewInProjectGraph={handleViewInProjectGraph}
onViewInTaskGraph={handleViewInTaskGraph}
onRunTarget={handleRunTarget}
viewInProjectGraphPosition="bottom"
connectedToCloud={connectedToCloud}
onNxConnect={handleNxConnect}
/>
</ExpandedTargetsProvider>
<ErrorToastUI errors={errors} />
</>
);
} else {
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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).toEqual(null);
});

it('should handle setting project and source map', () => {
service.send({
type: 'loadData',
project: {
type: 'app',
name: 'proj',
data: {},
},
sourceMap: {
root: ['project.json', 'nx-core-build-project-json-nodes'],
},
});

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'],
});
});

it('should handle errors', () => {
const testError = {
message: 'test',
stack: 'test',
cause: 'test',
name: 'test',
pluginName: 'test',
};

service.send({
type: 'setErrors',
errors: [testError],
});
expect(service.state.value).toEqual('error');
expect(service.state.context.errors).toBeDefined();

service.send({ type: 'clearErrors' });
expect(service.state.value).toEqual('idle');

service.send({
type: 'setErrors',
errors: [testError],
});
expect(service.state.value).toEqual('error');
service.send({
type: 'loadData',
project: {
type: 'app',
name: 'proj',
data: {},
},
sourceMap: {
root: ['project.json', 'nx-core-build-project-json-nodes'],
},
});
// Still in error state
expect(service.state.value).toEqual('error');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/* 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<string, string[]>;
errors: null | GraphError[];
connectedToCloud: boolean;
}

export type ProjectDetailsEvents =
| {
type: 'loadData';
project: ProjectGraphProjectNode;
sourceMap: Record<string, string[]>;
connectedToCloud: boolean;
}
| {
type: 'setErrors';
errors: GraphError[];
}
| {
type: 'clearErrors';
};

const initialContext: ProjectDetailsState = {
project: null,
sourceMap: null,
errors: null,
connectedToCloud: false,
};

export const projectDetailsMachine = createMachine<
ProjectDetailsState,
ProjectDetailsEvents
>({
predictableActionArguments: true,
preserveActionOrder: true,
id: 'project-view',
initial: 'idle',
context: initialContext,
states: {
idle: {},
loaded: {},
error: {},
},
on: {
loadData: [
{
target: 'loaded',
cond: (ctx, _event) => ctx.errors === null || ctx.errors.length === 0,
actions: [
assign((ctx, event) => {
ctx.project = event.project;
ctx.sourceMap = event.sourceMap;
ctx.connectedToCloud = event.connectedToCloud;
}),
],
},
{
target: 'error',
cond: (ctx, _event) => ctx.errors !== null && ctx.errors.length > 0,
actions: [
assign((ctx, event) => {
ctx.project = event.project;
ctx.sourceMap = event.sourceMap;
ctx.connectedToCloud = event.connectedToCloud;
}),
],
},
],
setErrors: {
target: 'error',
actions: assign((ctx, event) => {
ctx.errors = event.errors;
}),
},
clearErrors: [
{
target: 'idle',
cond: (ctx, _event) => ctx.project === null,
actions: assign((ctx) => {
ctx.errors = null;
}),
},
{
target: 'loaded',
cond: (ctx, _event) => ctx.project !== null,
actions: assign((ctx) => {
ctx.errors = null;
}),
},
],
},
});
1 change: 1 addition & 0 deletions graph/client/src/app/ui-components/error-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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;
Expand Down
10 changes: 9 additions & 1 deletion graph/client/src/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,7 +29,9 @@ export declare global {

// using bundled graph components directly from outside the graph app
__NX_RENDER_GRAPH__?: boolean;
renderPDV?: (data: any) => void;
renderPDV?: (
data: any
) => Interpreter<ProjectDetailsState, any, ProjectDetailsEvents>;
renderError?: (data: any) => void;
}
}
Expand Down
5 changes: 4 additions & 1 deletion graph/client/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@
document.documentElement.classList.remove('dark');
}
</script>
<script>
window.__NX_RENDER_GRAPH__ = false;
</script>
</head>

<body class="bg-white text-slate-500 dark:bg-slate-900 dark:text-slate-400">
<div class="flex p-0" id="app"></div>
<div class="" id="app"></div>
</body>
</html>
43 changes: 31 additions & 12 deletions graph/client/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,55 @@ 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 { ErrorToastUI, ExpandedTargetsProvider } from '@nx/graph/shared';
import { ProjectDetails } from '@nx/graph-internal/ui-project-details';
import { ErrorPage } from './app/ui-components/error-page';
import { ProjectDetailsApp } from './app/console-project-details/project-details.app';
import { interpret } from 'xstate';

if (window.__NX_RENDER_GRAPH__ === false) {
window.renderPDV = (data: any) => {
const container = document.getElementById('app');
window.renderPDV = (data: {
project: ProjectGraphProjectNode;
sourceMap: Record<string, string[]>;
connectedToCloud: boolean;
}) => {
const service = interpret(projectDetailsMachine).start();

service.send({
type: 'loadData',
...data,
});

render(
<StrictMode>
<ExpandedTargetsProvider>
<ProjectDetails {...data} />
</ExpandedTargetsProvider>
<ErrorToastUI errors={data.errors} />
<ProjectDetailsApp service={service} />
</StrictMode>,
container
document.getElementById('app')
);

return service;
};

window.renderError = (data: any) => {
const container = document.getElementById('app');
window.renderError = (data: {
message: string;
stack?: string;
errors: GraphError[];
}) => {
render(
<StrictMode>
<ErrorPage {...data} />
</StrictMode>,
container
document.getElementById('app')
);
};
} else {
Expand Down

0 comments on commit a5bb0e9

Please sign in to comment.