diff --git a/.gitignore b/.gitignore index 7759308e14..128b53e0ae 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # production /build src/styles/*.css +/.env # deployment terraform* diff --git a/src/actions/actionTypes.ts b/src/actions/actionTypes.ts index f565903214..1afa1cc6b1 100644 --- a/src/actions/actionTypes.ts +++ b/src/actions/actionTypes.ts @@ -8,10 +8,19 @@ export interface IAction extends ReduxAction { export const SAVE_CANVAS = 'SAVE_CANVAS' /** Playground */ +export const CHANGE_QUERY_STRING = 'CHANGE_QUERY_STRING' +export const GENERATE_LZ_STRING = 'GENERATE_LZ_STRING' + +/** Interpreter */ +export const HANDLE_CONSOLE_LOG = 'HANDLE_CONSOLE_LOG' +export const EVAL_INTERPRETER_SUCCESS = 'EVAL_INTERPRETER_SUCCESS' +export const EVAL_INTERPRETER_ERROR = 'EVAL_INTERPRETER_ERROR' +export const INTERRUPT_EXECUTION = 'INTERRUPT_EXECUTION' + +/** Workspace */ export const CHANGE_ACTIVE_TAB = 'CHANGE_ACTIVE_TAB' export const CHANGE_CHAPTER = 'CHANGE_CHAPTER' export const CHANGE_EDITOR_WIDTH = 'CHANGE_EDITOR_WIDTH' -export const CHANGE_QUERY_STRING = 'CHANGE_QUERY_STRING' export const CHANGE_SIDE_CONTENT_HEIGHT = 'CHANGE_SIDE_CONTENT_HEIGHT' export const CHAPTER_SELECT = 'CHAPTER_SELECT' export const CLEAR_REPL_INPUT = 'CLEAR_REPL_INPUT' @@ -19,16 +28,11 @@ export const CLEAR_REPL_OUTPUT = 'CLEAR_REPL_OUTPUT' export const CLEAR_CONTEXT = 'CLEAR_CONTEXT' export const EVAL_EDITOR = 'EVAL_EDITOR' export const EVAL_REPL = 'EVAL_REPL' -export const GENERATE_LZ_STRING = 'GENERATE_LZ_STRING' export const UPDATE_EDITOR_VALUE = 'UPDATE_EDITOR_VALUE' export const UPDATE_REPL_VALUE = 'UPDATE_REPL_VALUE' export const SEND_REPL_INPUT_TO_OUTPUT = 'SEND_REPL_INPUT_TO_OUTPUT' - -/** Interpreter */ -export const HANDLE_CONSOLE_LOG = 'HANDLE_CONSOLE_LOG' -export const EVAL_INTERPRETER_SUCCESS = 'EVAL_INTERPRETER_SUCCESS' -export const EVAL_INTERPRETER_ERROR = 'EVAL_INTERPRETER_ERROR' -export const INTERRUPT_EXECUTION = 'INTERRUPT_EXECUTION' +export const RESET_ASSESSMENT_WORKSPACE = 'RESET_ASSESSMENT_WORKSPACE' +export const UPDATE_CURRENT_ASSESSMENT_ID = 'UPDATE_CURRENT_ASSESSMENT_ID' /** Session */ export const CHANGE_TOKEN = 'CHANGE_TOKEN' diff --git a/src/actions/index.ts b/src/actions/index.ts index c650e6d60d..24afc969dc 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -2,3 +2,4 @@ export * from './game' export * from './interpreter' export * from './playground' export * from './session' +export * from './workspaces' diff --git a/src/actions/interpreter.ts b/src/actions/interpreter.ts index 3e75d1d41d..a4e3857198 100644 --- a/src/actions/interpreter.ts +++ b/src/actions/interpreter.ts @@ -1,21 +1,31 @@ import { SourceError, Value } from '../slang/types' import * as actionTypes from './actionTypes' +import { WorkspaceLocation } from './workspaces' -export const handleConsoleLog = (log: string) => ({ +// TODO fix this immediately after location +// is implemented completely +export const handleConsoleLog = ( + logString: string, + workspaceLocation: WorkspaceLocation = 'assessment' +) => ({ type: actionTypes.HANDLE_CONSOLE_LOG, - payload: log + payload: { logString, workspaceLocation } }) -export const evalInterpreterSuccess = (value: Value) => ({ +export const evalInterpreterSuccess = (value: Value, workspaceLocation: WorkspaceLocation) => ({ type: actionTypes.EVAL_INTERPRETER_SUCCESS, - payload: { type: 'result', value } + payload: { type: 'result', value, workspaceLocation } }) -export const evalInterpreterError = (errors: SourceError[]) => ({ +export const evalInterpreterError = ( + errors: SourceError[], + workspaceLocation: WorkspaceLocation +) => ({ type: actionTypes.EVAL_INTERPRETER_ERROR, - payload: { type: 'errors', errors } + payload: { type: 'errors', errors, workspaceLocation } }) -export const handleInterruptExecution = () => ({ - type: actionTypes.INTERRUPT_EXECUTION +export const handleInterruptExecution = (workspaceLocation: WorkspaceLocation) => ({ + type: actionTypes.INTERRUPT_EXECUTION, + payload: { workspaceLocation } }) diff --git a/src/actions/playground.ts b/src/actions/playground.ts index fbbbb12ede..2fe0219da5 100644 --- a/src/actions/playground.ts +++ b/src/actions/playground.ts @@ -1,74 +1,5 @@ -import { ActionCreator } from 'redux' import * as actionTypes from './actionTypes' -export const changeActiveTab: ActionCreator = (activeTab: number) => ({ - type: actionTypes.CHANGE_ACTIVE_TAB, - payload: activeTab -}) - -export const changeChapter: ActionCreator = (newChapter: number) => ({ - type: actionTypes.CHANGE_CHAPTER, - payload: newChapter -}) - -export const changeEditorWidth: ActionCreator = (widthChange: string) => ({ - type: actionTypes.CHANGE_EDITOR_WIDTH, - payload: widthChange -}) - -export const changeQueryString: ActionCreator = (queryString: string) => ({ - type: actionTypes.CHANGE_QUERY_STRING, - payload: queryString -}) - -export const changeSideContentHeight: ActionCreator = (height: number) => ({ - type: actionTypes.CHANGE_SIDE_CONTENT_HEIGHT, - payload: height -}) - -export const chapterSelect: ActionCreator = (chapter, changeEvent) => ({ - type: actionTypes.CHAPTER_SELECT, - payload: chapter.chapter -}) - -export const clearContext = () => ({ - type: actionTypes.CLEAR_CONTEXT -}) - -export const clearReplInput = () => ({ - type: actionTypes.CLEAR_REPL_INPUT -}) - -export const clearReplOutput = () => ({ - type: actionTypes.CLEAR_REPL_OUTPUT -}) - -export const evalEditor = () => ({ - type: actionTypes.EVAL_EDITOR -}) - -export const evalRepl = () => ({ - type: actionTypes.EVAL_REPL -}) - export const generateLzString = () => ({ type: actionTypes.GENERATE_LZ_STRING }) - -export const updateEditorValue: ActionCreator = (newEditorValue: string) => ({ - type: actionTypes.UPDATE_EDITOR_VALUE, - payload: newEditorValue -}) - -export const updateReplValue: ActionCreator = (newReplValue: string) => ({ - type: actionTypes.UPDATE_REPL_VALUE, - payload: newReplValue -}) - -export const sendReplInputToOutput: ActionCreator = (newOutput: string) => ({ - type: actionTypes.SEND_REPL_INPUT_TO_OUTPUT, - payload: { - type: 'code', - value: newOutput - } -}) diff --git a/src/actions/workspaces.ts b/src/actions/workspaces.ts new file mode 100644 index 0000000000..3c15a9ded5 --- /dev/null +++ b/src/actions/workspaces.ts @@ -0,0 +1,135 @@ +import { ActionCreator } from 'redux' +import * as actionTypes from './actionTypes' + +/** + * Used to differenciate between the sources of actions, as + * two workspaces can work at the same time. To generalise this + * or add more instances of `Workspace`s, one can add a string, + * and call the actions with the respective string (taken + * from the below enum). + * + * Note that the names must correspond with the name of the + * object in IWorkspaceManagerState. + */ +export enum WorkspaceLocations { + assessment, + playground +} +export type WorkspaceLocation = keyof typeof WorkspaceLocations + +export const changeActiveTab: ActionCreator = ( + activeTab: number, + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.CHANGE_ACTIVE_TAB, + payload: { activeTab, workspaceLocation } +}) + +export const changeChapter: ActionCreator = ( + newChapter: number, + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.CHANGE_CHAPTER, + payload: { newChapter, workspaceLocation } +}) + +export const changeEditorWidth: ActionCreator = ( + widthChange: string, + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.CHANGE_EDITOR_WIDTH, + payload: { widthChange, workspaceLocation } +}) + +export const changeQueryString: ActionCreator = ( + queryString: string, + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.CHANGE_QUERY_STRING, + payload: { queryString, workspaceLocation } +}) + +export const changeSideContentHeight: ActionCreator = ( + height: number, + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.CHANGE_SIDE_CONTENT_HEIGHT, + payload: { height, workspaceLocation } +}) + +export const chapterSelect: ActionCreator = ( + chapter, + changeEvent, + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.CHAPTER_SELECT, + payload: { + chapter: chapter.chapter, + workspaceLocation + } +}) + +export const clearContext = (workspaceLocation: WorkspaceLocation) => ({ + type: actionTypes.CLEAR_CONTEXT, + payload: { workspaceLocation } +}) + +export const clearReplInput = (workspaceLocation: WorkspaceLocation) => ({ + type: actionTypes.CLEAR_REPL_INPUT, + payload: { workspaceLocation } +}) + +export const clearReplOutput = (workspaceLocation: WorkspaceLocation) => ({ + type: actionTypes.CLEAR_REPL_OUTPUT, + payload: { workspaceLocation } +}) + +export const evalEditor = (workspaceLocation: WorkspaceLocation) => ({ + type: actionTypes.EVAL_EDITOR, + payload: { workspaceLocation } +}) + +export const evalRepl = (workspaceLocation: WorkspaceLocation) => ({ + type: actionTypes.EVAL_REPL, + payload: { workspaceLocation } +}) + +export const updateEditorValue: ActionCreator = ( + newEditorValue: string, + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.UPDATE_EDITOR_VALUE, + payload: { newEditorValue, workspaceLocation } +}) + +export const updateReplValue: ActionCreator = ( + newReplValue: string, + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.UPDATE_REPL_VALUE, + payload: { newReplValue, workspaceLocation } +}) + +export const sendReplInputToOutput: ActionCreator = ( + newOutput: string, + workspaceLocation: WorkspaceLocation +) => ({ + type: actionTypes.SEND_REPL_INPUT_TO_OUTPUT, + payload: { + type: 'code', + workspaceLocation, + value: newOutput + } +}) + +export const resetAssessmentWorkspace = () => ({ + type: actionTypes.RESET_ASSESSMENT_WORKSPACE +}) + +export const updateCurrentAssessmentId = (assessmentId: number, questionId: number) => ({ + type: actionTypes.UPDATE_CURRENT_ASSESSMENT_ID, + payload: { + assessmentId, + questionId + } +}) diff --git a/src/components/Playground.tsx b/src/components/Playground.tsx index ec074dd270..282564ec20 100644 --- a/src/components/Playground.tsx +++ b/src/components/Playground.tsx @@ -6,12 +6,33 @@ import * as React from 'react' import { HotKeys } from 'react-hotkeys' import { RouteComponentProps } from 'react-router' -import WorkspaceContainer from '../containers/workspace' -import { sourceChapters } from '../reducers/states' +import { InterpreterOutput, sourceChapters } from '../reducers/states' +import Workspace, { WorkspaceProps } from './workspace' import { SideContentTab } from './workspace/side-content' -export interface IPlaygroundProps extends RouteComponentProps<{}> { - editorValue: string +export interface IPlaygroundProps extends IDispatchProps, IStateProps, RouteComponentProps<{}> {} + +export interface IStateProps { + activeTab: number + editorValue?: string + editorWidth: string + isRunning: boolean + output: InterpreterOutput[] + replValue: string + sideContentHeight?: number +} + +export interface IDispatchProps { + handleChangeActiveTab: (activeTab: number) => void + handleChapterSelect: (chapter: any, changeEvent: any) => void + handleEditorEval: () => void + handleEditorValueChange: (val: string) => void + handleEditorWidthChange: (widthChange: number) => void + handleInterruptEval: () => void + handleReplEval: () => void + handleReplOutputClear: () => void + handleReplValueChange: (newValue: string) => void + handleSideContentHeightChange: (heightChange: number) => void } type PlaygroundState = { @@ -30,21 +51,57 @@ class Playground extends React.Component { } public render() { + const workspaceProps: WorkspaceProps = { + controlBarProps: { + handleChapterSelect: this.props.handleChapterSelect, + handleEditorEval: this.props.handleEditorEval, + handleInterruptEval: this.props.handleInterruptEval, + handleReplEval: this.props.handleReplEval, + handleReplOutputClear: this.props.handleReplOutputClear, + hasNextButton: false, + hasPreviousButton: false, + hasSubmitButton: false, + isRunning: this.props.isRunning, + sourceChapter: parseLibrary(this.props) || 2 + }, + editorProps: { + editorValue: this.chooseEditorValue(this.props), + handleEditorEval: this.props.handleEditorEval, + handleEditorValueChange: this.props.handleEditorValueChange + }, + editorWidth: this.props.editorWidth, + handleEditorWidthChange: this.props.handleEditorWidthChange, + handleSideContentHeightChange: this.props.handleSideContentHeightChange, + replProps: { + output: this.props.output, + replValue: this.props.replValue, + handleReplEval: this.props.handleReplEval, + handleReplValueChange: this.props.handleReplValueChange + }, + sideContentHeight: this.props.sideContentHeight, + sideContentProps: { + activeTab: this.props.activeTab, + handleChangeActiveTab: this.props.handleChangeActiveTab, + tabs: [playgroundIntroduction] + } + } return ( - + ) } + private chooseEditorValue(props: IPlaygroundProps): string { + return parsePrgrm(this.props) || this.props.editorValue === undefined + ? defaultPlaygroundText + : this.props.editorValue + } + private toggleIsGreen() { this.setState({ isGreen: !this.state.isGreen }) } @@ -65,6 +122,7 @@ const parseLibrary = (props: IPlaygroundProps) => { const SICP_SITE = 'http://www.comp.nus.edu.sg/~henz/sicp_js/' const CHAP = '\xa7' +const defaultPlaygroundText = '// Type your code here!' const playgroundIntroduction: SideContentTab = { label: 'Introduction', icon: IconNames.COMPASS, diff --git a/src/components/__tests__/Playground.tsx b/src/components/__tests__/Playground.tsx index 9231d0ea4e..382b3415f8 100644 --- a/src/components/__tests__/Playground.tsx +++ b/src/components/__tests__/Playground.tsx @@ -2,24 +2,48 @@ import { shallow } from 'enzyme' import * as React from 'react' import { mockRouterProps } from '../../mocks/components' -import Playground from '../Playground' +import Playground, { IPlaygroundProps } from '../Playground' + +const baseProps = { + editorValue: '', + isRunning: false, + activeTab: 0, + editorWidth: '50%', + sideContentHeight: 40, + output: [], + replValue: '', + handleEditorValueChange: () => {}, + handleChapterSelect: (i: any, e: any) => {}, + handleChangeActiveTab: (n: number) => {}, + handleEditorEval: () => {}, + handleReplEval: () => {}, + handleReplOutputClear: () => {}, + handleInterruptEval: () => {}, + handleEditorWidthChange: (widthChange: number) => {}, + handleSideContentHeightChange: (h: number) => {}, + handleReplValueChange: (code: string) => {} +} + +const testValueProps: IPlaygroundProps = { + ...baseProps, + ...mockRouterProps('/academy', {}), + editorValue: 'Test value' +} + +const playgroundLinkProps: IPlaygroundProps = { + ...baseProps, + ...mockRouterProps('/playground#lib=2&prgrm=CYSwzgDgNghgngCgOQAsCmUoHsCESCUA3EA', {}), + editorValue: 'This should not show up' +} test('Playground renders correctly', () => { - const props = { - ...mockRouterProps('/academy', {}), - editorValue: 'Test value' - } - const app = + const app = const tree = shallow(app) expect(tree.debug()).toMatchSnapshot() }) test('Playground with link renders correctly', () => { - const props = { - ...mockRouterProps('/playground#lib=2&prgrm=CYSwzgDgNghgngCgOQAsCmUoHsCESCUA3EA', {}), - editorValue: 'This should not show up' - } - const app = + const app = const tree = shallow(app) expect(tree.debug()).toMatchSnapshot() }) diff --git a/src/components/__tests__/__snapshots__/Playground.tsx.snap b/src/components/__tests__/__snapshots__/Playground.tsx.snap index 1c1d5a2217..3f1b0bc8cf 100644 --- a/src/components/__tests__/__snapshots__/Playground.tsx.snap +++ b/src/components/__tests__/__snapshots__/Playground.tsx.snap @@ -2,12 +2,12 @@ exports[`Playground renders correctly 1`] = ` " - + " `; exports[`Playground with link renders correctly 1`] = ` " - + " `; diff --git a/src/components/academy/NavigationBar.tsx b/src/components/academy/NavigationBar.tsx index 010f86846c..814563269b 100644 --- a/src/components/academy/NavigationBar.tsx +++ b/src/components/academy/NavigationBar.tsx @@ -10,7 +10,7 @@ const NavigationBar: React.SFC<{}> = () => ( @@ -19,7 +19,7 @@ const NavigationBar: React.SFC<{}> = () => ( @@ -28,7 +28,7 @@ const NavigationBar: React.SFC<{}> = () => ( @@ -37,7 +37,7 @@ const NavigationBar: React.SFC<{}> = () => ( diff --git a/src/components/academy/index.tsx b/src/components/academy/index.tsx index f92e6a8c9d..53432ad0e8 100644 --- a/src/components/academy/index.tsx +++ b/src/components/academy/index.tsx @@ -40,26 +40,26 @@ export const Academy: React.SFC = props => ( {checkLoggedIn(props)} diff --git a/src/components/assessment/AssessmentListing.tsx b/src/components/assessment/AssessmentListing.tsx index c775895250..0200853cb4 100644 --- a/src/components/assessment/AssessmentListing.tsx +++ b/src/components/assessment/AssessmentListing.tsx @@ -16,17 +16,45 @@ export interface IAssessmentParams { questionId?: string } -export interface IAssessmentListingProps extends RouteComponentProps { - assessmentOverviews?: IAssessmentOverview[] - assessmentCategory: AssessmentCategory +export interface IAssessmentListingProps + extends IDispatchProps, + IOwnProps, + RouteComponentProps, + IStateProps {} + +export interface IDispatchProps { handleAssessmentOverviewFetch: () => void + handleResetAssessmentWorkspace: () => void + handleUpdateCurrentAssessmentId: (assessmentId: number, questionId: number) => void } -export type DispatchProps = Pick -export type OwnProps = Pick -export type StateProps = Pick +export interface IOwnProps { + assessmentCategory: AssessmentCategory +} + +export interface IStateProps { + assessmentOverviews?: IAssessmentOverview[] + storedAssessmentId?: number + storedQuestionId?: number +} class AssessmentListing extends React.Component { + public componentWillMount() { + const assessmentId = stringParamToInt(this.props.match.params.assessmentId) + const questionId = stringParamToInt(this.props.match.params.questionId) + if (assessmentId === null || questionId === null) { + return + } + + if ( + this.props.storedAssessmentId !== assessmentId || + this.props.storedQuestionId !== questionId + ) { + this.props.handleUpdateCurrentAssessmentId(assessmentId, questionId) + this.props.handleResetAssessmentWorkspace() + } + } + public render() { const assessmentIdParam: number | null = stringParamToInt(this.props.match.params.assessmentId) // default questionId is 0 (the first question) diff --git a/src/components/assessment/__tests__/AssessmentListing.tsx b/src/components/assessment/__tests__/AssessmentListing.tsx index 4dd01e941b..dd0e10d5b3 100644 --- a/src/components/assessment/__tests__/AssessmentListing.tsx +++ b/src/components/assessment/__tests__/AssessmentListing.tsx @@ -7,24 +7,28 @@ import { mockRouterProps } from '../../../mocks/components' import AssessmentListing, { IAssessmentListingProps } from '../AssessmentListing' import { AssessmentCategories } from '../assessmentShape' -const mockUndefinedAssessmentListing: IAssessmentListingProps = { - ...mockRouterProps('/academy/missions', {}), +const defaultProps: IAssessmentListingProps = { + assessmentCategory: AssessmentCategories.Mission, + assessmentOverviews: undefined, handleAssessmentOverviewFetch: () => {}, - assessmentCategory: AssessmentCategories.MISSION + handleResetAssessmentWorkspace: () => {}, + handleUpdateCurrentAssessmentId: (assessmentId: number, questionId: number) => {}, + ...mockRouterProps('/academy/missions', {}) +} + +const mockUndefinedAssessmentListing: IAssessmentListingProps = { + ...defaultProps, + assessmentOverviews: undefined } const mockEmptyAssessmentListing: IAssessmentListingProps = { - ...mockRouterProps('/academy/missions', {}), - assessmentOverviews: [], - handleAssessmentOverviewFetch: () => {}, - assessmentCategory: AssessmentCategories.MISSION + ...defaultProps, + assessmentOverviews: [] } const mockPresentAssessmentListing: IAssessmentListingProps = { - ...mockRouterProps('/academy/missions', {}), - assessmentOverviews: mockAssessmentOverviews, - handleAssessmentOverviewFetch: () => {}, - assessmentCategory: AssessmentCategories.MISSION + ...defaultProps, + assessmentOverviews: mockAssessmentOverviews } test('AssessmentListing page "loading" content renders correctly', () => { diff --git a/src/components/assessment/__tests__/__snapshots__/AssessmentListing.tsx.snap b/src/components/assessment/__tests__/__snapshots__/AssessmentListing.tsx.snap index d823159cc9..c6859ed306 100644 --- a/src/components/assessment/__tests__/__snapshots__/AssessmentListing.tsx.snap +++ b/src/components/assessment/__tests__/__snapshots__/AssessmentListing.tsx.snap @@ -3,7 +3,7 @@ exports[`AssessmentListing page "loading" content renders correctly 1`] = ` " - +
@@ -45,7 +45,7 @@ exports[`AssessmentListing page "loading" content renders correctly 1`] = ` exports[`AssessmentListing page with 0 missions renders correctly 1`] = ` " - +
@@ -85,7 +85,7 @@ exports[`AssessmentListing page with 0 missions renders correctly 1`] = ` exports[`AssessmentListing page with multiple loaded missions renders correctly 1`] = ` " - +
diff --git a/src/components/assessment/assessmentShape.ts b/src/components/assessment/assessmentShape.ts index eb53408d0f..320f9d6c0e 100644 --- a/src/components/assessment/assessmentShape.ts +++ b/src/components/assessment/assessmentShape.ts @@ -26,13 +26,13 @@ export interface IAssessment { } /* The different kinds of Assessments available */ -export type AssessmentCategory = 'Contest' | 'Mission' | 'Path' | 'Sidequest' export enum AssessmentCategories { - CONTEST = 'Contest', - MISSION = 'Mission', - PATH = 'Path', - SIDEQUEST = 'Sidequest' + Contest = 'Contest', + Mission = 'Mission', + Path = 'Path', + Sidequest = 'Sidequest' } +export type AssessmentCategory = keyof typeof AssessmentCategories export interface IProgrammingQuestion extends IQuestion { library: Library diff --git a/src/components/assessment/index.tsx b/src/components/assessment/index.tsx index 96f3df3474..c5a167ed76 100644 --- a/src/components/assessment/index.tsx +++ b/src/components/assessment/index.tsx @@ -2,18 +2,25 @@ import { Button, Card, Dialog, NonIdealState, Spinner, Text } from '@blueprintjs import { IconNames } from '@blueprintjs/icons' import * as React from 'react' -import Workspace from '../../containers/workspace' +import { InterpreterOutput } from '../../reducers/states' import { history } from '../../utils/history' import { assessmentCategoryLink } from '../../utils/paramParseHelpers' -import { OwnProps as WorkspaceProps } from '../workspace' -import { OwnProps as ControlBarOwnProps } from '../workspace/ControlBar' -import { SideContentTab } from '../workspace/side-content' +import Workspace, { WorkspaceProps } from '../workspace' +import { ControlBarProps } from '../workspace/ControlBar' +import { SideContentProps } from '../workspace/side-content' import { IAssessment, IMCQQuestion, IProgrammingQuestion } from './assessmentShape' export type AssessmentProps = DispatchProps & OwnProps & StateProps export type StateProps = { + activeTab: number assessment?: IAssessment + editorValue?: string + editorWidth: string + isRunning: boolean + output: InterpreterOutput[] + replValue: string + sideContentHeight?: number } export type OwnProps = { @@ -23,6 +30,16 @@ export type OwnProps = { export type DispatchProps = { handleAssessmentFetch: (assessmentId: number) => void + handleChangeActiveTab: (activeTab: number) => void + handleChapterSelect: (chapter: any, changeEvent: any) => void + handleEditorEval: () => void + handleEditorValueChange: (val: string) => void + handleEditorWidthChange: (widthChange: number) => void + handleInterruptEval: () => void + handleReplEval: () => void + handleReplOutputClear: () => void + handleReplValueChange: (newValue: string) => void + handleSideContentHeightChange: (heightChange: number) => void } class Assessment extends React.Component { @@ -45,11 +62,10 @@ class Assessment extends React.Component ) } - const longSummaryElement = {this.props.assessment.longSummary} const overlay = ( - {longSummaryElement} + {this.props.assessment.longSummary} ) - const shortSummaryElement = ( - {this.props.assessment.questions[this.props.questionId].content} + const workspaceProps: WorkspaceProps = { + controlBarProps: this.controlBarProps(this.props), + editorProps: { + editorValue: + this.props.editorValue !== undefined + ? this.props.editorValue + : (this.props.assessment.questions[this.props.questionId] as IProgrammingQuestion) + .solutionTemplate, + handleEditorEval: this.props.handleEditorEval, + handleEditorValueChange: this.props.handleEditorValueChange + }, + editorWidth: this.props.editorWidth, + handleEditorWidthChange: this.props.handleEditorWidthChange, + handleSideContentHeightChange: this.props.handleSideContentHeightChange, + mcq: this.props.assessment.questions[this.props.questionId] as IMCQQuestion, + sideContentHeight: this.props.sideContentHeight, + sideContentProps: this.sideContentProps(this.props), + replProps: { + output: this.props.output, + replValue: this.props.replValue, + handleReplEval: this.props.handleReplEval, + handleReplValueChange: this.props.handleReplValueChange + } + } + return ( +
+ {overlay} + +
) - const sideContentTabs: SideContentTab[] = [ + } + + /** Pre-condition: IAssessment has been loaded */ + private sideContentProps: (p: AssessmentProps) => SideContentProps = ( + props: AssessmentProps + ) => ({ + activeTab: 0, + handleChangeActiveTab: (aT: number) => {}, + tabs: [ { - label: `Task ${this.props.questionId}`, + label: `Task ${props.questionId}`, icon: IconNames.NINJA, - body: shortSummaryElement + body: {props.assessment!.questions[props.questionId].content} }, { - label: `${this.props.assessment.category} Briefing`, + label: `${props.assessment!.category} Briefing`, icon: IconNames.BRIEFCASE, - body: longSummaryElement + body: {props.assessment!.longSummary} } ] - const listingPath = `/academy/${assessmentCategoryLink(this.props.assessment.category)}` - const assessmentPath = listingPath + `/${this.props.assessment.id.toString()}` - const controlBarOptions: ControlBarOwnProps = { + }) + + /** Pre-condition: IAssessment has been loaded */ + private controlBarProps: (p: AssessmentProps) => ControlBarProps = (props: AssessmentProps) => { + const listingPath = `/academy/${assessmentCategoryLink(this.props.assessment!.category)}` + const assessmentPath = listingPath + `/${this.props.assessment!.id.toString()}` + return { + handleChapterSelect: this.props.handleChapterSelect, + handleEditorEval: this.props.handleEditorEval, + handleInterruptEval: this.props.handleInterruptEval, + handleReplEval: this.props.handleReplEval, + handleReplOutputClear: this.props.handleReplOutputClear, hasChapterSelect: false, - hasNextButton: this.props.questionId < this.props.assessment.questions.length - 1, + hasNextButton: this.props.questionId < this.props.assessment!.questions.length - 1, hasPreviousButton: this.props.questionId > 0, - hasSubmitButton: this.props.questionId === this.props.assessment.questions.length - 1, + hasSaveButton: true, + hasShareButton: false, + hasSubmitButton: this.props.questionId === this.props.assessment!.questions.length - 1, + isRunning: this.props.isRunning, onClickNext: () => history.push(assessmentPath + `/${(this.props.questionId + 1).toString()}`), onClickPrevious: () => history.push(assessmentPath + `/${(this.props.questionId - 1).toString()}`), onClickSubmit: () => history.push(listingPath), - hasSaveButton: true, - hasShareButton: false + sourceChapter: 2 // TODO dynamic library changing } - const workspaceProps: WorkspaceProps = { - controlBarOptions, - sideContentTabs, - editorValue: (this.props.assessment.questions[this.props.questionId] as IProgrammingQuestion) - .solutionTemplate, - mcq: this.props.assessment.questions[this.props.questionId] as IMCQQuestion - } - return ( -
- {overlay} - -
- ) } } diff --git a/src/components/workspace/ControlBar.tsx b/src/components/workspace/ControlBar.tsx index 50477b3f1b..d134711b98 100644 --- a/src/components/workspace/ControlBar.tsx +++ b/src/components/workspace/ControlBar.tsx @@ -7,53 +7,44 @@ import * as CopyToClipboard from 'react-copy-to-clipboard' import { sourceChapters } from '../../reducers/states' import { controlButton } from '../commons' -type ControlBarProps = DispatchProps & OwnProps & StateProps - -export type DispatchProps = { - handleChapterSelect: (i: IChapter, e: React.ChangeEvent) => void - handleEditorEval: () => void - handleGenerateLz: () => void - handleInterruptEval: () => void - handleReplEval: () => void - handleReplOutputClear: () => void -} - -export type OwnProps = { +export type ControlBarProps = { hasChapterSelect?: boolean hasNextButton?: boolean hasPreviousButton?: boolean - hasSubmitButton?: boolean hasSaveButton?: boolean hasShareButton?: boolean + hasSubmitButton?: boolean + isRunning: boolean + queryString?: string + sourceChapter: number + handleChapterSelect?: (i: IChapter, e: React.ChangeEvent) => void + handleEditorEval: () => void + handleGenerateLz?: () => void + handleInterruptEval: () => void + handleReplEval: () => void + handleReplOutputClear: () => void onClickNext?(): any onClickPrevious?(): any onClickSave?(): any onClickSubmit?(): any } -export type StateProps = { - isRunning: boolean - queryString?: string - sourceChapter: number -} - interface IChapter { - displayName: string chapter: number + displayName: string } class ControlBar extends React.Component { - public static defaultProps: OwnProps = { - hasChapterSelect: true, + public static defaultProps: Partial = { + hasChapterSelect: false, hasNextButton: false, hasPreviousButton: false, - hasSubmitButton: false, hasSaveButton: false, hasShareButton: true, + hasSubmitButton: false, onClickNext: () => {}, onClickPrevious: () => {}, - onClickSave: () => {}, - onClickSubmit: () => {} + onClickSave: () => {} } private shareInputElem: HTMLInputElement @@ -162,6 +153,10 @@ class ControlBar extends React.Component { } } +function styliseChapter(chap: number) { + return `Source \xa7${chap}` +} + const chapters = sourceChapters.map(chap => ({ displayName: styliseChapter(chap), chapter: chap })) const chapterSelect = ( @@ -189,8 +184,4 @@ const chapterRenderer: ItemRenderer = (chap, { handleClick, modifiers, ) -function styliseChapter(chap: number) { - return `Source \xa7${chap}` -} - export default ControlBar diff --git a/src/components/workspace/index.tsx b/src/components/workspace/index.tsx index f334b92d33..c7c253143c 100644 --- a/src/components/workspace/index.tsx +++ b/src/components/workspace/index.tsx @@ -1,35 +1,24 @@ import Resizable, { ResizableProps, ResizeCallback } from 're-resizable' import * as React from 'react' -import ControlBarContainer from '../../containers/workspace/ControlBarContainer' -import EditorContainer from '../../containers/workspace/EditorContainer' -import MCQChooserContainer from '../../containers/workspace/MCQChooserContainer' -import ReplContainer from '../../containers/workspace/ReplContainer' -import SideContent from '../../containers/workspace/SideContentContainer' import { IMCQQuestion } from '../assessment/assessmentShape' -import { OwnProps as ControlBarOwnProps } from './ControlBar' -import { SideContentTab } from './side-content' - -type WorkspaceProps = DispatchProps & OwnProps & StateProps - -export type DispatchProps = { - changeChapter: (newChapter: number) => void +import ControlBar, { ControlBarProps } from './ControlBar' +import Editor, { IEditorProps } from './Editor' +import MCQChooser from './MCQChooser' +import Repl, { IReplProps } from './Repl' +import SideContent, { SideContentProps } from './side-content' + +export type WorkspaceProps = { + // Either editorProps or mcq must be provided + controlBarProps: ControlBarProps + editorProps?: IEditorProps + editorWidth: string handleEditorWidthChange: (widthChange: number) => void handleSideContentHeightChange: (height: number) => void - updateEditorValue: (newEditorValue: string) => void -} - -export type OwnProps = { - controlBarOptions?: ControlBarOwnProps - library?: number - editorValue?: string mcq?: IMCQQuestion - sideContentTabs: SideContentTab[] -} - -export type StateProps = { - editorWidth: string + replProps: IReplProps sideContentHeight?: number + sideContentProps: SideContentProps } class Workspace extends React.Component { @@ -51,16 +40,16 @@ class Workspace extends React.Component { public render() { return (
- +
(this.editorDividerDiv = e!)} /> {this.workspaceInput(this.props)}
- +
(this.sideDividerDiv = e!)} /> - +
@@ -83,10 +72,6 @@ class Workspace extends React.Component { } private sideContentResizableProps() { - const size = - this.props.sideContentHeight === undefined - ? undefined - : { height: this.props.sideContentHeight, width: '100%' } const onResizeStop: ResizeCallback = ({}, {}, ref, {}) => this.props.handleSideContentHeightChange(ref.clientHeight) return { @@ -96,7 +81,13 @@ class Workspace extends React.Component { minHeight: 0, onResize: this.toggleDividerDisplay, onResizeStop, - size + size: + this.props.sideContentHeight === undefined + ? undefined + : { + height: this.props.sideContentHeight, + width: '100%' + } } as ResizableProps } @@ -142,10 +133,10 @@ class Workspace extends React.Component { } private workspaceInput = (props: WorkspaceProps) => { - if (props.editorValue !== undefined) { - return + if (props.editorProps !== undefined) { + return } else { - return + return } } } diff --git a/src/components/workspace/side-content/index.tsx b/src/components/workspace/side-content/index.tsx index 448428514b..b05fef8ef4 100644 --- a/src/components/workspace/side-content/index.tsx +++ b/src/components/workspace/side-content/index.tsx @@ -1,18 +1,10 @@ import { Button, Card, IconName, Tooltip } from '@blueprintjs/core' import * as React from 'react' -type SideContentProps = DispatchProps & OwnProps & StateProps - -export type DispatchProps = { - handleChangeActiveTab: (aT: number) => void -} - -export type OwnProps = { - tabs: SideContentTab[] -} - -export type StateProps = { +export type SideContentProps = { activeTab: number + tabs: SideContentTab[] + handleChangeActiveTab: (aT: number) => void } export type SideContentTab = { diff --git a/src/containers/PlaygroundContainer.ts b/src/containers/PlaygroundContainer.ts index c09c6f3004..25158c52f3 100644 --- a/src/containers/PlaygroundContainer.ts +++ b/src/containers/PlaygroundContainer.ts @@ -1,13 +1,52 @@ -import { connect, MapStateToProps } from 'react-redux' +import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux' import { withRouter } from 'react-router' +import { bindActionCreators, Dispatch } from 'redux' -import Playground, { IPlaygroundProps } from '../components/Playground' +import { + changeActiveTab, + changeEditorWidth, + changeSideContentHeight, + chapterSelect, + clearReplOutput, + evalEditor, + evalRepl, + handleInterruptExecution, + updateEditorValue, + updateReplValue, + WorkspaceLocation +} from '../actions' +import Playground, { IDispatchProps, IStateProps } from '../components/Playground' import { IState } from '../reducers/states' -type StateProps = Pick - -const mapStateToProps: MapStateToProps = state => ({ - editorValue: state.playground.editorValue +const mapStateToProps: MapStateToProps = state => ({ + editorValue: state.workspaces.playground.editorValue, + isRunning: state.workspaces.playground.isRunning, + activeTab: state.workspaces.playground.sideContentActiveTab, + editorWidth: state.workspaces.playground.editorWidth, + sideContentHeight: state.workspaces.playground.sideContentHeight, + output: state.workspaces.playground.output, + replValue: state.workspaces.playground.replValue }) -export default withRouter(connect(mapStateToProps)(Playground)) +const location: WorkspaceLocation = 'playground' + +const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => + bindActionCreators( + { + handleChangeActiveTab: (activeTab: number) => changeActiveTab(activeTab, location), + handleChapterSelect: (chapter: any, changeEvent: any) => + chapterSelect(chapter, changeEvent, location), + handleEditorEval: () => evalEditor(location), + handleEditorValueChange: (val: string) => updateEditorValue(val, location), + handleEditorWidthChange: (widthChange: number) => changeEditorWidth(widthChange, location), + handleInterruptEval: () => handleInterruptExecution(location), + handleReplEval: () => evalRepl(location), + handleReplOutputClear: () => clearReplOutput(location), + handleReplValueChange: (newValue: string) => updateReplValue(newValue, location), + handleSideContentHeightChange: (heightChange: number) => + changeSideContentHeight(heightChange, location) + }, + dispatch + ) + +export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Playground)) diff --git a/src/containers/assessment/AssessmentListingContainer.ts b/src/containers/assessment/AssessmentListingContainer.ts index d0b685551a..7ead6b4bc4 100644 --- a/src/containers/assessment/AssessmentListingContainer.ts +++ b/src/containers/assessment/AssessmentListingContainer.ts @@ -3,28 +3,34 @@ import { withRouter } from 'react-router' import { bindActionCreators, Dispatch } from 'redux' import { fetchAssessmentOverviews } from '../../actions/session' +import { resetAssessmentWorkspace, updateCurrentAssessmentId } from '../../actions/workspaces' import AssessmentListing, { - DispatchProps, - OwnProps, - StateProps + IDispatchProps, + IOwnProps, + IStateProps } from '../../components/assessment/AssessmentListing' import { IAssessmentOverview } from '../../components/assessment/assessmentShape' import { IState } from '../../reducers/states' -const mapStateToProps: MapStateToProps = (state, props) => { +const mapStateToProps: MapStateToProps = (state, props) => { const categoryFilter = (overview: IAssessmentOverview) => overview.category === props.assessmentCategory - return { + const stateProps: IStateProps = { assessmentOverviews: state.session.assessmentOverviews ? state.session.assessmentOverviews.filter(categoryFilter) - : undefined + : undefined, + storedAssessmentId: state.workspaces.currentAssessment, + storedQuestionId: state.workspaces.currentQuestion } + return stateProps } -const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => +const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { - handleAssessmentOverviewFetch: fetchAssessmentOverviews + handleAssessmentOverviewFetch: fetchAssessmentOverviews, + handleResetAssessmentWorkspace: resetAssessmentWorkspace, + handleUpdateCurrentAssessmentId: updateCurrentAssessmentId }, dispatch ) diff --git a/src/containers/assessment/index.ts b/src/containers/assessment/index.ts index 298f0476db..2776b84141 100644 --- a/src/containers/assessment/index.ts +++ b/src/containers/assessment/index.ts @@ -1,20 +1,54 @@ import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux' import { bindActionCreators, Dispatch } from 'redux' -import { fetchAssessment } from '../../actions/session' +import { + changeActiveTab, + changeEditorWidth, + changeSideContentHeight, + chapterSelect, + clearReplOutput, + evalEditor, + evalRepl, + fetchAssessment, + handleInterruptExecution, + updateEditorValue, + updateReplValue, + WorkspaceLocation +} from '../../actions' import Assessment, { DispatchProps, OwnProps, StateProps } from '../../components/assessment' import { IState } from '../../reducers/states' const mapStateToProps: MapStateToProps = (state, props) => { return { - assessment: state.session.assessments.get(props.assessmentId) + assessment: state.session.assessments.get(props.assessmentId), + editorValue: state.workspaces.assessment.editorValue, + isRunning: state.workspaces.assessment.isRunning, + activeTab: state.workspaces.assessment.sideContentActiveTab, + editorWidth: state.workspaces.assessment.editorWidth, + sideContentHeight: state.workspaces.assessment.sideContentHeight, + output: state.workspaces.assessment.output, + replValue: state.workspaces.assessment.replValue } } +const location: WorkspaceLocation = 'assessment' + const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { - handleAssessmentFetch: fetchAssessment + handleAssessmentFetch: fetchAssessment, + handleChangeActiveTab: (activeTab: number) => changeActiveTab(activeTab, location), + handleChapterSelect: (chapter: any, changeEvent: any) => + chapterSelect(chapter, changeEvent, location), + handleEditorEval: () => evalEditor(location), + handleEditorValueChange: (val: string) => updateEditorValue(val, location), + handleEditorWidthChange: (widthChange: number) => changeEditorWidth(widthChange, location), + handleInterruptEval: () => handleInterruptExecution(location), + handleReplEval: () => evalRepl(location), + handleReplOutputClear: () => clearReplOutput(location), + handleReplValueChange: (newValue: string) => updateReplValue(newValue, location), + handleSideContentHeightChange: (heightChange: number) => + changeSideContentHeight(heightChange, location) }, dispatch ) diff --git a/src/containers/workspace/ControlBarContainer.ts b/src/containers/workspace/ControlBarContainer.ts deleted file mode 100644 index c7e5bca483..0000000000 --- a/src/containers/workspace/ControlBarContainer.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux' -import { bindActionCreators, Dispatch } from 'redux' - -import { handleInterruptExecution } from '../../actions/interpreter' -import { - chapterSelect, - clearReplOutput, - evalEditor, - evalRepl, - generateLzString -} from '../../actions/playground' -import ControlBar, { - DispatchProps, - OwnProps, - StateProps -} from '../../components/workspace/ControlBar' -import { IState } from '../../reducers/states' - -const mapStateToProps: MapStateToProps = (state, props) => ({ - ...props, - isRunning: state.playground.isRunning, - queryString: state.playground.queryString, - sourceChapter: state.playground.sourceChapter -}) - -const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - handleChapterSelect: chapterSelect, - handleEditorEval: evalEditor, - handleGenerateLz: generateLzString, - handleInterruptEval: handleInterruptExecution, - handleReplEval: evalRepl, - handleReplOutputClear: clearReplOutput - }, - dispatch - ) - -export default connect(mapStateToProps, mapDispatchToProps)( - ControlBar -) diff --git a/src/containers/workspace/EditorContainer.ts b/src/containers/workspace/EditorContainer.ts deleted file mode 100644 index bb8992e594..0000000000 --- a/src/containers/workspace/EditorContainer.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux' -import { bindActionCreators, Dispatch } from 'redux' - -import { evalEditor, updateEditorValue } from '../../actions/playground' -import Editor, { IEditorProps } from '../../components/workspace/Editor' -import { IState } from '../../reducers/states' - -type DispatchProps = Pick & - Pick - -const mapStateToProps: MapStateToProps<{}, {}, IState> = state => ({}) - -const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - handleEditorEval: evalEditor, - handleEditorValueChange: updateEditorValue - }, - dispatch - ) - -export default connect(mapStateToProps, mapDispatchToProps)(Editor) diff --git a/src/containers/workspace/MCQChooserContainer.ts b/src/containers/workspace/MCQChooserContainer.ts deleted file mode 100644 index cbdc885be8..0000000000 --- a/src/containers/workspace/MCQChooserContainer.ts +++ /dev/null @@ -1,3 +0,0 @@ -import MCQChooser from '../../components/workspace/MCQChooser' - -export default MCQChooser diff --git a/src/containers/workspace/ReplContainer.ts b/src/containers/workspace/ReplContainer.ts deleted file mode 100644 index a3319541eb..0000000000 --- a/src/containers/workspace/ReplContainer.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux' -import { bindActionCreators, Dispatch } from 'redux' - -import { evalRepl, updateReplValue } from '../../actions/playground' -import Repl, { IReplProps } from '../../components/workspace/Repl' -import { IState } from '../../reducers/states' - -type StateProps = Pick & Pick -type DispatchProps = Pick & Pick - -const mapStateToProps: MapStateToProps = state => { - return { - output: state.playground.output, - replValue: state.playground.replValue - } -} - -const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - handleReplEval: evalRepl, - handleReplValueChange: updateReplValue - }, - dispatch - ) - -export default connect(mapStateToProps, mapDispatchToProps)(Repl) diff --git a/src/containers/workspace/SideContentContainer.ts b/src/containers/workspace/SideContentContainer.ts deleted file mode 100644 index 928a4da5e4..0000000000 --- a/src/containers/workspace/SideContentContainer.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux' -import { bindActionCreators, Dispatch } from 'redux' - -import { changeActiveTab } from '../../actions/playground' -import SideContent, { DispatchProps, StateProps } from '../../components/workspace/side-content' -import { IState } from '../../reducers/states' - -const mapStateToProps: MapStateToProps = state => { - return { - activeTab: state.playground.sideContentActiveTab - } -} - -const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - handleChangeActiveTab: changeActiveTab - }, - dispatch - ) - -export default connect(mapStateToProps, mapDispatchToProps)(SideContent) diff --git a/src/containers/workspace/index.ts b/src/containers/workspace/index.ts deleted file mode 100644 index 26335629e0..0000000000 --- a/src/containers/workspace/index.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux' -import { bindActionCreators, Dispatch } from 'redux' - -import { - changeChapter, - changeEditorWidth, - changeSideContentHeight, - updateEditorValue -} from '../../actions/playground' -import Workspace, { DispatchProps, StateProps } from '../../components/workspace/' -import { IState } from '../../reducers/states' - -/** Provides the editorValue of the `IPlaygroundState` of the `IState` as a - * `StateProps` to the Playground component - */ -const mapStateToProps: MapStateToProps = state => { - return { - editorWidth: state.playground.editorWidth, - sideContentHeight: state.playground.sideContentHeight - } -} - -const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch) => - bindActionCreators( - { - changeChapter, - handleEditorWidthChange: changeEditorWidth, - handleSideContentHeightChange: changeSideContentHeight, - updateEditorValue - }, - dispatch - ) - -export default connect(mapStateToProps, mapDispatchToProps)(Workspace) diff --git a/src/createStore.ts b/src/createStore.ts index 3f3d130fc8..a4b743cbe4 100644 --- a/src/createStore.ts +++ b/src/createStore.ts @@ -7,14 +7,13 @@ import storage from 'redux-persist/lib/storage' // defaults to localStorage import createSagaMiddleware from 'redux-saga' import reducers from './reducers' -import { IApplicationState, IPlaygroundState, ISessionState, IState } from './reducers/states' +import { IApplicationState, ISessionState, IState } from './reducers/states' import mainSaga from './sagas' import { history as appHistory } from './utils/history' declare var __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: () => StoreEnhancer type IPersistState = Pick & - Pick & Pick & Pick & Pick @@ -30,7 +29,6 @@ function createStore(history: History) { const transforms = [ createFilter('application', ['environment']), - createFilter('playground', ['editorValue']), createFilter('session', ['token', 'username', 'historyHelper']) ] diff --git a/src/mocks/api.ts b/src/mocks/api.ts index 224c720b60..05ab5cc515 100644 --- a/src/mocks/api.ts +++ b/src/mocks/api.ts @@ -9,7 +9,7 @@ import { const mockOpenAssessmentsOverviews: IAssessmentOverview[] = [ { - category: AssessmentCategories.MISSION, + category: AssessmentCategories.Mission, closeAt: '2048-06-18T05:24:26.026Z', coverImage: 'www.imgur.com', id: 0, @@ -21,7 +21,7 @@ const mockOpenAssessmentsOverviews: IAssessmentOverview[] = [ title: 'An Odessey to Runes' }, { - category: AssessmentCategories.MISSION, + category: AssessmentCategories.Mission, closeAt: '2048-06-18T05:24:26.026Z', coverImage: 'www.imgur.com', id: 1, @@ -33,7 +33,7 @@ const mockOpenAssessmentsOverviews: IAssessmentOverview[] = [ title: 'The Secret to Streams' }, { - category: AssessmentCategories.SIDEQUEST, + category: AssessmentCategories.Sidequest, closeAt: '2048-06-18T05:24:26.026Z', coverImage: 'www.imgur.com', id: 2, @@ -48,7 +48,7 @@ const mockOpenAssessmentsOverviews: IAssessmentOverview[] = [ const mockClosedAssessmentOverviews: IAssessmentOverview[] = [ { - category: AssessmentCategories.MISSION, + category: AssessmentCategories.Mission, closeAt: '2008-06-18T05:24:26.026Z', coverImage: 'www.imgur.com', id: 3, @@ -60,7 +60,7 @@ const mockClosedAssessmentOverviews: IAssessmentOverview[] = [ title: 'A closed Mission' }, { - category: AssessmentCategories.SIDEQUEST, + category: AssessmentCategories.Sidequest, closeAt: '2008-06-18T05:24:26.026Z', coverImage: 'www.imgur.com', id: 4, @@ -149,7 +149,7 @@ export const mockAssessments: IAssessment[] = [ title: 'The Secret to Streams' }, { - category: AssessmentCategories.SIDEQUEST, + category: AssessmentCategories.Sidequest, id: 2, longSummary: 'This is the sidequest briefing. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas viverra, sem scelerisque ultricies ullamcorper, sem nibh sollicitudin enim, at ultricies sem orci eget odio. Pellentesque varius et mauris quis vestibulum. Etiam in egestas dolor. Nunc consectetur, sapien sodales accumsan convallis, lectus mi tempus ipsum, vel ornare metus turpis sed justo. Vivamus at tellus sed ex convallis commodo at in lectus. Pellentesque pharetra pulvinar sapien pellentesque facilisis. Curabitur efficitur malesuada urna sed aliquam. Quisque massa metus, aliquam in sagittis non, cursus in sem. Morbi vel nunc at nunc pharetra lobortis. Aliquam feugiat ultricies ipsum vel sollicitudin. Vivamus nulla massa, hendrerit sit amet nibh quis, porttitor convallis nisi. ', diff --git a/src/mocks/store.ts b/src/mocks/store.ts index 6d5b522491..9d56e1c607 100644 --- a/src/mocks/store.ts +++ b/src/mocks/store.ts @@ -6,6 +6,7 @@ import { defaultApplication, defaultPlayground, defaultSession, + defaultWorkspaceManager, IState } from '../reducers/states' @@ -15,6 +16,7 @@ export function mockInitialStore

(): Store { academy: defaultAcademy, application: defaultApplication, playground: defaultPlayground, + workspaces: defaultWorkspaceManager, session: defaultSession } return createStore(state) diff --git a/src/reducers/index.ts b/src/reducers/index.ts index b5228a7423..90f1f13fd9 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,11 +1,11 @@ import { reducer as academy } from './academy' import { reducer as application } from './application' -import { reducer as playground } from './playground' import { reducer as session } from './session' +import { reducer as workspaces } from './workspaces' export default { academy, application, - playground, + workspaces, session } diff --git a/src/reducers/playground.ts b/src/reducers/playground.ts deleted file mode 100644 index 45b34d851d..0000000000 --- a/src/reducers/playground.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { Reducer } from 'redux' -import { - CHANGE_ACTIVE_TAB, - CHANGE_CHAPTER, - CHANGE_EDITOR_WIDTH, - CHANGE_QUERY_STRING, - CHANGE_SIDE_CONTENT_HEIGHT, - CLEAR_CONTEXT, - CLEAR_REPL_INPUT, - CLEAR_REPL_OUTPUT, - EVAL_EDITOR, - EVAL_INTERPRETER_ERROR, - EVAL_INTERPRETER_SUCCESS, - EVAL_REPL, - HANDLE_CONSOLE_LOG, - IAction, - INTERRUPT_EXECUTION, - SEND_REPL_INPUT_TO_OUTPUT, - UPDATE_EDITOR_VALUE, - UPDATE_REPL_VALUE -} from '../actions/actionTypes' -import { createContext } from '../slang' -import { CodeOutput, defaultPlayground, InterpreterOutput, IPlaygroundState } from './states' - -export const reducer: Reducer = (state = defaultPlayground, action: IAction) => { - let newOutput: InterpreterOutput[] - let lastOutput: InterpreterOutput - switch (action.type) { - case CHANGE_ACTIVE_TAB: - return { - ...state, - sideContentActiveTab: action.payload - } - case CHANGE_EDITOR_WIDTH: - return { - ...state, - editorWidth: (parseFloat(state.editorWidth.slice(0, -1)) + action.payload).toString() + '%' - } - case CHANGE_QUERY_STRING: - return { - ...state, - queryString: action.payload - } - case CHANGE_SIDE_CONTENT_HEIGHT: - return { - ...state, - sideContentHeight: action.payload - } - case CLEAR_REPL_INPUT: - return { - ...state, - replValue: '' - } - case CLEAR_REPL_OUTPUT: - return { - ...state, - output: [] - } - case CLEAR_CONTEXT: - return { - ...state, - context: createContext(state.sourceChapter) - } - case CHANGE_CHAPTER: - return { - ...state, - sourceChapter: action.payload - } - case HANDLE_CONSOLE_LOG: - /* Possible cases: - * (1) state.output === [], i.e. state.output[-1] === undefined - * (2) state.output[-1] is not RunningOutput - * (3) state.output[-1] is RunningOutput */ - lastOutput = state.output.slice(-1)[0] - if (lastOutput === undefined || lastOutput.type !== 'running') { - newOutput = state.output.concat({ - type: 'running', - consoleLogs: [action.payload] - }) - } else { - const updatedLastOutput = { - type: lastOutput.type, - consoleLogs: lastOutput.consoleLogs.concat(action.payload) - } - newOutput = state.output.slice(0, -1).concat(updatedLastOutput) - } - return { - ...state, - output: newOutput - } - case SEND_REPL_INPUT_TO_OUTPUT: - newOutput = state.output.concat(action.payload as CodeOutput) - return { - ...state, - output: newOutput - } - case EVAL_EDITOR: - return { - ...state, - isRunning: true - } - case EVAL_REPL: - return { - ...state, - isRunning: true - } - case EVAL_INTERPRETER_SUCCESS: - lastOutput = state.output.slice(-1)[0] - if (lastOutput !== undefined && lastOutput.type === 'running') { - newOutput = state.output.slice(0, -1).concat({ - ...action.payload, - consoleLogs: lastOutput.consoleLogs - }) - } else { - newOutput = state.output.concat({ - ...action.payload, - consoleLogs: [] - }) - } - return { - ...state, - output: newOutput, - isRunning: false - } - case EVAL_INTERPRETER_ERROR: - lastOutput = state.output.slice(-1)[0] - if (lastOutput !== undefined && lastOutput.type === 'running') { - newOutput = state.output.slice(0, -1).concat({ - ...action.payload, - consoleLogs: lastOutput.consoleLogs - }) - } else { - newOutput = state.output.concat({ - ...action.payload, - consoleLogs: [] - }) - } - return { - ...state, - output: newOutput, - isRunning: false - } - case INTERRUPT_EXECUTION: - return { - ...state, - isRunning: false - } - case UPDATE_EDITOR_VALUE: - return { - ...state, - editorValue: action.payload - } - case UPDATE_REPL_VALUE: - return { - ...state, - replValue: action.payload - } - default: - return state - } -} diff --git a/src/reducers/states.ts b/src/reducers/states.ts index e101d93679..7c0e8ad2a3 100644 --- a/src/reducers/states.ts +++ b/src/reducers/states.ts @@ -9,6 +9,7 @@ export interface IState { readonly application: IApplicationState readonly playground: IPlaygroundState readonly session: ISessionState + readonly workspaces: IWorkspaceManagerState } export interface IAcademyState { @@ -20,13 +21,20 @@ export interface IApplicationState { readonly environment: ApplicationEnvironment } -export interface IPlaygroundState extends IWorkspaceState { +export interface IPlaygroundState { readonly queryString?: string } +export interface IWorkspaceManagerState { + readonly assessment: IWorkspaceState + readonly currentAssessment?: number + readonly playground: IWorkspaceState + readonly currentQuestion?: number +} + interface IWorkspaceState { readonly context: Context - readonly editorValue: string + readonly editorValue?: string readonly editorWidth: string readonly isRunning: boolean readonly output: InterpreterOutput[] @@ -120,16 +128,24 @@ export const defaultApplication: IApplicationState = { environment: currentEnvironment() } -export const defaultPlayground: IPlaygroundState = { +export const defaultPlayground: IPlaygroundState = {} + +export const createDefaultWorkspace: () => IWorkspaceState = () => ({ context: createContext(latestSourceChapter), - editorValue: '', + editorValue: undefined, editorWidth: '50%', isRunning: false, output: [], replValue: '', sideContentActiveTab: 0, - sideContentHeight: undefined, sourceChapter: latestSourceChapter +}) + +export const defaultWorkspaceManager: IWorkspaceManagerState = { + currentAssessment: undefined, + currentQuestion: undefined, + assessment: { ...createDefaultWorkspace() }, + playground: { ...createDefaultWorkspace() } } export const defaultSession: ISessionState = { diff --git a/src/reducers/workspaces.ts b/src/reducers/workspaces.ts new file mode 100644 index 0000000000..1aea1ef2d7 --- /dev/null +++ b/src/reducers/workspaces.ts @@ -0,0 +1,250 @@ +import { Reducer } from 'redux' +import { + CHANGE_ACTIVE_TAB, + CHANGE_CHAPTER, + CHANGE_EDITOR_WIDTH, + CHANGE_QUERY_STRING, + CHANGE_SIDE_CONTENT_HEIGHT, + CLEAR_CONTEXT, + CLEAR_REPL_INPUT, + CLEAR_REPL_OUTPUT, + EVAL_EDITOR, + EVAL_INTERPRETER_ERROR, + EVAL_INTERPRETER_SUCCESS, + EVAL_REPL, + HANDLE_CONSOLE_LOG, + IAction, + INTERRUPT_EXECUTION, + RESET_ASSESSMENT_WORKSPACE, + SEND_REPL_INPUT_TO_OUTPUT, + UPDATE_CURRENT_ASSESSMENT_ID, + UPDATE_EDITOR_VALUE, + UPDATE_REPL_VALUE +} from '../actions/actionTypes' +import { WorkspaceLocation } from '../actions/workspaces' +import { createContext } from '../slang' +import { + CodeOutput, + createDefaultWorkspace, + defaultWorkspaceManager, + InterpreterOutput, + IWorkspaceManagerState +} from './states' + +/** + * Takes in a IWorkspaceManagerState and maps it to a new state. The pre-conditions are that + * - There exists an IWorkspaceState in the IWorkspaceManagerState of the key `location`. + * - `location` is defined (and exists) as a property 'workspaceLocation' in the action's payload. + */ +export const reducer: Reducer = ( + state = defaultWorkspaceManager, + action: IAction +) => { + const location: WorkspaceLocation = + action.payload !== undefined ? action.payload.workspaceLocation : undefined + let newOutput: InterpreterOutput[] + let lastOutput: InterpreterOutput + switch (action.type) { + case CHANGE_ACTIVE_TAB: + return { + ...state, + [location]: { + ...state[location], + sideContentActiveTab: action.payload + } + } + case CHANGE_EDITOR_WIDTH: + return { + ...state, + [location]: { + ...state[location], + editorWidth: + ( + parseFloat(state[location].editorWidth.slice(0, -1)) + action.payload.widthChange + ).toString() + '%' + } + } + case CHANGE_QUERY_STRING: + return { + ...state, + [location]: { + ...state[location], + queryString: action.payload.queryString + } + } + case CHANGE_SIDE_CONTENT_HEIGHT: + return { + ...state, + [location]: { + ...state[location], + sideContentHeight: action.payload.height + } + } + case CLEAR_REPL_INPUT: + return { + ...state, + [location]: { + ...state[location], + replValue: '' + } + } + case CLEAR_REPL_OUTPUT: + return { + ...state, + [location]: { + ...state[location], + output: [] + } + } + case CLEAR_CONTEXT: + return { + ...state, + [location]: { + ...state[location], + context: createContext(state[location].sourceChapter) + } + } + case CHANGE_CHAPTER: + return { + ...state, + [location]: { + ...state[location], + sourceChapter: action.payload.newChapter + } + } + case HANDLE_CONSOLE_LOG: + /* Possible cases: + * (1) state[location].output === [], i.e. state[location].output[-1] === undefined + * (2) state[location].output[-1] is not RunningOutput + * (3) state[location].output[-1] is RunningOutput */ + lastOutput = state[location].output.slice(-1)[0] + if (lastOutput === undefined || lastOutput.type !== 'running') { + newOutput = state[location].output.concat({ + type: 'running', + consoleLogs: [action.payload.log] + }) + } else { + const updatedLastOutput = { + type: lastOutput.type, + consoleLogs: lastOutput.consoleLogs.concat(action.payload.log) + } + newOutput = state[location].output.slice(0, -1).concat(updatedLastOutput) + } + return { + ...state, + [location]: { + ...state[location], + output: newOutput + } + } + case SEND_REPL_INPUT_TO_OUTPUT: + // CodeOutput properties exist in parallel with workspaceLocation + newOutput = state[location].output.concat(action.payload as CodeOutput) + return { + ...state, + [location]: { + ...state[location], + output: newOutput + } + } + case EVAL_EDITOR: + return { + ...state, + [location]: { + ...state[location], + isRunning: true + } + } + case EVAL_REPL: + return { + ...state, + [location]: { + ...state[location], + isRunning: true + } + } + case EVAL_INTERPRETER_SUCCESS: + lastOutput = state[location].output.slice(-1)[0] + if (lastOutput !== undefined && lastOutput.type === 'running') { + newOutput = state[location].output.slice(0, -1).concat({ + ...action.payload, + workspaceLocation: undefined, + consoleLogs: lastOutput.consoleLogs + }) + } else { + newOutput = state[location].output.concat({ + ...action.payload, + workspaceLocation: undefined, + consoleLogs: [] + }) + } + return { + ...state, + [location]: { + ...state[location], + output: newOutput, + isRunning: false + } + } + case EVAL_INTERPRETER_ERROR: + lastOutput = state[location].output.slice(-1)[0] + if (lastOutput !== undefined && lastOutput.type === 'running') { + newOutput = state[location].output.slice(0, -1).concat({ + ...action.payload, + workspaceLocation: undefined, + consoleLogs: lastOutput.consoleLogs + }) + } else { + newOutput = state[location].output.concat({ + ...action.payload, + workspaceLocation: undefined, + consoleLogs: [] + }) + } + return { + ...state, + [location]: { + ...state[location], + output: newOutput, + isRunning: false + } + } + case INTERRUPT_EXECUTION: + return { + ...state, + [location]: { + ...state[location], + isRunning: false + } + } + case RESET_ASSESSMENT_WORKSPACE: + return { + ...state, + assessment: createDefaultWorkspace() + } + case UPDATE_CURRENT_ASSESSMENT_ID: + return { + ...state, + currentAssessment: action.payload.assessmentId, + currentQuestion: action.payload.questionId + } + case UPDATE_EDITOR_VALUE: + return { + ...state, + [location]: { + ...state[location], + editorValue: action.payload.newEditorValue + } + } + case UPDATE_REPL_VALUE: + return { + ...state, + [location]: { + ...state[location], + replValue: action.payload.newReplValue + } + } + default: + return state + } +} diff --git a/src/sagas/index.ts b/src/sagas/index.ts index 275ea1690b..47ce70d69b 100644 --- a/src/sagas/index.ts +++ b/src/sagas/index.ts @@ -5,6 +5,7 @@ import { call, put, race, select, take, takeEvery } from 'redux-saga/effects' import * as actions from '../actions' import * as actionTypes from '../actions/actionTypes' +import { WorkspaceLocation } from '../actions/workspaces' import { mockAssessmentOverviews, mockAssessments } from '../mocks/api' import { IState } from '../reducers/states' import { Context, interrupt, runInContext } from '../slang' @@ -13,9 +14,9 @@ import { showSuccessMessage, showWarningMessage } from '../utils/notification' function* mainSaga() { yield* apiFetchSaga() - yield* interpreterSaga() - yield* loginSaga() yield* workspaceSaga() + yield* loginSaga() + yield* playgroundSaga() } function* apiFetchSaga(): SagaIterator { @@ -37,32 +38,35 @@ function* apiFetchSaga(): SagaIterator { }) } -function* interpreterSaga(): SagaIterator { +function* workspaceSaga(): SagaIterator { let context: Context - yield takeEvery(actionTypes.EVAL_EDITOR, function*() { - const code: string = yield select((state: IState) => state.playground.editorValue) - yield put(actions.clearContext()) - yield put(actions.clearReplOutput()) - context = yield select((state: IState) => state.playground.context) - yield* evalCode(code, context) + yield takeEvery(actionTypes.EVAL_EDITOR, function*(action) { + const location = (action as actionTypes.IAction).payload.workspaceLocation + const code: string = yield select((state: IState) => state.workspaces[location].editorValue) + yield put(actions.clearContext(location)) + yield put(actions.clearReplOutput(location)) + context = yield select((state: IState) => state.workspaces[location].context) + yield* evalCode(code, context, location) }) - yield takeEvery(actionTypes.EVAL_REPL, function*() { - const code: string = yield select((state: IState) => state.playground.replValue) - context = yield select((state: IState) => state.playground.context) - yield put(actions.clearReplInput()) - yield put(actions.sendReplInputToOutput(code)) - yield* evalCode(code, context) + yield takeEvery(actionTypes.EVAL_REPL, function*(action) { + const location = (action as actionTypes.IAction).payload.workspaceLocation + const code: string = yield select((state: IState) => state.workspaces[location].replValue) + context = yield select((state: IState) => state.workspaces[location].context) + yield put(actions.clearReplInput(location)) + yield put(actions.sendReplInputToOutput(code, location)) + yield* evalCode(code, context, location) }) yield takeEvery(actionTypes.CHAPTER_SELECT, function*(action) { + const location = (action as actionTypes.IAction).payload.workspaceLocation const newChapter = parseInt((action as actionTypes.IAction).payload, 10) - const oldChapter = yield select((state: IState) => state.playground.sourceChapter) + const oldChapter = yield select((state: IState) => state.workspaces[location].sourceChapter) if (newChapter !== oldChapter) { - yield put(actions.changeChapter(newChapter)) - yield put(actions.clearContext()) - yield put(actions.clearReplOutput()) + yield put(actions.changeChapter(newChapter, location)) + yield put(actions.clearContext(location)) + yield put(actions.clearReplOutput(location)) yield call(showSuccessMessage, `Switched to Source \xa7${newChapter}`) } }) @@ -86,10 +90,10 @@ function* loginSaga(): SagaIterator { }) } -function* workspaceSaga(): SagaIterator { +function* playgroundSaga(): SagaIterator { yield takeEvery(actionTypes.GENERATE_LZ_STRING, function*() { - const code = yield select((state: IState) => state.playground.editorValue) - const lib = yield select((state: IState) => state.playground.sourceChapter) + const code = yield select((state: IState) => state.workspaces.playground.editorValue) + const lib = yield select((state: IState) => state.workspaces.playground.sourceChapter) const newQueryString = code === '' ? undefined @@ -101,16 +105,16 @@ function* workspaceSaga(): SagaIterator { }) } -function* evalCode(code: string, context: Context) { +function* evalCode(code: string, context: Context, location: WorkspaceLocation) { const { result, interrupted } = yield race({ result: call(runInContext, code, context, { scheduler: 'async' }), interrupted: take(actionTypes.INTERRUPT_EXECUTION) }) if (result) { if (result.status === 'finished') { - yield put(actions.evalInterpreterSuccess(result.value)) + yield put(actions.evalInterpreterSuccess(result.value, location)) } else { - yield put(actions.evalInterpreterError(context.errors)) + yield put(actions.evalInterpreterError(context.errors, location)) } } else if (interrupted) { interrupt(context)