Skip to content
This repository was archived by the owner on Feb 25, 2024. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,26 @@
import { Box, ChakraProvider } from '@chakra-ui/react';
import React, { useEffect, useMemo } from 'react';
import { useActor, useInterpret, useSelector } from '@xstate/react';
import { useAuth } from './authContext';
import router, { useRouter } from 'next/router';
import React, { useEffect, useMemo } from 'react';
import { AppHead } from './AppHead';
import { useAuth } from './authContext';
import { CanvasProvider } from './CanvasContext';
import { EmbedProvider } from './embedContext';
import { CanvasView } from './CanvasView';
import { EmbedProvider } from './embedContext';
import { isOnClientSide } from './isOnClientSide';
import { MachineNameChooserModal } from './MachineNameChooserModal';
import { PaletteProvider } from './PaletteContext';
import { paletteMachine } from './paletteMachine';
import { PanelsView } from './PanelsView';
import { registryLinks } from './registryLinks';
import { SimulationProvider } from './SimulationContext';
import { simulationMachine } from './simulationMachine';
import { getSourceActor, useSourceRegistryData } from './sourceMachine';
import { theme } from './theme';
import { EditorThemeProvider } from './themeContext';
import { EmbedContext, EmbedMode } from './types';
import { useInterpretCanvas } from './useInterpretCanvas';
import router, { useRouter } from 'next/router';
import { parseEmbedQuery, withoutEmbedQueryParams } from './utils';
import { registryLinks } from './registryLinks';

const defaultHeadProps = {
title: 'XState Visualizer',
Expand Down Expand Up @@ -88,6 +88,7 @@ function App({ isEmbedded = false }: { isEmbedded?: boolean }) {
const paletteService = useInterpret(paletteMachine);
// don't use `devTools: true` here as it would freeze your browser
const simService = useInterpret(simulationMachine);

const machine = useSelector(simService, (state) => {
return state.context.currentSessionId
? state.context.serviceDataMap[state.context.currentSessionId!]?.machine
Expand All @@ -111,15 +112,15 @@ function App({ isEmbedded = false }: { isEmbedded?: boolean }) {
});
}, [machine?.id, sendToSourceService]);

// TODO: Subject to refactor into embedActor

const sourceID = sourceState!.context.sourceID;

const canvasService = useInterpretCanvas({
sourceID,
embed,
});

const canShowWelcomeMessage = sourceState.hasTag('canShowWelcomeMessage');

// This is because we're doing loads of things on client side anyway
if (!isOnClientSide()) return <VizHead />;

Expand All @@ -142,7 +143,9 @@ function App({ isEmbedded = false }: { isEmbedded?: boolean }) {
>
{!(embed?.isEmbedded && embed.mode === EmbedMode.Panels) && (
<CanvasProvider value={canvasService}>
<CanvasView />
<CanvasView
canShowWelcomeMessage={canShowWelcomeMessage}
/>
</CanvasProvider>
)}
<PanelsView />
Expand Down
25 changes: 7 additions & 18 deletions src/CanvasView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,15 @@ import { CanvasHeader } from './CanvasHeader';
import { Overlay } from './Overlay';
import { useEmbed } from './embedContext';
import { CompressIcon, HandIcon } from './Icons';
import { useSourceActor } from './sourceMachine';
import { WelcomeArea } from './WelcomeArea';

export const CanvasView: React.FC = () => {
export const CanvasView = (props: { canShowWelcomeMessage?: boolean }) => {
// TODO: refactor this so an event can be explicitly sent to a machine
// it isn't straightforward to do at the moment cause the target machine lives in a child component
const [panModeEnabled, setPanModeEnabled] = React.useState(false);
const embed = useEmbed();
const simService = useSimulation();
const canvasService = useCanvas();
const [sourceState] = useSourceActor();
const machine = useSelector(simService, (state) => {
return state.context.currentSessionId
? state.context.serviceDataMap[state.context.currentSessionId!]?.machine
Expand All @@ -68,21 +66,12 @@ export const CanvasView: React.FC = () => {

const simulationMode = useSimulationMode();

const canShowWelcomeMessage = sourceState.hasTag('canShowWelcomeMessage');
const showControls = !embed?.isEmbedded || embed.controls;

const showControls = useMemo(
() => !embed?.isEmbedded || embed.controls,
[embed],
);

const showZoomButtonsInEmbed = useMemo(
() => !embed?.isEmbedded || (embed.controls && embed.zoom),
[embed],
);
const showPanButtonInEmbed = useMemo(
() => !embed?.isEmbedded || (embed.controls && embed.pan),
[embed],
);
const showZoomButtonsInEmbed =
!embed?.isEmbedded || (embed.controls && embed.zoom);
const showPanButtonInEmbed =
!embed?.isEmbedded || (embed.controls && embed.pan);

return (
<Box
Expand All @@ -107,7 +96,7 @@ export const CanvasView: React.FC = () => {
</Box>
</Overlay>
)}
{isEmpty && canShowWelcomeMessage && <WelcomeArea />}
{isEmpty && props.canShowWelcomeMessage && <WelcomeArea />}
</CanvasContainer>

{showControls && (
Expand Down
10 changes: 6 additions & 4 deletions src/embedContext.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createContext, useContext } from 'react';
import { EmbedContext } from './types';
import { createRequiredContext } from './utils';

export const [EmbedProvider, useEmbed] = createRequiredContext<
EmbedContext | undefined
>('Embed');
const EmbedReactContext = createContext(null as EmbedContext);

export const EmbedProvider = EmbedReactContext.Provider;

export const useEmbed = () => useContext(EmbedReactContext);
15 changes: 14 additions & 1 deletion src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import '../InvokeViz.scss';
import '../monacoPatch';
import '../StateNodeViz.scss';
import '../TransitionViz.scss';
import { NextComponentWithMeta } from '../types';

// import { isOnClientSide } from '../isOnClientSide';

Expand All @@ -32,7 +33,7 @@ if (
});
}

const MyApp = ({ pageProps, Component }: AppProps) => {
const AuthWrapper = ({ pageProps, Component }: AppProps) => {
const router = useRouter();

const authService = useInterpret(
Expand All @@ -50,4 +51,16 @@ const MyApp = ({ pageProps, Component }: AppProps) => {
);
};

const MyApp = (
props: AppProps & {
Component: NextComponentWithMeta;
},
) => {
if (props.Component.preventAuth) {
return <props.Component {...props.pageProps}></props.Component>;
}

return <AuthWrapper {...props}></AuthWrapper>;
};

export default MyApp;
154 changes: 154 additions & 0 deletions src/pages/view-only.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Box, ChakraProvider } from '@chakra-ui/react';
import { useInterpret, useMachine } from '@xstate/react';
import Head from 'next/head';
import { NextRouter, useRouter } from 'next/router';
import React, { useEffect, useMemo } from 'react';
import { createMachine, MachineConfig, sendParent } from 'xstate';
import { CanvasProvider } from '../CanvasContext';
import { CanvasView } from '../CanvasView';
import { EmbedProvider } from '../embedContext';
import { SimulationProvider } from '../SimulationContext';
import { simModel, simulationMachine } from '../simulationMachine';
import { theme } from '../theme';
import { NextComponentWithMeta } from '../types';
import { useInterpretCanvas } from '../useInterpretCanvas';
import { parseEmbedQuery, withoutEmbedQueryParams } from '../utils';
import { withReadyRouter } from '../withReadyRouter';
import lzString from 'lz-string';
import { AppHead } from '../AppHead';

const parseMachineFromQuery = (query: NextRouter['query']) => {
if (!query.machine) {
throw new Error('`machine` query param is required');
}

if (Array.isArray(query.machine)) {
throw new Error("`machine` query param can't be an array");
}

const lzResult = lzString.decompressFromEncodedURIComponent(query.machine);

if (!lzResult)
throw new Error("`machine` query param couldn't be decompressed");

const machineConfig = JSON.parse(lzResult);

// Tests that the machine is valid
try {
return createMachine(machineConfig);
} catch {
throw new Error(
"decompressed `machine` couldn't be used to `createMachine`",
);
}
};

const viewOnlyPageMachine = createMachine<{
query: NextRouter['query'];
}>({

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initial: 'checkingIfMachineIsValid',
states: {
checkingIfMachineIsValid: {
invoke: {
src: async (context) => {
return parseMachineFromQuery(context.query);
},
onDone: {
target: 'valid',
},
onError: {
target: 'notValid',
},
},
},
notValid: {
type: 'final',
entry: (context, event) => {
console.error('Could not parse machine.', event);
},
},
valid: {
type: 'final',
entry: sendParent((context) =>
simModel.events['MACHINES.REGISTER']([
parseMachineFromQuery(context.query),
]),
),
},
},
});

/**
* Displays a view-only page which can render a machine
* to the canvas from the URL
*
* Use this example URL: http://localhost:3000/viz/view-only?machine=N4IglgdmAuYIYBsQC4QHcD2aQBoQGdo5oBTfFUTbZYAXzwhOrttqA
*
* This is for loading OG images quickly, and for many other applications
*
* To create the machine hash, use the lzString.compressToEncodedURIComponent
* function on a JSON.stringified machine config.
*
* You can also use the typical embed controls
*/
const ViewOnlyPage = withReadyRouter(() => {
const canvasService = useInterpretCanvas({
sourceID: null,
});
const simulationService = useInterpret(simulationMachine);
const router = useRouter();

useMachine(viewOnlyPageMachine, {
context: {
query: router.query,
},
parent: simulationService,
});

const embed = useMemo(
() => ({
...parseEmbedQuery(router.query),
isEmbedded: true,
originalUrl: withoutEmbedQueryParams(router.query),
}),
[router.query],
);

return (
<EmbedProvider value={embed}>
<ChakraProvider theme={theme}>
<CanvasProvider value={canvasService}>
<SimulationProvider value={simulationService}>
<Box
data-testid="app"
data-viz-theme="dark"
as="main"
height="100vh"
>
<CanvasView />
</Box>
</SimulationProvider>
</CanvasProvider>
</ChakraProvider>
</EmbedProvider>
);
});

const ViewOnlyPageParent: NextComponentWithMeta = () => {
return (
<>
<AppHead
description="A visualisation of a state machine"
title="XState Visualizer"
ogImageUrl={null}
ogTitle="XState Visualizer"
importElk
/>
<ViewOnlyPage />
</>
);
};

ViewOnlyPageParent.preventAuth = true;

export default ViewOnlyPageParent;
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
import { SourceFileFragment } from './graphql/SourceFileFragment.generated';
import { Model } from 'xstate/lib/model.types';
import type { editor } from 'monaco-editor';
import { NextPage } from 'next';

export type AnyStateMachine = StateMachine<any, any, any>;

Expand Down Expand Up @@ -84,3 +85,7 @@ export interface Point {
x: number;
y: number;
}

export type NextComponentWithMeta = NextPage & {
preventAuth?: boolean;
};
19 changes: 10 additions & 9 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,17 +214,18 @@ export const DEFAULT_EMBED_PARAMS: ParsedEmbed = {
zoom: false,
controls: false,
};
export const parseEmbedQuery = (query?: NextRouter['query']): ParsedEmbed => {
const parsedEmbed = DEFAULT_EMBED_PARAMS;

const getQueryParamValue = (qParamValue: string | string[]) => {
return Array.isArray(qParamValue) ? qParamValue[0] : qParamValue;
};
const getQueryParamValue = (qParamValue: string | string[]) => {
return Array.isArray(qParamValue) ? qParamValue[0] : qParamValue;
};

const computeBooleanQParamValue = (qParamValue: string) => {
// Parse to number to treat "0" as false
return !!+qParamValue;
};
const computeBooleanQParamValue = (qParamValue: string) => {
// Parse to number to treat "0" as false
return !!+qParamValue;
};

export const parseEmbedQuery = (query?: NextRouter['query']): ParsedEmbed => {
const parsedEmbed = DEFAULT_EMBED_PARAMS;

if (query?.mode) {
const parsedMode = getQueryParamValue(query?.mode);
Expand Down
25 changes: 25 additions & 0 deletions src/withReadyRouter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';

/**
* Ensures that Next's router is always ready (i.e. has
* query params loaded)
*/
export const withReadyRouter = (WrappedComponent: any) => {
WrappedComponent.displayName = `WithReadyRouter(${WrappedComponent.displayName || WrappedComponent.name})`;

const WithReadyRouter = () => {
const router = useRouter();
const [isReady, setIsReady] = useState(false);

useEffect(() => {
setIsReady(router.isReady);
}, [router.isReady]);

if (!isReady) return null;

return <WrappedComponent />;
};

return WithReadyRouter;
};