From c383d2a0045a0119b038946834c13b7e055df0ec Mon Sep 17 00:00:00 2001 From: shmck Date: Sun, 26 Jan 2020 20:28:33 -0800 Subject: [PATCH 1/7] replace client channel --- src/channel/index.ts | 2 +- typings/index.d.ts | 1 + web-app/src/Routes.tsx | 3 + web-app/src/components/Router/index.tsx | 69 ++-- web-app/src/containers/New/NewPage.tsx | 4 +- .../src/containers/New/TutorialList/index.tsx | 5 +- web-app/src/containers/New/index.tsx | 2 +- web-app/src/services/channel/index.ts | 69 ---- web-app/src/services/channel/mock.ts | 32 -- web-app/src/services/channel/receiver.ts | 12 - web-app/src/services/state/actions/api.ts | 68 ---- web-app/src/services/state/actions/context.ts | 11 +- web-app/src/services/state/actions/editor.ts | 23 +- web-app/src/services/state/actions/index.ts | 11 - web-app/src/services/state/machine.ts | 355 +++++++++--------- .../services/state/services/authenticate.ts | 64 ++++ web-app/src/services/state/services/index.ts | 1 + 17 files changed, 316 insertions(+), 416 deletions(-) delete mode 100644 web-app/src/services/channel/index.ts delete mode 100644 web-app/src/services/channel/mock.ts delete mode 100644 web-app/src/services/channel/receiver.ts delete mode 100644 web-app/src/services/state/actions/api.ts delete mode 100644 web-app/src/services/state/actions/index.ts create mode 100644 web-app/src/services/state/services/authenticate.ts create mode 100644 web-app/src/services/state/services/index.ts diff --git a/src/channel/index.ts b/src/channel/index.ts index 8284b269..7ef5692f 100644 --- a/src/channel/index.ts +++ b/src/channel/index.ts @@ -40,7 +40,7 @@ class Channel implements Channel { const onError = (error: T.ErrorMessage) => this.send({ type: 'ERROR', payload: { error } }) switch (actionType) { - case 'ENV_GET': + case 'EDITOR_ENV_GET': this.send({ type: 'ENV_LOAD', payload: { diff --git a/typings/index.d.ts b/typings/index.d.ts index bc07807c..be377728 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -63,6 +63,7 @@ export interface MachineStateSchema { states: { Startup: {} Authenticate: {} + Error: {} NewOrContinue: {} SelectTutorial: {} ContinueTutorial: {} diff --git a/web-app/src/Routes.tsx b/web-app/src/Routes.tsx index ed89f5b6..829b43a2 100644 --- a/web-app/src/Routes.tsx +++ b/web-app/src/Routes.tsx @@ -21,6 +21,9 @@ const Routes = () => { + +
Error
+
diff --git a/web-app/src/components/Router/index.tsx b/web-app/src/components/Router/index.tsx index 3d7023d9..35eff8c5 100644 --- a/web-app/src/components/Router/index.tsx +++ b/web-app/src/components/Router/index.tsx @@ -1,8 +1,6 @@ import * as React from 'react' import * as CR from 'typings' -import channel from '../../services/channel' -import messageBusReceiver from '../../services/channel/receiver' -import machine from '../../services/state/machine' +import { createMachine } from '../../services/state/machine' import { useMachine } from '../../services/xstate-react' import debuggerWrapper from '../Debugger/debuggerWrapper' import Route from './Route' @@ -17,38 +15,43 @@ interface CloneElementProps { send(action: CR.Action): void } +declare let acquireVsCodeApi: any + +const editor = acquireVsCodeApi() + // router finds first state match of const Router = ({ children }: Props): React.ReactElement | null => { - const [state, send] = useMachine(machine, { - interpreterOptions: { - logger: console.log.bind('XSTATE:'), - }, - }) - - channel.setMachineSend(send) - - // event bus listener - React.useEffect(messageBusReceiver, []) - - const childArray = React.Children.toArray(children) - for (const child of childArray) { - const { path } = child.props - let pathMatch - if (typeof path === 'string') { - pathMatch = state.matches(path) - } else if (Array.isArray(path)) { - pathMatch = path.some(p => state.matches(p)) - } else { - throw new Error(`Invalid route path ${JSON.stringify(path)}`) - } - if (pathMatch) { - const element = React.cloneElement(child.props.children, { send, context: state.context }) - return debuggerWrapper(element, state) - } - } - const message = `No Route matches for ${JSON.stringify(state)}` - onError(new Error(message)) - console.warn(message) + // const [state, send] = useMachine(createMachine({ editorSend: editor.postMessage })) + + // // event bus listener + // React.useEffect(() => { + // const listener = 'message' + // window.addEventListener(listener, send) + // return () => { + // window.removeEventListener(listener, send) + // } + // }, []) + + // const childArray = React.Children.toArray(children) + // for (const child of childArray) { + // const { path } = child.props + // let pathMatch + // if (typeof path === 'string') { + // pathMatch = state.matches(path) + // } else if (Array.isArray(path)) { + // pathMatch = path.some(p => state.matches(p)) + // } else { + // throw new Error(`Invalid route path ${JSON.stringify(path)}`) + // } + // if (pathMatch) { + // // @ts-ignore + // const element = React.cloneElement(child.props.children, { send, context: state.context }) + // return debuggerWrapper(element, state) + // } + // } + // const message = `No Route matches for ${JSON.stringify(state)}` + // onError(new Error(message)) + // console.warn(message) return null } diff --git a/web-app/src/containers/New/NewPage.tsx b/web-app/src/containers/New/NewPage.tsx index b2eb55a1..73bbc444 100644 --- a/web-app/src/containers/New/NewPage.tsx +++ b/web-app/src/containers/New/NewPage.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import * as T from 'typings' import * as G from 'typings/graphql' import { css, jsx } from '@emotion/core' import TutorialList from './TutorialList' @@ -23,6 +24,7 @@ const styles = { } interface Props { + send(action: T.Action): void tutorialList: G.Tutorial[] } @@ -34,7 +36,7 @@ const NewPage = (props: Props) => (
Select a Tutorial to Start
- + ) diff --git a/web-app/src/containers/New/TutorialList/index.tsx b/web-app/src/containers/New/TutorialList/index.tsx index f337d5ca..78223e66 100644 --- a/web-app/src/containers/New/TutorialList/index.tsx +++ b/web-app/src/containers/New/TutorialList/index.tsx @@ -1,15 +1,16 @@ import * as React from 'react' +import * as T from 'typings' import * as G from 'typings/graphql' -import channel from '../../../services/channel' import TutorialItem from './TutorialItem' interface Props { + send(action: T.Action): void tutorialList: G.Tutorial[] } const TutorialList = (props: Props) => { const onSelect = (tutorial: G.Tutorial) => { - channel.machineSend({ + props.send({ type: 'TUTORIAL_START', payload: { tutorial, diff --git a/web-app/src/containers/New/index.tsx b/web-app/src/containers/New/index.tsx index 74472c8a..40385fd5 100644 --- a/web-app/src/containers/New/index.tsx +++ b/web-app/src/containers/New/index.tsx @@ -31,7 +31,7 @@ const NewPageContainer = (props: ContainerProps) => { return null } - return + return } export default NewPageContainer diff --git a/web-app/src/services/channel/index.ts b/web-app/src/services/channel/index.ts deleted file mode 100644 index 64bf55c5..00000000 --- a/web-app/src/services/channel/index.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Action } from 'typings' - -declare let acquireVsCodeApi: any - -interface ReceivedEvent { - data: Action -} - -class Channel { - constructor() { - // setup mock if browser only - // @ts-ignore - if (!window.acquireVsCodeApi) { - // @ts-ignore - require('./mock') - } - - // Loads VSCode webview connection with editor - const editor = acquireVsCodeApi() - - this.editorSend = editor.postMessage - } - - public machineSend = (action: Action | string) => { - /* implemented by `setMachineSend` in router on startup */ - } - public editorSend = (action: Action) => { - /* */ - } - - public setMachineSend = (send: any) => { - this.machineSend = send - } - public receive = (event: ReceivedEvent) => { - // NOTE: must call event.data, cannot destructure. VSCode acts odd - const action = event.data - - // @ts-ignore // ignore browser events from plugins - if (action.source) { - return - } - - // messages from core - switch (action.type) { - case 'ENV_LOAD': - case 'AUTHENTICATED': - case 'TUTORIAL_LOADED': - case 'NEW_TUTORIAL': - case 'TUTORIAL_CONFIGURED': - case 'CONTINUE_TUTORIAL': - case 'TEST_PASS': - case 'TEST_FAIL': - case 'TEST_RUNNING': - case 'TEST_ERROR': - case 'COMMAND_START': - case 'COMMAND_SUCCESS': - case 'COMMAND_FAIL': - case 'ERROR': - this.machineSend(action) - return - default: - if (action.type) { - console.warn(`Unknown received action ${action.type}`, action) - } - } - } -} - -export default new Channel() diff --git a/web-app/src/services/channel/mock.ts b/web-app/src/services/channel/mock.ts deleted file mode 100644 index 43ab9dfa..00000000 --- a/web-app/src/services/channel/mock.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Action } from 'typings' -import channel from './index' - -const createReceiveEvent = (action: Action) => ({ - data: action, -}) - -// mock vscode from client side development -// @ts-ignore -window.acquireVsCodeApi = () => ({ - postMessage(action: Action) { - switch (action.type) { - case 'TUTORIAL_START': - return setTimeout(() => { - const receiveAction: Action = { - type: 'TUTORIAL_LOADED', - } - channel.receive(createReceiveEvent(receiveAction)) - }, 1000) - case 'TEST_RUN': - return setTimeout(() => { - const receiveAction: Action = { - type: 'TEST_PASS', - payload: action.payload, - } - channel.receive(createReceiveEvent(receiveAction)) - }, 1000) - default: - console.warn(`${action.type} not found in post message mock`) - } - }, -}) diff --git a/web-app/src/services/channel/receiver.ts b/web-app/src/services/channel/receiver.ts deleted file mode 100644 index 9cf20789..00000000 --- a/web-app/src/services/channel/receiver.ts +++ /dev/null @@ -1,12 +0,0 @@ -import channel from './index' - -const messageBusReceiver = () => { - // update state based on response from editor - const listener = 'message' - window.addEventListener(listener, channel.receive) - return () => { - window.removeEventListener(listener, channel.receive) - } -} - -export default messageBusReceiver diff --git a/web-app/src/services/state/actions/api.ts b/web-app/src/services/state/actions/api.ts deleted file mode 100644 index 1390dccd..00000000 --- a/web-app/src/services/state/actions/api.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as CR from 'typings' -import * as G from 'typings/graphql' -import client from '../../apollo' -import { setAuthToken } from '../../apollo/auth' -import authenticateMutation from '../../apollo/mutations/authenticate' -import channel from '../../channel' -import onError from '../../../services/sentry/onError' - -interface AuthenticateData { - editorLogin: { - token: string - user: G.User - } -} - -interface AuthenticateVariables { - machineId: string - sessionId: string - editor: 'VSCODE' -} - -export default { - authenticate: async (context: CR.MachineContext): Promise => { - const result = await client - .mutate({ - mutation: authenticateMutation, - variables: { - machineId: context.env.machineId, - sessionId: context.env.sessionId, - editor: 'VSCODE', - }, - }) - .catch(error => { - onError(error) - console.log('ERROR: Authentication failed') - console.log(error.message) - let message - if (error.message.match(/Network error:/)) { - message = { - title: 'Network Error', - description: 'Make sure you have an Internet connection. Restart and try again', - } - } else { - message = { - title: 'Server Error', - description: error.message, - } - } - channel.receive({ data: { type: 'ERROR', payload: { error: message } } }) - return - }) - - if (!result || !result.data) { - const error = new Error('Authentication request responded with no data') - console.log(error) - onError(error) - return - } - const { token } = result.data.editorLogin - // add token to headers - setAuthToken(token) - // pass authenticated action back to state machine - channel.receive({ data: { type: 'AUTHENTICATED' } }) - }, - userTutorialComplete(context: CR.MachineContext) { - console.log('should update user tutorial as complete') - }, -} diff --git a/web-app/src/services/state/actions/context.ts b/web-app/src/services/state/actions/context.ts index 8e1c3df3..be984f52 100644 --- a/web-app/src/services/state/actions/context.ts +++ b/web-app/src/services/state/actions/context.ts @@ -1,10 +1,11 @@ import * as CR from 'typings' import * as G from 'typings/graphql' -import { assign, send } from 'xstate' +import { assign, send, ActionFunctionMap } from 'xstate' import * as selectors from '../../selectors' import onError from '../../../services/sentry/onError' -export default { +const contextActions: ActionFunctionMap = { + // @ts-ignore setEnv: assign({ env: (context: CR.MachineContext, event: CR.MachineEvent) => { return { @@ -13,6 +14,7 @@ export default { } }, }), + // @ts-ignore continueTutorial: assign({ tutorial: (context: CR.MachineContext, event: CR.MachineEvent) => { return event.payload.tutorial @@ -24,6 +26,7 @@ export default { return event.payload.position }, }), + // @ts-ignore newTutorial: assign({ tutorial: (context: CR.MachineContext, event: CR.MachineEvent): any => { return event.payload.tutorial @@ -32,6 +35,7 @@ export default { return { levels: {}, steps: {}, complete: false } }, }), + // @ts-ignore initTutorial: assign({ // loads complete tutorial tutorial: (context: CR.MachineContext, event: CR.MachineEvent): any => { @@ -201,6 +205,7 @@ export default { } }, ), + // @ts-ignore reset: assign({ tutorial() { return null @@ -221,3 +226,5 @@ export default { }, }), } + +export default contextActions diff --git a/web-app/src/services/state/actions/editor.ts b/web-app/src/services/state/actions/editor.ts index 4d84d869..26d0db55 100644 --- a/web-app/src/services/state/actions/editor.ts +++ b/web-app/src/services/state/actions/editor.ts @@ -2,7 +2,6 @@ import * as CR from 'typings' import * as G from 'typings/graphql' import client from '../../apollo' import tutorialQuery from '../../apollo/queries/tutorial' -import channel from '../../channel' import * as selectors from '../../selectors' import onError from '../../../services/sentry/onError' @@ -15,16 +14,16 @@ interface TutorialDataVariables { // version: string } -export default { +export default (editorSend: any) => ({ loadEnv(): void { - channel.editorSend({ - type: 'ENV_GET', + editorSend({ + type: 'EDITOR_ENV_GET', }) }, loadStoredTutorial(): void { // send message to editor to see if there is existing tutorial progress // in local storage on the editor - channel.editorSend({ + editorSend({ type: 'EDITOR_TUTORIAL_LOAD', }) }, @@ -51,7 +50,7 @@ export default { return Promise.reject(message) } - channel.editorSend({ + editorSend({ type: 'EDITOR_TUTORIAL_CONFIG', payload: { tutorial: result.data.tutorial }, }) @@ -63,7 +62,7 @@ export default { }) }, continueConfig(context: CR.MachineContext) { - channel.editorSend({ + editorSend({ type: 'EDITOR_TUTORIAL_CONTINUE_CONFIG', payload: { // pass position because current stepId or first stepId will be empty @@ -75,7 +74,7 @@ export default { const level: G.Level = selectors.currentLevel(context) if (level.setup) { // load step actions - channel.editorSend({ + editorSend({ type: 'SETUP_ACTIONS', payload: level.setup, }) @@ -85,7 +84,7 @@ export default { const step: G.Step = selectors.currentStep(context) if (step.setup) { // load step actions - channel.editorSend({ + editorSend({ type: 'SETUP_ACTIONS', payload: { stepId: step.id, @@ -97,7 +96,7 @@ export default { editorLoadSolution(context: CR.MachineContext): void { const step: G.Step = selectors.currentStep(context) // tell editor to load solution commit - channel.editorSend({ + editorSend({ type: 'SOLUTION_ACTIONS', payload: { stepId: step.id, @@ -106,6 +105,6 @@ export default { }) }, clearStorage(): void { - channel.editorSend({ type: 'TUTORIAL_CLEAR' }) + editorSend({ type: 'TUTORIAL_CLEAR' }) }, -} +}) diff --git a/web-app/src/services/state/actions/index.ts b/web-app/src/services/state/actions/index.ts deleted file mode 100644 index 79cf318e..00000000 --- a/web-app/src/services/state/actions/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import apiActions from './api' -import commandActions from './command' -import contextActions from './context' -import editorActions from './editor' - -export default { - ...editorActions, - ...contextActions, - ...apiActions, - ...commandActions, -} diff --git a/web-app/src/services/state/machine.ts b/web-app/src/services/state/machine.ts index dc6b37b5..9d375eb4 100644 --- a/web-app/src/services/state/machine.ts +++ b/web-app/src/services/state/machine.ts @@ -1,215 +1,226 @@ import * as CR from 'typings' -import { Machine, MachineOptions } from 'xstate' -import actions from './actions' +import { assign, Machine, MachineOptions } from 'xstate' +import editorActions from './actions/editor' +import commandActions from './actions/command' +import contextActions from './actions/context' +import * as services from './services' -const options: MachineOptions = { - // @ts-ignore - actions, -} +const createOptions = ({ editorSend }: any): MachineOptions => ({ + activities: {}, + actions: { + ...editorActions(editorSend), + ...contextActions, + ...commandActions, + }, + guards: {}, + services: {}, + delays: {}, +}) -export const machine = Machine( - { - id: 'root', - initial: 'Start', - context: { - error: null, - env: { machineId: '', sessionId: '', token: '' }, - tutorial: null, - position: { levelId: '', stepId: '' }, - progress: { - levels: {}, - steps: {}, - complete: false, +export const createMachine = (options: any) => + Machine( + { + id: 'root', + initial: 'Start', + context: { + error: null, + env: { machineId: '', sessionId: '', token: '' }, + tutorial: null, + position: { levelId: '', stepId: '' }, + progress: { + levels: {}, + steps: {}, + complete: false, + }, + processes: [], }, - processes: [], - }, - states: { - Start: { - initial: 'Startup', - states: { - Startup: { - onEntry: ['loadEnv'], - on: { - ENV_LOAD: { - target: 'Authenticate', - actions: ['setEnv'], + states: { + Start: { + initial: 'Startup', + states: { + Startup: { + onEntry: ['loadEnv'], + on: { + ENV_LOAD: { + target: 'Authenticate', + actions: ['setEnv'], + }, }, }, - }, - Authenticate: { - onEntry: ['authenticate'], - on: { - AUTHENTICATED: 'NewOrContinue', - ERROR: { - actions: ['setError'], + Authenticate: { + invoke: { + src: services.authenticate, + onDone: 'NewOrContinue', + onError: { + target: 'Error', + actions: assign({ error: (context, event) => event.data }), + }, }, }, - }, - NewOrContinue: { - onEntry: ['loadStoredTutorial'], - on: { - CONTINUE_TUTORIAL: { - target: 'ContinueTutorial', - actions: ['continueTutorial'], - }, - NEW_TUTORIAL: { - target: 'SelectTutorial', + Error: {}, + NewOrContinue: { + onEntry: ['loadStoredTutorial'], + on: { + CONTINUE_TUTORIAL: { + target: 'ContinueTutorial', + actions: ['continueTutorial'], + }, + NEW_TUTORIAL: { + target: 'SelectTutorial', + }, }, }, - }, - SelectTutorial: { - onEntry: ['clearStorage'], - id: 'start-new-tutorial', - on: { - TUTORIAL_START: { - target: '#tutorial', - actions: ['newTutorial'], + SelectTutorial: { + onEntry: ['clearStorage'], + id: 'start-new-tutorial', + on: { + TUTORIAL_START: { + target: '#tutorial', + actions: ['newTutorial'], + }, }, }, - }, - ContinueTutorial: { - on: { - TUTORIAL_START: { - target: '#tutorial-level', - actions: ['continueConfig'], + ContinueTutorial: { + on: { + TUTORIAL_START: { + target: '#tutorial-level', + actions: ['continueConfig'], + }, + TUTORIAL_SELECT: 'SelectTutorial', }, - TUTORIAL_SELECT: 'SelectTutorial', }, }, }, - }, - Tutorial: { - id: 'tutorial', - initial: 'Initialize', - on: { - // track commands - COMMAND_START: { - actions: ['commandStart'], - }, - COMMAND_SUCCESS: { - actions: ['commandSuccess'], - }, - COMMAND_FAIL: { - actions: ['commandFail'], - }, - ERROR: { - actions: ['setError'], - }, - }, - states: { - // TODO move Initialize into New Tutorial setup - Initialize: { - onEntry: ['initializeTutorial'], - on: { - TUTORIAL_CONFIGURED: 'Summary', - // TUTORIAL_CONFIG_ERROR: 'Start' // TODO should handle error + Tutorial: { + id: 'tutorial', + initial: 'Initialize', + on: { + // track commands + COMMAND_START: { + actions: ['commandStart'], }, - }, - Summary: { - on: { - LOAD_TUTORIAL: { - target: 'Level', - actions: ['initPosition', 'initTutorial'], - }, + COMMAND_SUCCESS: { + actions: ['commandSuccess'], + }, + COMMAND_FAIL: { + actions: ['commandFail'], + }, + ERROR: { + actions: ['setError'], }, }, - LoadNext: { - id: 'tutorial-load-next', - onEntry: ['loadNext'], - on: { - NEXT_STEP: { - target: 'Level', - actions: ['updatePosition'], + states: { + // TODO move Initialize into New Tutorial setup + Initialize: { + onEntry: ['initializeTutorial'], + on: { + TUTORIAL_CONFIGURED: 'Summary', + // TUTORIAL_CONFIG_ERROR: 'Start' // TODO should handle error }, - NEXT_LEVEL: { - target: 'Level', // TODO should return to levels summary page - actions: ['updatePosition'], + }, + Summary: { + on: { + LOAD_TUTORIAL: { + target: 'Level', + actions: ['initPosition', 'initTutorial'], + }, }, - COMPLETED: '#completed-tutorial', }, - }, - Level: { - initial: 'Load', - states: { - Load: { - onEntry: ['loadLevel', 'loadStep'], - after: { - 0: 'Normal', + LoadNext: { + id: 'tutorial-load-next', + onEntry: ['loadNext'], + on: { + NEXT_STEP: { + target: 'Level', + actions: ['updatePosition'], + }, + NEXT_LEVEL: { + target: 'Level', // TODO should return to levels summary page + actions: ['updatePosition'], }, + COMPLETED: '#completed-tutorial', }, - Normal: { - id: 'tutorial-level', - on: { - TEST_RUNNING: 'TestRunning', - STEP_SOLUTION_LOAD: { - actions: ['editorLoadSolution'], + }, + Level: { + initial: 'Load', + states: { + Load: { + onEntry: ['loadLevel', 'loadStep'], + after: { + 0: 'Normal', }, }, - }, - TestRunning: { - onEntry: ['testStart'], - on: { - TEST_PASS: { - target: 'TestPass', - actions: ['updateStepProgress'], + Normal: { + id: 'tutorial-level', + on: { + TEST_RUNNING: 'TestRunning', + STEP_SOLUTION_LOAD: { + actions: ['editorLoadSolution'], + }, }, - TEST_FAIL: 'TestFail', - TEST_ERROR: 'TestError', }, - }, - TestError: { - onEntry: ['testFail'], - after: { - 0: 'Normal', + TestRunning: { + onEntry: ['testStart'], + on: { + TEST_PASS: { + target: 'TestPass', + actions: ['updateStepProgress'], + }, + TEST_FAIL: 'TestFail', + TEST_ERROR: 'TestError', + }, }, - }, - TestPass: { - onExit: ['updateStepPosition'], - after: { - 1000: 'StepNext', + TestError: { + onEntry: ['testFail'], + after: { + 0: 'Normal', + }, }, - }, - TestFail: { - onEntry: ['testFail'], - after: { - 0: 'Normal', + TestPass: { + onExit: ['updateStepPosition'], + after: { + 1000: 'StepNext', + }, }, - }, - StepNext: { - onEntry: ['stepNext'], - on: { - LOAD_NEXT_STEP: { - target: 'Normal', - actions: ['loadStep'], + TestFail: { + onEntry: ['testFail'], + after: { + 0: 'Normal', }, - LEVEL_COMPLETE: { - target: 'LevelComplete', - actions: ['updateLevelProgress'], + }, + StepNext: { + onEntry: ['stepNext'], + on: { + LOAD_NEXT_STEP: { + target: 'Normal', + actions: ['loadStep'], + }, + LEVEL_COMPLETE: { + target: 'LevelComplete', + actions: ['updateLevelProgress'], + }, }, }, - }, - LevelComplete: { - on: { - LEVEL_NEXT: '#tutorial-load-next', + LevelComplete: { + on: { + LEVEL_NEXT: '#tutorial-load-next', + }, }, }, }, - }, - Completed: { - id: 'completed-tutorial', - onEntry: ['userTutorialComplete'], - on: { - SELECT_TUTORIAL: { - target: '#start-new-tutorial', - actions: ['reset'], + Completed: { + id: 'completed-tutorial', + onEntry: ['userTutorialComplete'], + on: { + SELECT_TUTORIAL: { + target: '#start-new-tutorial', + actions: ['reset'], + }, }, }, }, }, }, }, - }, - options, -) - -export default machine + createOptions(options), + ) diff --git a/web-app/src/services/state/services/authenticate.ts b/web-app/src/services/state/services/authenticate.ts new file mode 100644 index 00000000..88eab6a5 --- /dev/null +++ b/web-app/src/services/state/services/authenticate.ts @@ -0,0 +1,64 @@ +import * as CR from 'typings' +import * as G from 'typings/graphql' +import client from '../../apollo' +import { setAuthToken } from '../../apollo/auth' +import authenticateMutation from '../../apollo/mutations/authenticate' +import onError from '../../../services/sentry/onError' + +interface AuthenticateData { + editorLogin: { + token: string + user: G.User + } +} + +interface AuthenticateVariables { + machineId: string + sessionId: string + editor: 'VSCODE' +} + +export async function authenticate(context: CR.MachineContext): Promise { + const result = await client + .mutate({ + mutation: authenticateMutation, + variables: { + machineId: context.env.machineId, + sessionId: context.env.sessionId, + editor: 'VSCODE', + }, + }) + .catch(error => { + onError(error) + console.log('ERROR: Authentication failed') + console.log(error.message) + // let message + if (error.message.match(/Network error:/)) { + throw { + error: { + title: 'Network Error', + description: 'Make sure you have an Internet connection. Restart and try again', + }, + } + } else { + throw { + error: { + title: 'Server Error', + description: error.message, + }, + } + } + }) + + if (!result || !result.data) { + const error = new Error('Authentication request responded with no data') + console.log(error) + onError(error) + return + } + const { token } = result.data.editorLogin + // add token to headers + setAuthToken(token) + // pass authenticated action back to state machine + return +} diff --git a/web-app/src/services/state/services/index.ts b/web-app/src/services/state/services/index.ts new file mode 100644 index 00000000..b5b929e0 --- /dev/null +++ b/web-app/src/services/state/services/index.ts @@ -0,0 +1 @@ +export { authenticate } from './authenticate' From be25c8a2266d4b99b7a26b6b8017590e2a95c270 Mon Sep 17 00:00:00 2001 From: shmck Date: Sun, 26 Jan 2020 20:42:59 -0800 Subject: [PATCH 2/7] auth service --- web-app/src/components/Router/index.tsx | 62 +++++++++---------- web-app/src/services/state/machine.ts | 8 ++- .../services/state/services/authenticate.ts | 3 +- 3 files changed, 40 insertions(+), 33 deletions(-) diff --git a/web-app/src/components/Router/index.tsx b/web-app/src/components/Router/index.tsx index 35eff8c5..a143c863 100644 --- a/web-app/src/components/Router/index.tsx +++ b/web-app/src/components/Router/index.tsx @@ -21,37 +21,37 @@ const editor = acquireVsCodeApi() // router finds first state match of const Router = ({ children }: Props): React.ReactElement | null => { - // const [state, send] = useMachine(createMachine({ editorSend: editor.postMessage })) - - // // event bus listener - // React.useEffect(() => { - // const listener = 'message' - // window.addEventListener(listener, send) - // return () => { - // window.removeEventListener(listener, send) - // } - // }, []) - - // const childArray = React.Children.toArray(children) - // for (const child of childArray) { - // const { path } = child.props - // let pathMatch - // if (typeof path === 'string') { - // pathMatch = state.matches(path) - // } else if (Array.isArray(path)) { - // pathMatch = path.some(p => state.matches(p)) - // } else { - // throw new Error(`Invalid route path ${JSON.stringify(path)}`) - // } - // if (pathMatch) { - // // @ts-ignore - // const element = React.cloneElement(child.props.children, { send, context: state.context }) - // return debuggerWrapper(element, state) - // } - // } - // const message = `No Route matches for ${JSON.stringify(state)}` - // onError(new Error(message)) - // console.warn(message) + const [state, send] = useMachine(createMachine({ editorSend: editor.postMessage })) + + // event bus listener + React.useEffect(() => { + const listener = 'message' + window.addEventListener(listener, send) + return () => { + window.removeEventListener(listener, send) + } + }, []) + + const childArray = React.Children.toArray(children) + for (const child of childArray) { + const { path } = child.props + let pathMatch + if (typeof path === 'string') { + pathMatch = state.matches(path) + } else if (Array.isArray(path)) { + pathMatch = path.some(p => state.matches(p)) + } else { + throw new Error(`Invalid route path ${JSON.stringify(path)}`) + } + if (pathMatch) { + // @ts-ignore + const element = React.cloneElement(child.props.children, { send, context: state.context }) + return debuggerWrapper(element, state) + } + } + const message = `No Route matches for ${JSON.stringify(state)}` + onError(new Error(message)) + console.warn(message) return null } diff --git a/web-app/src/services/state/machine.ts b/web-app/src/services/state/machine.ts index 9d375eb4..6b6de42e 100644 --- a/web-app/src/services/state/machine.ts +++ b/web-app/src/services/state/machine.ts @@ -53,7 +53,13 @@ export const createMachine = (options: any) => onDone: 'NewOrContinue', onError: { target: 'Error', - actions: assign({ error: (context, event) => event.data }), + actions: assign({ + error: (context, event) => { + console.log('ERROR') + console.log(JSON.stringify(event)) + return event.data + }, + }), }, }, }, diff --git a/web-app/src/services/state/services/authenticate.ts b/web-app/src/services/state/services/authenticate.ts index 88eab6a5..a115c27f 100644 --- a/web-app/src/services/state/services/authenticate.ts +++ b/web-app/src/services/state/services/authenticate.ts @@ -18,7 +18,7 @@ interface AuthenticateVariables { editor: 'VSCODE' } -export async function authenticate(context: CR.MachineContext): Promise { +export async function authenticate(context: CR.MachineContext): Promise { const result = await client .mutate({ mutation: authenticateMutation, @@ -57,6 +57,7 @@ export async function authenticate(context: CR.MachineContext): Promise { return } const { token } = result.data.editorLogin + console.log(`Token: ${token}`) // add token to headers setAuthToken(token) // pass authenticated action back to state machine From dec93544883cc26532cf05208befe1b7a79c71d5 Mon Sep 17 00:00:00 2001 From: shmck Date: Tue, 28 Jan 2020 19:24:45 -0800 Subject: [PATCH 3/7] cleanup client channel --- web-app/src/components/Router/index.tsx | 14 ++++++++++++-- web-app/src/services/state/machine.ts | 7 ++++--- .../src/services/state/services/authenticate.ts | 15 +++++++-------- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/web-app/src/components/Router/index.tsx b/web-app/src/components/Router/index.tsx index a143c863..b5f0114c 100644 --- a/web-app/src/components/Router/index.tsx +++ b/web-app/src/components/Router/index.tsx @@ -26,9 +26,19 @@ const Router = ({ children }: Props): React.ReactElement | nu // event bus listener React.useEffect(() => { const listener = 'message' - window.addEventListener(listener, send) + // propograte channel event to state machine + const handler = (action: any) => { + // NOTE: must call event.data, cannot destructure. VSCode acts odd + const event = action.data + // ignore browser events from plugins + if (event.source) { + return + } + send(event) + } + window.addEventListener(listener, handler) return () => { - window.removeEventListener(listener, send) + window.removeEventListener(listener, handler) } }, []) diff --git a/web-app/src/services/state/machine.ts b/web-app/src/services/state/machine.ts index 6b6de42e..41e17843 100644 --- a/web-app/src/services/state/machine.ts +++ b/web-app/src/services/state/machine.ts @@ -1,5 +1,5 @@ import * as CR from 'typings' -import { assign, Machine, MachineOptions } from 'xstate' +import { assign, Machine, MachineOptions, actions } from 'xstate' import editorActions from './actions/editor' import commandActions from './actions/command' import contextActions from './actions/context' @@ -17,8 +17,8 @@ const createOptions = ({ editorSend }: any): MachineOptions - Machine( +export const createMachine = (options: any) => { + return Machine( { id: 'root', initial: 'Start', @@ -230,3 +230,4 @@ export const createMachine = (options: any) => }, createOptions(options), ) +} diff --git a/web-app/src/services/state/services/authenticate.ts b/web-app/src/services/state/services/authenticate.ts index a115c27f..e430ed61 100644 --- a/web-app/src/services/state/services/authenticate.ts +++ b/web-app/src/services/state/services/authenticate.ts @@ -29,37 +29,36 @@ export async function authenticate(context: CR.MachineContext): Promise { }, }) .catch(error => { - onError(error) + // onError(error) console.log('ERROR: Authentication failed') console.log(error.message) // let message if (error.message.match(/Network error:/)) { - throw { + return Promise.reject({ error: { title: 'Network Error', description: 'Make sure you have an Internet connection. Restart and try again', }, - } + }) } else { - throw { + return Promise.reject({ error: { title: 'Server Error', description: error.message, }, - } + }) } }) if (!result || !result.data) { const error = new Error('Authentication request responded with no data') console.log(error) - onError(error) + // onError(error) return } const { token } = result.data.editorLogin - console.log(`Token: ${token}`) // add token to headers setAuthToken(token) // pass authenticated action back to state machine - return + return Promise.resolve() } From 69190ec996d5f27a4806f30357b613660cbc218b Mon Sep 17 00:00:00 2001 From: shmck Date: Tue, 28 Jan 2020 19:45:40 -0800 Subject: [PATCH 4/7] invoke initialize tutorial --- web-app/src/services/state/actions/editor.ts | 46 ------------------- web-app/src/services/state/machine.ts | 24 ++++++---- .../services/state/services/authenticate.ts | 4 +- web-app/src/services/state/services/index.ts | 1 + .../src/services/state/services/initialize.ts | 43 +++++++++++++++++ 5 files changed, 61 insertions(+), 57 deletions(-) create mode 100644 web-app/src/services/state/services/initialize.ts diff --git a/web-app/src/services/state/actions/editor.ts b/web-app/src/services/state/actions/editor.ts index 26d0db55..0488b439 100644 --- a/web-app/src/services/state/actions/editor.ts +++ b/web-app/src/services/state/actions/editor.ts @@ -1,18 +1,6 @@ import * as CR from 'typings' import * as G from 'typings/graphql' -import client from '../../apollo' -import tutorialQuery from '../../apollo/queries/tutorial' import * as selectors from '../../selectors' -import onError from '../../../services/sentry/onError' - -interface TutorialData { - tutorial: G.Tutorial -} - -interface TutorialDataVariables { - tutorialId: string - // version: string -} export default (editorSend: any) => ({ loadEnv(): void { @@ -27,40 +15,6 @@ export default (editorSend: any) => ({ type: 'EDITOR_TUTORIAL_LOAD', }) }, - initializeTutorial(context: CR.MachineContext, event: CR.MachineEvent) { - // setup test runner and git - if (!context.tutorial) { - const error = new Error('Tutorial not available to load') - onError(error) - throw error - } - - client - .query({ - query: tutorialQuery, - variables: { - tutorialId: context.tutorial.id, - // version: context.tutorial.version.version, // TODO: reimplement version - }, - }) - .then(result => { - if (!result || !result.data || !result.data.tutorial) { - const message = 'No tutorial returned from tutorial config query' - onError(new Error(message)) - return Promise.reject(message) - } - - editorSend({ - type: 'EDITOR_TUTORIAL_CONFIG', - payload: { tutorial: result.data.tutorial }, - }) - }) - .catch((error: Error) => { - const message = `Failed to load tutorial config ${error.message}` - onError(new Error(message)) - return Promise.reject(message) - }) - }, continueConfig(context: CR.MachineContext) { editorSend({ type: 'EDITOR_TUTORIAL_CONTINUE_CONFIG', diff --git a/web-app/src/services/state/machine.ts b/web-app/src/services/state/machine.ts index 41e17843..19b0db14 100644 --- a/web-app/src/services/state/machine.ts +++ b/web-app/src/services/state/machine.ts @@ -54,11 +54,7 @@ export const createMachine = (options: any) => { onError: { target: 'Error', actions: assign({ - error: (context, event) => { - console.log('ERROR') - console.log(JSON.stringify(event)) - return event.data - }, + error: (context, event) => event.data, }), }, }, @@ -118,10 +114,20 @@ export const createMachine = (options: any) => { states: { // TODO move Initialize into New Tutorial setup Initialize: { - onEntry: ['initializeTutorial'], - on: { - TUTORIAL_CONFIGURED: 'Summary', - // TUTORIAL_CONFIG_ERROR: 'Start' // TODO should handle error + invoke: { + src: services.initialize, + onDone: { + target: 'Summary', + actions: assign({ + tutorial: (context, event) => event.data, + }), + }, + onError: { + target: 'Summary', + actions: assign({ + error: (context, event) => event.data, + }), + }, }, }, Summary: { diff --git a/web-app/src/services/state/services/authenticate.ts b/web-app/src/services/state/services/authenticate.ts index e430ed61..640c0901 100644 --- a/web-app/src/services/state/services/authenticate.ts +++ b/web-app/src/services/state/services/authenticate.ts @@ -29,7 +29,7 @@ export async function authenticate(context: CR.MachineContext): Promise { }, }) .catch(error => { - // onError(error) + onError(error) console.log('ERROR: Authentication failed') console.log(error.message) // let message @@ -53,7 +53,7 @@ export async function authenticate(context: CR.MachineContext): Promise { if (!result || !result.data) { const error = new Error('Authentication request responded with no data') console.log(error) - // onError(error) + onError(error) return } const { token } = result.data.editorLogin diff --git a/web-app/src/services/state/services/index.ts b/web-app/src/services/state/services/index.ts index b5b929e0..1e87068f 100644 --- a/web-app/src/services/state/services/index.ts +++ b/web-app/src/services/state/services/index.ts @@ -1 +1,2 @@ export { authenticate } from './authenticate' +export { initialize } from './initialize' diff --git a/web-app/src/services/state/services/initialize.ts b/web-app/src/services/state/services/initialize.ts new file mode 100644 index 00000000..b4bac48d --- /dev/null +++ b/web-app/src/services/state/services/initialize.ts @@ -0,0 +1,43 @@ +import * as CR from 'typings' +import * as G from 'typings/graphql' +import client from '../../apollo' +import tutorialQuery from '../../apollo/queries/tutorial' +import onError from '../../../services/sentry/onError' + +interface TutorialData { + tutorial: G.Tutorial +} + +interface TutorialDataVariables { + tutorialId: string + // version: string +} + +export async function initialize(context: CR.MachineContext): Promise { + // setup test runner and git + if (!context.tutorial) { + const error = new Error('Tutorial not available to load') + onError(error) + throw error + } + + try { + const result = await client.query({ + query: tutorialQuery, + variables: { + tutorialId: context.tutorial.id, + // version: context.tutorial.version.version, // TODO: reimplement version + }, + }) + if (!result || !result.data || !result.data.tutorial) { + const message = 'No tutorial returned from tutorial config query' + onError(new Error(message)) + return Promise.reject(message) + } + return Promise.resolve(result.data.tutorial) + } catch (error) { + const message: CR.ErrorMessage = { title: 'Failed to load tutorial config', description: error.message } + onError(error) + return Promise.reject(message) + } +} From 409df4763437433af524515633acfcb6fa723dea Mon Sep 17 00:00:00 2001 From: shmck Date: Tue, 28 Jan 2020 20:02:15 -0800 Subject: [PATCH 5/7] test custom error page --- typings/index.d.ts | 1 + web-app/src/Routes.tsx | 5 ++++- web-app/src/components/Error/index.tsx | 14 ++++++++++--- web-app/src/services/state/machine.ts | 3 ++- .../services/state/services/authenticate.ts | 20 +++++++++---------- 5 files changed, 28 insertions(+), 15 deletions(-) diff --git a/typings/index.d.ts b/typings/index.d.ts index be377728..24864c41 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -74,6 +74,7 @@ export interface MachineStateSchema { Initialize: {} Summary: {} LoadNext: {} + Error: {} Level: { states: { Load: {} diff --git a/web-app/src/Routes.tsx b/web-app/src/Routes.tsx index 829b43a2..f7162658 100644 --- a/web-app/src/Routes.tsx +++ b/web-app/src/Routes.tsx @@ -22,7 +22,7 @@ const Routes = () => { -
Error
+
Something went wrong wrong
@@ -30,6 +30,9 @@ const Routes = () => { + +
Something went wrong wrong
+
diff --git a/web-app/src/components/Error/index.tsx b/web-app/src/components/Error/index.tsx index 541e2b27..42b9c029 100644 --- a/web-app/src/components/Error/index.tsx +++ b/web-app/src/components/Error/index.tsx @@ -9,20 +9,28 @@ const styles = { color: '#D8000C', backgroundColor: '#FFBABA', padding: '1rem', + width: '100%', + height: '100%', }, } interface Props { - error: ApolloError + error?: ApolloError } const ErrorView = ({ error }: Props) => { // log error React.useEffect(() => { - console.log(error) - onError(error) + if (error) { + console.log(error) + onError(error) + } }, []) + if (!error) { + return null + } + return (

Error

diff --git a/web-app/src/services/state/machine.ts b/web-app/src/services/state/machine.ts index 19b0db14..718c50bd 100644 --- a/web-app/src/services/state/machine.ts +++ b/web-app/src/services/state/machine.ts @@ -123,13 +123,14 @@ export const createMachine = (options: any) => { }), }, onError: { - target: 'Summary', + target: 'Error', actions: assign({ error: (context, event) => event.data, }), }, }, }, + Error: {}, Summary: { on: { LOAD_TUTORIAL: { diff --git a/web-app/src/services/state/services/authenticate.ts b/web-app/src/services/state/services/authenticate.ts index 640c0901..84ab92b6 100644 --- a/web-app/src/services/state/services/authenticate.ts +++ b/web-app/src/services/state/services/authenticate.ts @@ -35,26 +35,26 @@ export async function authenticate(context: CR.MachineContext): Promise { // let message if (error.message.match(/Network error:/)) { return Promise.reject({ - error: { - title: 'Network Error', - description: 'Make sure you have an Internet connection. Restart and try again', - }, + title: 'Network Error', + description: 'Make sure you have an Internet connection. Restart and try again', }) } else { return Promise.reject({ - error: { - title: 'Server Error', - description: error.message, - }, + title: 'Server Error', + description: error.message, }) } }) if (!result || !result.data) { - const error = new Error('Authentication request responded with no data') + const message = 'Authentication request responded with no data' + const error = new Error() console.log(error) onError(error) - return + return Promise.reject({ + title: message, + description: 'Something went wrong.', + }) } const { token } = result.data.editorLogin // add token to headers From 1109c290be8ac02509ff8e77a09c1e7938ef74c7 Mon Sep 17 00:00:00 2001 From: shmck Date: Tue, 28 Jan 2020 20:13:58 -0800 Subject: [PATCH 6/7] cleanup router code --- web-app/src/Routes.tsx | 23 +++++------ web-app/src/components/Router/index.tsx | 54 ++++++++++++++----------- 2 files changed, 40 insertions(+), 37 deletions(-) diff --git a/web-app/src/Routes.tsx b/web-app/src/Routes.tsx index f7162658..e30c0b8d 100644 --- a/web-app/src/Routes.tsx +++ b/web-app/src/Routes.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import * as CR from 'typings' -import Router from './components/Router' +import useRouter from './components/Router' import Workspace from './components/Workspace' import ContinuePage from './containers/Continue' import LoadingPage from './containers/LoadingPage' @@ -9,44 +9,41 @@ import OverviewPage from './containers/Overview' import CompletedPage from './containers/Tutorial/CompletedPage' import LevelSummaryPage from './containers/Tutorial/LevelPage' -const { Route } = Router - -const tempSend = (action: any) => console.log('sent') - const Routes = () => { + const { context, send, Router, Route } = useRouter() // TODO refactor for typescript to understand send & context passed into React.cloneElement's return ( - +
Something went wrong wrong
- + - +
Something went wrong wrong
- + - + - + - + - +
diff --git a/web-app/src/components/Router/index.tsx b/web-app/src/components/Router/index.tsx index b5f0114c..83f57e53 100644 --- a/web-app/src/components/Router/index.tsx +++ b/web-app/src/components/Router/index.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import * as CR from 'typings' import { createMachine } from '../../services/state/machine' import { useMachine } from '../../services/xstate-react' -import debuggerWrapper from '../Debugger/debuggerWrapper' import Route from './Route' import onError from '../../services/sentry/onError' @@ -20,7 +19,7 @@ declare let acquireVsCodeApi: any const editor = acquireVsCodeApi() // router finds first state match of -const Router = ({ children }: Props): React.ReactElement | null => { +const useRouter = () => { const [state, send] = useMachine(createMachine({ editorSend: editor.postMessage })) // event bus listener @@ -42,29 +41,36 @@ const Router = ({ children }: Props): React.ReactElement | nu } }, []) - const childArray = React.Children.toArray(children) - for (const child of childArray) { - const { path } = child.props - let pathMatch - if (typeof path === 'string') { - pathMatch = state.matches(path) - } else if (Array.isArray(path)) { - pathMatch = path.some(p => state.matches(p)) - } else { - throw new Error(`Invalid route path ${JSON.stringify(path)}`) - } - if (pathMatch) { - // @ts-ignore - const element = React.cloneElement(child.props.children, { send, context: state.context }) - return debuggerWrapper(element, state) + const Router = ({ children }: Props): React.ReactElement | null => { + const childArray = React.Children.toArray(children) + for (const child of childArray) { + // match path + const { path } = child.props + let pathMatch + if (typeof path === 'string') { + pathMatch = state.matches(path) + } else if (Array.isArray(path)) { + pathMatch = path.some(p => state.matches(p)) + } else { + throw new Error(`Invalid route path ${JSON.stringify(path)}`) + } + if (pathMatch) { + // @ts-ignore + return child.props.children + } } + const message = `No Route matches for ${JSON.stringify(state)}` + onError(new Error(message)) + console.warn(message) + return null } - const message = `No Route matches for ${JSON.stringify(state)}` - onError(new Error(message)) - console.warn(message) - return null -} -Router.Route = Route + return { + context: state.context, + send, + Router, + Route, + } +} -export default Router +export default useRouter From 82d22a408a006cb904db6be9c7868b568b29b9c9 Mon Sep 17 00:00:00 2001 From: shmck Date: Tue, 28 Jan 2020 20:15:14 -0800 Subject: [PATCH 7/7] cleanup router typings --- web-app/src/components/Router/index.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/web-app/src/components/Router/index.tsx b/web-app/src/components/Router/index.tsx index 83f57e53..0e06bb0e 100644 --- a/web-app/src/components/Router/index.tsx +++ b/web-app/src/components/Router/index.tsx @@ -1,5 +1,4 @@ import * as React from 'react' -import * as CR from 'typings' import { createMachine } from '../../services/state/machine' import { useMachine } from '../../services/xstate-react' import Route from './Route' @@ -9,11 +8,6 @@ interface Props { children: any } -interface CloneElementProps { - context: CR.MachineContext - send(action: CR.Action): void -} - declare let acquireVsCodeApi: any const editor = acquireVsCodeApi() @@ -41,7 +35,7 @@ const useRouter = () => { } }, []) - const Router = ({ children }: Props): React.ReactElement | null => { + const Router = ({ children }: Props): React.ReactElement | null => { const childArray = React.Children.toArray(children) for (const child of childArray) { // match path