diff --git a/typings/index.d.ts b/typings/index.d.ts index f7cf5095..3e2cc4b4 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -76,7 +76,6 @@ export interface MachineStateSchema { } Tutorial: { states: { - LoadNext: {} Level: { states: { Load: {} @@ -86,6 +85,7 @@ export interface MachineStateSchema { TestFail: {} StepNext: {} LevelComplete: {} + LoadNext: {} } } Completed: {} diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 3c296c87..9f490dec 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -2,12 +2,15 @@ import * as React from 'react' import { ConfigProvider } from '@alifd/next' import enUS from '@alifd/next/lib/locale/en-us' import ErrorBoundary from './components/ErrorBoundary' +import Workspace from './components/Workspace' import Routes from './Routes' const App = () => ( - + + + ) diff --git a/web-app/src/Routes.tsx b/web-app/src/Routes.tsx index 26460463..2753a653 100644 --- a/web-app/src/Routes.tsx +++ b/web-app/src/Routes.tsx @@ -1,6 +1,6 @@ import * as React from 'react' -import useRouter from './components/Router' -import Workspace from './components/Workspace' +import useStateMachine from './services/state/useStateMachine' +import { Router, Route } from './components/Router' import ErrorView from './components/Error' import LoadingPage from './containers/Loading' import StartPage from './containers/Start' @@ -8,47 +8,45 @@ import SelectTutorialPage from './containers/SelectTutorial' import CompletedPage from './containers/Tutorial/CompletedPage' import TutorialPage from './containers/Tutorial' +/* + * NOTE: due to a lack of URLs and a dependency on xstate + * we have to implement a custom router here + */ const Routes = () => { - const { context, send, Router, Route } = useRouter() + const { context, route, send } = useStateMachine() // TODO: handle only full page errors if (context.error) { - return ( - - - - ) + return } return ( - - - {/* Setup */} - - - - - - - - - - - - - {/* Tutorial */} - - - - - - - {/* Completed */} - - - - - + + {/* Setup */} + + + + + + + + + + + + + {/* Tutorial */} + + + + + + + {/* Completed */} + + + + ) } diff --git a/web-app/src/components/Router/Route.tsx b/web-app/src/components/Router/Route.tsx deleted file mode 100644 index d73001df..00000000 --- a/web-app/src/components/Router/Route.tsx +++ /dev/null @@ -1,8 +0,0 @@ -interface Props { - children: any - path: string | string[] -} - -const Route = ({ children }: Props) => children - -export default Route diff --git a/web-app/src/components/Router/index.tsx b/web-app/src/components/Router/index.tsx index 8dda5bce..cf81e174 100644 --- a/web-app/src/components/Router/index.tsx +++ b/web-app/src/components/Router/index.tsx @@ -1,87 +1,54 @@ import * as React from 'react' -import * as T from 'typings' -import { createMachine } from '../../services/state/machine' -import { useMachine } from '../../services/xstate-react' -import Route from './Route' import onError from '../../services/sentry/onError' -import logger from '../../services/logger' -interface Output { - context: T.MachineContext - send: (action: any) => void - Router: any - Route: any +interface RouterProps { + children: React.ReactChildren | React.ReactChildren[] + route: string } -declare let acquireVsCodeApi: any - -const editor = acquireVsCodeApi() -const editorSend = (action: T.Action) => { - logger(`TO EXT: "${action.type}"`) - return editor.postMessage(action) -} - -// router finds first state match of -const useRouter = (): Output => { - const [state, send] = useMachine(createMachine({ editorSend })) - - const sendWithLog = (action: T.Action): void => { - logger(`SEND: ${action.type}`, action) - send(action) - } - - logger(`STATE: ${JSON.stringify(state.value)}`) - - // event bus listener - React.useEffect(() => { - const listener = 'message' - // propograte channel event to state machine - const handler = (event: any) => { - // NOTE: must call event.data, cannot destructure. VSCode acts odd - const action = event.data - // ignore browser events from plugins - if (action.source) { - return +// check if a route string (eg. 'a.b.c') +// matches a paths object ({ a: { b: { c: true }}}) +const matches = (route: string, paths: object): boolean => { + const keys: string[] = route.split('.') + let current: any = paths || {} + // if the key throws, there is no match + for (const key of keys) { + const next = current[key] + if (next) { + // exit early if property value is true + if (next === true) { + return true } - sendWithLog(action) - } - window.addEventListener(listener, handler) - return () => { - window.removeEventListener(listener, handler) + current = next + continue + } else { + return false } - }, []) + } + return true +} - const Router = ({ children }: any) => { - const childArray = React.Children.toArray(children) - for (const child of childArray) { - // match path - // @ts-ignore - 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 - } +export const Router = ({ children, route }: RouterProps) => { + // @ts-ignore may accept string as well as element + const childArray: React.ReactElement[] = React.Children.toArray(children) + for (const child of childArray) { + // match path + const { paths } = child.props + let pathMatch = matches(route, paths) + + if (pathMatch) { + 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(route)}"` + onError(new Error(message)) + console.warn(message) + return null +} - return { - context: state.context, - send: sendWithLog, - Router, - Route, - } +interface RouteProps { + children: any + paths: object } -export default useRouter +export const Route = ({ children }: RouteProps) => children diff --git a/web-app/src/containers/Tutorial/ContentMenu.tsx b/web-app/src/containers/Tutorial/components/ContentMenu.tsx similarity index 97% rename from web-app/src/containers/Tutorial/ContentMenu.tsx rename to web-app/src/containers/Tutorial/components/ContentMenu.tsx index 69e3329e..3399ed60 100644 --- a/web-app/src/containers/Tutorial/ContentMenu.tsx +++ b/web-app/src/containers/Tutorial/components/ContentMenu.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import * as T from 'typings' import * as TT from 'typings/tutorial' import { Menu } from '@alifd/next' -import Icon from '../../components/Icon' +import Icon from '../../../components/Icon' interface Props { tutorial: TT.Tutorial diff --git a/web-app/src/containers/Tutorial/components/Hints.tsx b/web-app/src/containers/Tutorial/components/Hints.tsx index 7e45fe35..3d66bc0e 100644 --- a/web-app/src/containers/Tutorial/components/Hints.tsx +++ b/web-app/src/containers/Tutorial/components/Hints.tsx @@ -19,22 +19,24 @@ const styles = { interface Props { hints: string[] + hintIndex: number + setHintIndex(value: number): void } const Hints = (props: Props) => { - const [hintIndex, setHintIndex] = React.useState(-1) - const isFinalHint = props.hints.length - 1 === hintIndex + const isFinalHint = props.hints.length - 1 === props.hintIndex const nextHint = () => { - if (!isFinalHint) { - setHintIndex((currentHintIndex) => currentHintIndex + 1) + if (isFinalHint) { + return } + props.setHintIndex(props.hintIndex + 1) } return (
{/* only show revealed hints */} {props.hints.map((h, i) => { - return i <= hintIndex ? ( + return i <= props.hintIndex ? (
{h}
diff --git a/web-app/src/containers/Tutorial/components/Level.tsx b/web-app/src/containers/Tutorial/components/Level.tsx index b28382dd..98a4e1a2 100644 --- a/web-app/src/containers/Tutorial/components/Level.tsx +++ b/web-app/src/containers/Tutorial/components/Level.tsx @@ -8,6 +8,7 @@ import Button from '../../../components/Button' import Markdown from '../../../components/Markdown' import ProcessMessages from '../../../components/ProcessMessages' import NuxTutorial from '../../../components/NewUserExperience/NuxTutorial' +import ContentMenu from './ContentMenu' import Step from './Step' import { DISPLAY_RUN_TEST_BUTTON } from '../../../environment' @@ -87,12 +88,11 @@ const styles = { } interface Props { - menu: any - steps: Array - title: string + tutorial: TT.Tutorial index: number - content: string status: 'COMPLETE' | 'ACTIVE' | 'INCOMPLETE' + progress: T.Progress + position: T.Position processes: T.ProcessEvent[] testStatus: T.TestStatus | null onContinue(): void @@ -102,12 +102,11 @@ interface Props { } const Level = ({ - menu, - steps, - title, - content, + tutorial, index, status, + progress, + position, onContinue, onRunTest, onLoadSolution, @@ -115,14 +114,53 @@ const Level = ({ processes, testStatus, }: Props) => { - // @ts-ignore + const level = tutorial.levels[index] + + const [title, setTitle] = React.useState(level.title) + const [content, setContent] = React.useState(level.content) + + // hold state for hints for the level + const [displayHintsIndex, setDisplayHintsIndex] = React.useState([]) + const setHintsIndex = (index: number, value: number) => { + return setDisplayHintsIndex((displayHintsIndex) => { + const next = [...displayHintsIndex] + next[index] = value + return next + }) + } + React.useEffect(() => { + // set the hints to empty on level starts + setDisplayHintsIndex(steps.map((s) => -1)) + }, [position.levelId]) + + const menu = ( + + ) + + const steps: Array = level.steps.map((step: TT.Step) => { + // label step status for step component + let status: T.ProgressStatus = 'INCOMPLETE' + if (progress.steps[step.id]) { + status = 'COMPLETE' + } else if (step.id === position.stepId) { + status = 'ACTIVE' + } + return { ...step, status } + }) + + // current let currentStep = steps.findIndex((s) => s.status === 'ACTIVE') if (currentStep === -1) { currentStep = steps.length } const pageBottomRef = React.useRef(null) - const scrollToBottom = () => { // @ts-ignore pageBottomRef.current.scrollIntoView({ behavior: 'smooth' }) @@ -144,6 +182,7 @@ const Level = ({ {menu}
+

{title}

{content || ''} @@ -153,27 +192,30 @@ const Level = ({
Tasks
- {steps.map((step: (TT.Step & { status: T.ProgressStatus }) | null, index: number) => { + {steps.map((step: (TT.Step & { status: T.ProgressStatus }) | null, stepIndex: number) => { if (!step) { return null } let subtasks = null - if (step.setup.subtasks && testStatus?.summary) { + if (step?.setup?.subtasks && testStatus?.summary) { subtasks = Object.keys(testStatus.summary).map((testName: string) => ({ name: testName, // @ts-ignore typescript is wrong here pass: testStatus.summary[testName], })) } + const hints = step.hints return ( setHintsIndex(stepIndex, value)} /> ) })} diff --git a/web-app/src/containers/Tutorial/components/Step.tsx b/web-app/src/containers/Tutorial/components/Step.tsx index 5065685c..43782cd4 100644 --- a/web-app/src/containers/Tutorial/components/Step.tsx +++ b/web-app/src/containers/Tutorial/components/Step.tsx @@ -6,11 +6,13 @@ import Hints from './Hints' import Markdown from '../../../components/Markdown' interface Props { - order: number + index: number content: string status: T.ProgressStatus subtasks: { name: string; pass: boolean }[] | null hints?: string[] + hintIndex: number + setHintIndex(value: number): void onLoadSolution(): void } @@ -73,7 +75,9 @@ const Step = (props: Props) => { ) : null} {/* hints */} - {props.hints && props.hints.length ? : null} + {props.hints && props.hints.length ? ( + + ) : null}
diff --git a/web-app/src/containers/Tutorial/index.tsx b/web-app/src/containers/Tutorial/index.tsx index 6d6c31c3..31c5c66a 100644 --- a/web-app/src/containers/Tutorial/index.tsx +++ b/web-app/src/containers/Tutorial/index.tsx @@ -2,7 +2,6 @@ import * as React from 'react' import * as T from 'typings' import * as TT from 'typings/tutorial' import * as selectors from '../../services/selectors' -import ContentMenu from './ContentMenu' import Level from './components/Level' interface PageProps { @@ -14,10 +13,6 @@ const TutorialPage = (props: PageProps) => { const { position, progress, processes, testStatus } = props.context const tutorial = selectors.currentTutorial(props.context) - const levelData: TT.Level = selectors.currentLevel(props.context) - - const [title, setTitle] = React.useState(levelData.title) - const [content, setContent] = React.useState(levelData.content) const onContinue = (): void => { props.send({ @@ -40,33 +35,16 @@ const TutorialPage = (props: PageProps) => { props.send({ type: 'OPEN_LOGS', payload: { channel } }) } - const steps = levelData.steps.map((step: TT.Step) => { - // label step status for step component - let status: T.ProgressStatus = 'INCOMPLETE' - if (progress.steps[step.id]) { - status = 'COMPLETE' - } else if (step.id === position.stepId) { - status = 'ACTIVE' - } - return { ...step, status } - }) + const levelIndex = tutorial.levels.findIndex((l: TT.Level) => l.id === position.levelId) + const levelStatus = progress.levels[position.levelId] ? 'COMPLETE' : 'ACTIVE' return ( - } - index={tutorial.levels.findIndex((l: TT.Level) => l.id === position.levelId)} - steps={steps} - status={progress.levels[position.levelId] ? 'COMPLETE' : 'ACTIVE'} + tutorial={tutorial} + index={levelIndex} + status={levelStatus} + progress={progress} + position={position} onContinue={onContinue} onRunTest={onRunTest} onLoadSolution={onLoadSolution} diff --git a/web-app/src/services/sentry/init.tsx b/web-app/src/services/sentry/init.tsx index 8f71c272..7100a1a6 100644 --- a/web-app/src/services/sentry/init.tsx +++ b/web-app/src/services/sentry/init.tsx @@ -1,5 +1,6 @@ import * as sentry from '@sentry/browser' import { NODE_ENV, SENTRY_DSN } from '../../environment' +import logger from '../logger' try { if (SENTRY_DSN && NODE_ENV === 'production') { @@ -9,5 +10,5 @@ try { }) } } catch (error) { - console.log(`Error in Sentry init: ${error.message}`) + logger(`Error in Sentry init: ${error.message}`) } diff --git a/web-app/src/services/state/machine.ts b/web-app/src/services/state/machine.ts index 26b4229f..4a7d1059 100644 --- a/web-app/src/services/state/machine.ts +++ b/web-app/src/services/state/machine.ts @@ -142,21 +142,6 @@ export const createMachine = (options: any) => { id: 'tutorial', initial: 'Level', states: { - LoadNext: { - id: 'tutorial-load-next', - onEntry: ['loadNext'], - on: { - NEXT_STEP: { - target: 'Level', - actions: ['updatePosition'], - }, - NEXT_LEVEL: { - target: 'Level', - actions: ['updatePosition'], - }, - COMPLETED: '#completed-tutorial', - }, - }, Level: { initial: 'Load', states: { @@ -228,11 +213,26 @@ export const createMachine = (options: any) => { onExit: ['syncLevelProgress'], on: { NEXT_LEVEL: { - target: '#tutorial-load-next', + target: 'LoadNext', actions: ['testClear', 'updatePosition'], }, }, }, + LoadNext: { + id: 'tutorial-load-next', + onEntry: ['loadNext'], + on: { + NEXT_STEP: { + target: 'Load', + actions: ['updatePosition'], + }, + NEXT_LEVEL: { + target: 'Load', + actions: ['updatePosition'], + }, + COMPLETED: '#completed-tutorial', + }, + }, }, }, Completed: { diff --git a/web-app/src/services/state/routeString.test.ts b/web-app/src/services/state/routeString.test.ts new file mode 100644 index 00000000..e733d41f --- /dev/null +++ b/web-app/src/services/state/routeString.test.ts @@ -0,0 +1,16 @@ +import { createRouteString } from './useStateMachine' + +describe('route string', () => { + it('should take a single key route', () => { + const result = createRouteString('a') + expect(result).toBe('a') + }) + it('should take a 1 level nested key route', () => { + const result = createRouteString({ a: 'b' }) + expect(result).toBe('a.b') + }) + it('should take a 3 level nested key route', () => { + const result = createRouteString({ a: { b: { c: 'd' } } }) + expect(result).toBe('a.b.c.d') + }) +}) diff --git a/web-app/src/services/state/useStateMachine.tsx b/web-app/src/services/state/useStateMachine.tsx new file mode 100644 index 00000000..5b9ceda1 --- /dev/null +++ b/web-app/src/services/state/useStateMachine.tsx @@ -0,0 +1,83 @@ +import * as React from 'react' +import * as T from 'typings' +import { createMachine } from './machine' +import { useMachine } from '../xstate-react' +import logger from '../logger' + +interface Output { + context: T.MachineContext + route: string + send: (action: any) => void +} + +declare let acquireVsCodeApi: any + +export const createRouteString = (route: object | string): string => { + if (typeof route === 'string') { + return route + } + const paths: string[] = [] + let current: object | string | undefined = route + while (current) { + // current is final string value + if (typeof current === 'string') { + paths.push(current) + break + } + + // current is object + const next: string = Object.keys(current)[0] + paths.push(next) + // @ts-ignore + current = current[next] + } + + return paths.join('.') +} + +const editor = acquireVsCodeApi() +const editorSend = (action: T.Action) => { + logger(`TO EXT: "${action.type}"`) + return editor.postMessage(action) +} + +// router finds first state match of +const useStateMachine = (): Output => { + const [state, send] = useMachine(createMachine({ editorSend })) + + const sendWithLog = (action: T.Action): void => { + logger(`SEND: ${action.type}`, action) + send(action) + } + + // event bus listener + React.useEffect(() => { + const listener = 'message' + // propograte channel event to state machine + const handler = (event: any) => { + // NOTE: must call event.data, cannot destructure. VSCode acts odd + const action = event.data + // ignore browser events from other extensions + if (action.source) { + return + } + sendWithLog(action) + } + window.addEventListener(listener, handler) + return () => { + window.removeEventListener(listener, handler) + } + }, []) + + // convert route to a string to avoid unnecessary React re-renders on deeply nested objects + const route = createRouteString(state.value) + logger(`STATE: "${route}"`) + + return { + context: state.context, + route, + send: sendWithLog, + } +} + +export default useStateMachine