Skip to content
This repository has been archived by the owner on Feb 25, 2024. It is now read-only.

Added an embed mode which works via the URL, not any data in our db #323

Open
wants to merge 10 commits into
base: dev
Choose a base branch
from
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, {
mattpocock marked this conversation as resolved.
Show resolved Hide resolved
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;
};