{/* 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