diff --git a/src/channel/index.ts b/src/channel/index.ts index 4a88cb23..03529759 100644 --- a/src/channel/index.ts +++ b/src/channel/index.ts @@ -70,7 +70,6 @@ class Channel implements Channel { this.send({ type: 'START_NEW_TUTORIAL' }) return } - console.log('send LOAD_STORED_TUTORIAL') // communicate to client the tutorial & stepProgress state this.send({ type: 'LOAD_STORED_TUTORIAL', payload: { tutorial, progress, position } }) diff --git a/src/channel/state/Position.ts b/src/channel/state/Position.ts index dcf049f0..c8b67a54 100644 --- a/src/channel/state/Position.ts +++ b/src/channel/state/Position.ts @@ -3,7 +3,7 @@ import * as G from 'typings/graphql' const defaultValue: CR.Position = { levelId: '', - stepId: '', + stepId: null, } // position @@ -42,18 +42,25 @@ class Position { // get step const currentLevel: G.Level = levels[lastLevelIndex] - const { steps } = currentLevel - const lastStepIndex: number | undefined = steps.findIndex((s: G.Step) => !progress.steps[s.id]) - if (lastStepIndex >= steps.length) { - throw new Error('Error setting progress step') + let currentStepId: string | null + if (!currentLevel.steps.length) { + // no steps available for level + currentStepId = null + } else { + // find current step id + const { steps } = currentLevel + const lastStepIndex: number | undefined = steps.findIndex((s: G.Step) => !progress.steps[s.id]) + if (lastStepIndex >= steps.length) { + throw new Error('Error setting progress step') + } + // handle position when last step is complete but "continue" not yet selected + const adjustedLastStepIndex = lastStepIndex === -1 ? steps.length - 1 : lastStepIndex + currentStepId = steps[adjustedLastStepIndex].id } - // handle position when last step is complete but "continue" not yet selected - const adjustedLastStepIndex = lastStepIndex === -1 ? steps.length - 1 : lastStepIndex - const currentStep: G.Step = steps[adjustedLastStepIndex] this.value = { levelId: currentLevel.id, - stepId: currentStep.id, + stepId: currentStepId, } return this.value diff --git a/typings/index.d.ts b/typings/index.d.ts index 94e3e9af..1e65f7a9 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -19,7 +19,7 @@ export interface StepProgress { // current tutorial position export interface Position { levelId: string - stepId: string + stepId: string | null complete?: boolean } diff --git a/web-app/src/components/Router/index.tsx b/web-app/src/components/Router/index.tsx index d52464fb..32e7b4e8 100644 --- a/web-app/src/components/Router/index.tsx +++ b/web-app/src/components/Router/index.tsx @@ -4,6 +4,7 @@ import { createMachine } from '../../services/state/machine' import { useMachine } from '../../services/xstate-react' import Route from './Route' import onError from '../../services/sentry/onError' +import { LOG_STATE } from '../../environment' interface Output { context: T.MachineContext @@ -20,6 +21,10 @@ const editor = acquireVsCodeApi() const useRouter = (): Output => { const [state, send] = useMachine(createMachine({ editorSend: editor.postMessage })) + if (LOG_STATE) { + console.log(JSON.stringify(state.value)) + } + // event bus listener React.useEffect(() => { const listener = 'message' diff --git a/web-app/src/containers/Tutorial/LevelPage/Level.tsx b/web-app/src/containers/Tutorial/LevelPage/Level.tsx index a67f5b08..84adf9d4 100644 --- a/web-app/src/containers/Tutorial/LevelPage/Level.tsx +++ b/web-app/src/containers/Tutorial/LevelPage/Level.tsx @@ -12,12 +12,14 @@ const styles = { page: { backgroundColor: 'white', position: 'relative' as 'relative', + height: 'auto', + width: '100%', + }, + content: { display: 'flex' as 'flex', flexDirection: 'column' as 'column', padding: 0, paddingBottom: '5rem', - height: 'auto', - width: '100%', }, header: { height: '2rem', @@ -26,13 +28,11 @@ const styles = { lineHeight: '1rem', padding: '10px 1rem', }, - content: { + text: { padding: '0rem 1rem', paddingBottom: '1rem', }, - tasks: { - paddingBottom: '5rem', - }, + tasks: {}, steps: { padding: '1rem 1rem', }, @@ -85,8 +85,10 @@ interface Props { } const Level = ({ level, onContinue, onLoadSolution, processes, testStatus }: Props) => { - if (!level.steps) { - throw new Error('No Stage steps found') + // @ts-ignore + let currentStep = level.steps.findIndex(s => s.status === 'ACTIVE') + if (currentStep === -1) { + currentStep = level.steps.length } const pageBottomRef = React.useRef(null) @@ -95,70 +97,70 @@ const Level = ({ level, onContinue, onLoadSolution, processes, testStatus }: Pro // @ts-ignore pageBottomRef.current.scrollIntoView({ behavior: 'smooth' }) } - // @ts-ignore - let currentStep = level.steps.findIndex(s => s.status === 'ACTIVE') - if (currentStep === -1) { - currentStep = level.steps.length - } React.useEffect(scrollToBottom, [currentStep]) return (
-
- Learn -
-

{level.title}

- {level.content || ''} -
- -
-
Tasks
-
- {level.steps.map((step: (G.Step & { status: T.ProgressStatus }) | null, index: number) => { - if (!step) { - return null - } - return ( - - ) - })} +
+ Learn +
+
+

{level.title}

+ {level.content || ''}
+ + {level.steps.length ? ( +
+
Tasks
+
+ {level.steps.map((step: (G.Step & { status: T.ProgressStatus }) | null, index: number) => { + if (!step) { + return null + } + return ( + + ) + })} +
+
+ ) : null} +
-
- {(testStatus || processes.length > 0) && ( -
- -
- )} + {(testStatus || processes.length > 0) && ( +
+ +
+ )} -
- -
+
+ +
-
- - {typeof level.index === 'number' ? `${level.index + 1}. ` : ''} - {level.title} - - - {level.status === 'COMPLETE' ? ( - - ) : ( - - {currentStep} of {level.steps.length} tasks - - )} - +
+ + {typeof level.index === 'number' ? `${level.index + 1}. ` : ''} + {level.title} + + + {level.status === 'COMPLETE' || !level.steps.length ? ( + + ) : ( + + {currentStep} of {level.steps.length} tasks + + )} + +
) diff --git a/web-app/src/environment.ts b/web-app/src/environment.ts index a509763a..d77dc04b 100644 --- a/web-app/src/environment.ts +++ b/web-app/src/environment.ts @@ -12,3 +12,4 @@ export const DEBUG: boolean = (process.env.REACT_APP_DEBUG || '').toLowerCase() export const VERSION: string = process.env.VERSION || 'unknown' export const NODE_ENV: string = process.env.NODE_ENV || 'production' export const AUTH_TOKEN: string | null = process.env.AUTH_TOKEN || null +export const LOG_STATE: boolean = (process.env.LOG_STATE || '').toLowerCase() === 'true' diff --git a/web-app/src/services/selectors/position.ts b/web-app/src/services/selectors/position.ts index e0c5fd62..7beafeda 100644 --- a/web-app/src/services/selectors/position.ts +++ b/web-app/src/services/selectors/position.ts @@ -5,14 +5,14 @@ import * as tutorial from './tutorial' export const defaultPosition = () => ({ levelId: '', - stepId: '', + stepId: null, }) export const initialPosition = createSelector(tutorial.currentVersion, (version: G.TutorialVersion) => { const level = version.data.levels[0] const position: CR.Position = { levelId: level.id, - stepId: level.steps[0].id, + stepId: level.steps.length ? level.steps[0].id : null, } return position }) diff --git a/web-app/src/services/selectors/tutorial.ts b/web-app/src/services/selectors/tutorial.ts index 4cd60354..31912d67 100644 --- a/web-app/src/services/selectors/tutorial.ts +++ b/web-app/src/services/selectors/tutorial.ts @@ -41,17 +41,9 @@ export const currentLevel = (context: MachineContext): G.Level => }, )(context) -export const currentStep = (context: MachineContext): G.Step => - createSelector( - currentLevel, - (level: G.Level): G.Step => { - const steps: G.Step[] = level.steps - const step: G.Step | undefined = steps.find((s: G.Step) => s.id === context.position.stepId) - if (!step) { - const error = new Error(`No Step found for Level ${level.id}. Expected step ${context.position.stepId}`) - onError(error) - throw error - } - return step - }, - )(context) +export const currentStep = (context: MachineContext): G.Step | null => + createSelector(currentLevel, (level: G.Level): G.Step | null => { + const steps: G.Step[] = level.steps + const step: G.Step | null = steps.find((s: G.Step) => s.id === context.position.stepId) || null + return step + })(context) diff --git a/web-app/src/services/state/actions/context.ts b/web-app/src/services/state/actions/context.ts index c7c3d232..5729452e 100644 --- a/web-app/src/services/state/actions/context.ts +++ b/web-app/src/services/state/actions/context.ts @@ -17,7 +17,6 @@ const contextActions: ActionFunctionMap = { // @ts-ignore storeContinuedTutorial: assign({ tutorial: (context: T.MachineContext, event: T.MachineEvent) => { - console.log('storeContinuedTutorial') return event.payload.tutorial }, progress: (context: T.MachineContext, event: T.MachineEvent) => { @@ -132,25 +131,27 @@ const contextActions: ActionFunctionMap = { const steps: G.Step[] = level.steps - const stepIndex = steps.findIndex((s: G.Step) => s.id === position.stepId) - const stepComplete = progress.steps[position.stepId] - const finalStep = stepIndex > -1 && stepIndex === steps.length - 1 - const hasNextStep = !finalStep && !stepComplete - - // NEXT STEP - if (hasNextStep) { - const nextPosition = { ...position, stepId: steps[stepIndex + 1].id } - return { type: 'NEXT_STEP', payload: { position: nextPosition } } - } + if (steps.length && position.stepId) { + const stepIndex = steps.findIndex((s: G.Step) => s.id === position.stepId) + const stepComplete = progress.steps[position.stepId] + const finalStep = stepIndex > -1 && stepIndex === steps.length - 1 + const hasNextStep = !finalStep && !stepComplete - // has next level? + // NEXT STEP + if (hasNextStep) { + const nextPosition = { ...position, stepId: steps[stepIndex + 1].id } + return { type: 'NEXT_STEP', payload: { position: nextPosition } } + } - if (!context.tutorial) { - const error = new Error('Tutorial not found') - onError(error) - throw error + // has next level? + if (!context.tutorial) { + const error = new Error('Tutorial not found') + onError(error) + throw error + } } + // @ts-ignore const levels = context.tutorial.version.data.levels || [] const levelIndex = levels.findIndex((l: G.Level) => l.id === position.levelId) const finalLevel = levelIndex > -1 && levelIndex === levels.length - 1 @@ -177,26 +178,27 @@ const contextActions: ActionFunctionMap = { const level: G.Level = selectors.currentLevel(context) const { steps } = level - // TODO verify not -1 - const stepIndex = steps.findIndex((s: G.Step) => s.id === position.stepId) - const finalStep = stepIndex === steps.length - 1 - const stepComplete = progress.steps[position.stepId] - // not final step, or final step but not complete - const hasNextStep = !finalStep || !stepComplete - - if (hasNextStep) { - const nextStep = steps[stepIndex + 1] - return { - type: 'LOAD_NEXT_STEP', - payload: { - step: nextStep, - }, - } - } else { - return { - type: 'LEVEL_COMPLETE', + + if (steps.length && position.stepId) { + const stepIndex = steps.findIndex((s: G.Step) => s.id === position.stepId) + const finalStep = stepIndex === steps.length - 1 + const stepComplete = progress.steps[position.stepId] + // not final step, or final step but not complete + const hasNextStep = !finalStep || !stepComplete + + if (hasNextStep) { + const nextStep = steps[stepIndex + 1] + return { + type: 'LOAD_NEXT_STEP', + payload: { + step: nextStep, + }, + } } } + return { + type: 'LEVEL_COMPLETE', + } }, ), // @ts-ignore @@ -223,6 +225,13 @@ const contextActions: ActionFunctionMap = { return message }, }), + // @ts-ignore + checkEmptySteps: send((context: T.MachineContext) => { + // no step id indicates no steps to complete + return { + type: context.position.stepId === null ? 'START_COMPLETED_LEVEL' : 'START_LEVEL', + } + }), } export default contextActions diff --git a/web-app/src/services/state/actions/editor.ts b/web-app/src/services/state/actions/editor.ts index 3a1abd25..8efc450f 100644 --- a/web-app/src/services/state/actions/editor.ts +++ b/web-app/src/services/state/actions/editor.ts @@ -44,8 +44,8 @@ export default (editorSend: any) => ({ } }, loadStep(context: CR.MachineContext): void { - const step: G.Step = selectors.currentStep(context) - if (step.setup) { + const step: G.Step | null = selectors.currentStep(context) + if (step && step.setup) { // load step actions editorSend({ type: 'SETUP_ACTIONS', @@ -57,15 +57,17 @@ export default (editorSend: any) => ({ } }, editorLoadSolution(context: CR.MachineContext): void { - const step: G.Step = selectors.currentStep(context) + const step: G.Step | null = selectors.currentStep(context) // tell editor to load solution commit - editorSend({ - type: 'SOLUTION_ACTIONS', - payload: { - stepId: step.id, - ...step.solution, - }, - }) + if (step && step.solution) { + editorSend({ + type: 'SOLUTION_ACTIONS', + payload: { + stepId: step.id, + ...step.solution, + }, + }) + } }, clearStorage(): void { editorSend({ type: 'TUTORIAL_CLEAR' }) diff --git a/web-app/src/services/state/machine.ts b/web-app/src/services/state/machine.ts index 22ec14e4..4cbca2c5 100644 --- a/web-app/src/services/state/machine.ts +++ b/web-app/src/services/state/machine.ts @@ -21,7 +21,7 @@ export const createMachine = (options: any) => { error: null, env: { machineId: '', sessionId: '', token: '' }, tutorial: null, - position: { levelId: '', stepId: '' }, + position: { levelId: '', stepId: null }, progress: { levels: {}, steps: {}, @@ -188,9 +188,10 @@ export const createMachine = (options: any) => { initial: 'Load', states: { Load: { - onEntry: ['loadLevel', 'loadStep'], - after: { - 0: 'Normal', + onEntry: ['loadLevel', 'loadStep', 'checkEmptySteps'], + on: { + START_LEVEL: 'Normal', + START_COMPLETED_LEVEL: 'LevelComplete', }, }, Normal: { diff --git a/web-app/stories/Level.stories.tsx b/web-app/stories/Level.stories.tsx index b5b1d5b1..49fcba1a 100644 --- a/web-app/stories/Level.stories.tsx +++ b/web-app/stories/Level.stories.tsx @@ -289,3 +289,24 @@ storiesOf('Level', module) /> ) }) + .add('No steps', () => { + const level = { + id: 'L1', + index: 0, + title: 'A Title', + description: 'A summary of the level', + content: 'Some content here in markdown', + setup: null, + status: 'ACTIVE', + steps: [], + } + return ( + + ) + })