diff --git a/game_frontend/src/components/GameView/__snapshots__/index.test.js.snap b/game_frontend/src/components/GameView/__snapshots__/index.test.js.snap index 5ca89bf74..119536fad 100644 --- a/game_frontend/src/components/GameView/__snapshots__/index.test.js.snap +++ b/game_frontend/src/components/GameView/__snapshots__/index.test.js.snap @@ -60,7 +60,7 @@ exports[` shows loading bar whilst game is loading 1`] = ` /> { return ( diff --git a/game_frontend/src/components/IDEEditor/__snapshots__/index.test.js.snap b/game_frontend/src/components/IDEEditor/__snapshots__/index.test.js.snap index 16deb5dc8..ebbb6890a 100644 --- a/game_frontend/src/components/IDEEditor/__snapshots__/index.test.js.snap +++ b/game_frontend/src/components/IDEEditor/__snapshots__/index.test.js.snap @@ -47,16 +47,12 @@ exports[` matches snapshot 1`] = ` width="100%" wrapEnabled={false} /> - - - Run Code - + isCodeOnServerDifferent={true} + whenClicked={[MockFunction]} + /> `; @@ -70,576 +66,3 @@ exports[` renders correctly 1`] = ` className="c0" /> `; - -exports[` matches snapshot 1`] = ` -.c0 { - margin-right: 8px; -} - - -`; - -exports[` matches snapshot 1`] = ` -.c0.c0 { - position: absolute; - right: 24px; - bottom: 24px; - z-index: 5; -} - - -`; diff --git a/game_frontend/src/components/IDEEditor/index.js b/game_frontend/src/components/IDEEditor/index.js index 5785d5b6d..f8fa817b7 100644 --- a/game_frontend/src/components/IDEEditor/index.js +++ b/game_frontend/src/components/IDEEditor/index.js @@ -7,15 +7,14 @@ import 'brace/snippets/python' import 'brace/ext/language_tools' import PropTypes from 'prop-types' import { withTheme } from '@material-ui/core/styles' -import Button from '@material-ui/core/Button' -import PlayIcon from 'components/icons/Play' +import RunCodeButton from 'components/RunCodeButton' export const IDEEditorLayout = styled.div` position: relative; grid-area: ide-editor; ` -export const RunCodeButton = styled(Button)` +export const PositionedRunCodeButton = styled(RunCodeButton)` && { position: absolute; right: ${props => props.theme.spacing.unit * 3}px; @@ -24,11 +23,11 @@ export const RunCodeButton = styled(Button)` } ` -export const MarginedPlayIcon = styled(PlayIcon)` - margin-right: ${props => props.theme.spacing.unit}px; -` - export class IDEEditor extends PureComponent { + isCodeOnServerDifferent () { + return this.props.code !== this.props.codeOnServer + } + render () { return ( @@ -53,14 +52,12 @@ export class IDEEditor extends PureComponent { tabSize: 2, fontFamily: this.props.theme.additionalVariables.typography.code.fontFamily }} /> - - Run Code - + whenClicked={this.props.postCode} /> ) } @@ -68,10 +65,12 @@ export class IDEEditor extends PureComponent { IDEEditor.propTypes = { code: PropTypes.string, + codeOnServer: PropTypes.string, getCode: PropTypes.func, editorChanged: PropTypes.func, theme: PropTypes.object, - postCode: PropTypes.func + postCode: PropTypes.func, + runCodeButtonStatus: PropTypes.object } export default withTheme()(IDEEditor) diff --git a/game_frontend/src/components/IDEEditor/index.test.js b/game_frontend/src/components/IDEEditor/index.test.js index 88483a52b..136e71143 100644 --- a/game_frontend/src/components/IDEEditor/index.test.js +++ b/game_frontend/src/components/IDEEditor/index.test.js @@ -1,7 +1,8 @@ /* eslint-env jest */ import React from 'react' -import { IDEEditor, IDEEditorLayout, MarginedPlayIcon, RunCodeButton } from 'components/IDEEditor' +import { IDEEditor, IDEEditorLayout } from 'components/IDEEditor' import createShallowWithTheme from 'testHelpers/createShallow' +import { Avatar } from '@material-ui/core' describe('', () => { it('matches snapshot', () => { @@ -16,7 +17,7 @@ describe('', () => { expect(component).toMatchSnapshot() }) - it('calls the postCode function in props when Post code button is pressed', () => { + it('does not call post code as button is intially disabled', () => { const postCode = jest.fn() const props = { postCode @@ -25,7 +26,7 @@ describe('', () => { const component = createShallowWithTheme(, 'dark') component.find('#post-code-button').simulate('click') - expect(postCode).toBeCalled() + expect(postCode).not.toBeCalled() }) }) @@ -35,19 +36,3 @@ describe('', () => { expect(tree).toMatchSnapshot() }) }) - -describe('', () => { - it('matches snapshot', () => { - const component = createShallowWithTheme(, 'dark') - - expect(component).toMatchSnapshot() - }) -}) - -describe('', () => { - it('matches snapshot', () => { - const component = createShallowWithTheme(, 'dark') - - expect(component).toMatchSnapshot() - }) -}) diff --git a/game_frontend/src/components/RunCodeButton/__snapshots__/index.test.js.snap b/game_frontend/src/components/RunCodeButton/__snapshots__/index.test.js.snap new file mode 100644 index 000000000..212643f06 --- /dev/null +++ b/game_frontend/src/components/RunCodeButton/__snapshots__/index.test.js.snap @@ -0,0 +1,892 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` matches snapshot 1`] = ` +.c0 { + margin-right: 8px; +} + + +`; + +exports[` matches snapshot 1`] = ` +.c0 { + margin-right: 8px; +} + + +`; + +exports[` matches snapshot 1`] = ` +.c0 { + margin-right: 8px; +} + + +`; + +exports[` becomes disabled when code is the same as the server 1`] = ` + + + +`; + +exports[` becomes enabled when code is different from the server 1`] = ` + + + +`; + +exports[` renders with a done status 1`] = ` + + + +`; + +exports[` renders with a normal status 1`] = ` + + + +`; + +exports[` renders with a updating status 1`] = ` + + + +`; + +exports[` shows an error when a timeout is detected 1`] = ` + + + +`; diff --git a/game_frontend/src/components/RunCodeButton/index.js b/game_frontend/src/components/RunCodeButton/index.js new file mode 100644 index 000000000..69d5f442c --- /dev/null +++ b/game_frontend/src/components/RunCodeButton/index.js @@ -0,0 +1,117 @@ +import styled from 'styled-components' +import React, { Component } from 'react' +import { connect } from 'react-redux' +import PropTypes from 'prop-types' +import Button from '@material-ui/core/Button' +import PlayIcon from 'components/icons/Play' +import BugIcon from 'components/icons/Bug' +import { CircularProgress } from '@material-ui/core' +import CheckCircle from 'components/icons/CheckCircle' + +export const MarginedPlayIcon = styled(PlayIcon)` + margin-right: ${props => props.theme.spacing.unit}px; +` + +export const MarginedCircularProgress = styled(CircularProgress)` + margin-right: ${props => props.theme.spacing.unit}px; +` + +export const MarginedCheckCircle = styled(CheckCircle)` + margin-right: ${props => props.theme.spacing.unit}px; +` + +export const MarginedBugIcon = styled(BugIcon)` + margin-right: ${props => props.theme.spacing.unit}px; +` + +export const RunCodeButtonStatus = Object.freeze({ + normal: 'normal', + updating: 'updating', + done: 'done' +}) + +export class RunCodeButton extends Component { + shouldButtonBeDisabled () { + if (this.props.timeoutStatus) { + return false + } else { + if (this.props.runCodeButtonStatus.status === RunCodeButtonStatus.done) { + return false + } + return !this.props.isCodeOnServerDifferent || + this.props.runCodeButtonStatus.status === RunCodeButtonStatus.updating + } + } + + shouldButtonBeClickable () { + return (!(this.shouldButtonBeDisabled() && this.props.runCodeButtonStatus === RunCodeButtonStatus.done) || + !this.props.timeoutStatus) + } + + renderContent (status) { + if (this.props.timeoutStatus) { + return ( + <> + Error + + ) + } else { + switch (status) { + case RunCodeButtonStatus.normal: + return ( + <> + Run Code + + ) + case RunCodeButtonStatus.updating: + return ( + <> + Updating + + ) + case RunCodeButtonStatus.done: + return ( + <> + Done + + ) + } + } + } + + render () { + return ( + + ) + } +} + +RunCodeButton.propTypes = { + whenClicked: PropTypes.func, + runCodeButtonStatus: PropTypes.shape({ + status: PropTypes.oneOf([ + RunCodeButtonStatus.normal, + RunCodeButtonStatus.updating, + RunCodeButtonStatus.done] + ) + }), + isCodeOnServerDifferent: PropTypes.bool, + className: PropTypes.string +} + +const mapStateToProps = state => ({ + timeoutStatus: state.game.timeoutStatus +}) + +export default connect(mapStateToProps)(RunCodeButton) diff --git a/game_frontend/src/components/RunCodeButton/index.test.js b/game_frontend/src/components/RunCodeButton/index.test.js new file mode 100644 index 000000000..6da1d4863 --- /dev/null +++ b/game_frontend/src/components/RunCodeButton/index.test.js @@ -0,0 +1,108 @@ +/* eslint-env jest */ +import React from 'react' +import RunCodeButton, { MarginedCheckCircle, MarginedCircularProgress, MarginedPlayIcon, RunCodeButtonStatus } from 'components/RunCodeButton' +import createShallowWithTheme from 'testHelpers/createShallow' + +describe('', () => { + it('renders with a normal status', () => { + const props = { + whenClicked: jest.fn(), + runCodeButtonStatus: { + status: RunCodeButtonStatus.normal + } + } + + const component = createShallowWithTheme(, 'dark') + + expect(component).toMatchSnapshot() + }) + + it('renders with a updating status', () => { + const props = { + whenClicked: jest.fn(), + runCodeButtonStatus: { + status: RunCodeButtonStatus.updating + } + } + + const component = createShallowWithTheme(, 'dark') + + expect(component).toMatchSnapshot() + }) + + it('renders with a done status', () => { + const props = { + whenClicked: jest.fn(), + runCodeButtonStatus: { + status: RunCodeButtonStatus.done + } + } + + const component = createShallowWithTheme(, 'dark') + + expect(component).toMatchSnapshot() + }) + + it('becomes enabled when code is different from the server', () => { + const props = { + whenClicked: jest.fn(), + runCodeButtonStatus: { + status: RunCodeButtonStatus.normal + }, + isCodeOnServerDifferent: true + } + + const component = createShallowWithTheme(, 'dark') + + expect(component).toMatchSnapshot() + }) + + it('becomes disabled when code is the same as the server', () => { + const props = { + whenClicked: jest.fn(), + runCodeButtonStatus: { + status: RunCodeButtonStatus.normal + }, + isCodeOnServerDifferent: false + } + + const component = createShallowWithTheme(, 'dark') + + expect(component).toMatchSnapshot() + }) + + it('shows an error when a timeout is detected', () => { + const props = { + whenClicked: jest.fn(), + timeoutStatus: true + } + + const component = createShallowWithTheme(, 'dark') + + expect(component).toMatchSnapshot() + }) +}) + +describe('', () => { + it('matches snapshot', () => { + const component = createShallowWithTheme(, 'dark') + + expect(component).toMatchSnapshot() + }) +}) + +describe('', () => { + it('matches snapshot', () => { + const component = createShallowWithTheme(, 'dark') + + expect(component).toMatchSnapshot() + }) +}) + +describe('', () => { + it('matches snapshot', () => { + const component = createShallowWithTheme(, 'dark') + + expect(component).toMatchSnapshot() + }) +}) diff --git a/game_frontend/src/containers/IDE/index.js b/game_frontend/src/containers/IDE/index.js index 428aaec45..c3cd8a7f1 100644 --- a/game_frontend/src/containers/IDE/index.js +++ b/game_frontend/src/containers/IDE/index.js @@ -9,23 +9,17 @@ import { MuiThemeProvider } from '@material-ui/core/styles' import { ThemeProvider as StyledComponentsThemeProvider } from 'styled-components' export class IDE extends Component { - static propTypes = { - code: PropTypes.string, - postCode: PropTypes.func, - getCode: PropTypes.func, - editorChanged: PropTypes.func, - logs: PropTypes.arrayOf(PropTypes.object) - } - render () { return ( @@ -35,8 +29,10 @@ export class IDE extends Component { } const mapStateToProps = state => ({ - code: state.editor.code, - logs: state.consoleLog.logs + code: state.editor.code.code, + codeOnServer: state.editor.code.codeOnServer, + logs: state.consoleLog.logs, + runCodeButtonStatus: state.editor.runCodeButton }) const mapDispatchToProps = { @@ -45,4 +41,13 @@ const mapDispatchToProps = { postCode: editorActions.postCodeRequest } +IDE.propTypes = { + code: PropTypes.string, + postCode: PropTypes.func, + getCode: PropTypes.func, + editorChanged: PropTypes.func, + logs: PropTypes.arrayOf(PropTypes.object), + runCodeButtonStatus: PropTypes.object +} + export default connect(mapStateToProps, mapDispatchToProps)(IDE) diff --git a/game_frontend/src/index.js b/game_frontend/src/index.js index 58f0f9799..e08681d6d 100644 --- a/game_frontend/src/index.js +++ b/game_frontend/src/index.js @@ -2,15 +2,16 @@ import '@babel/polyfill' import React from 'react' import { render } from 'react-dom' +import WebFont from 'webfontloader' import { MuiThemeProvider } from '@material-ui/core/styles' import { ThemeProvider as StyledComponentsThemeProvider } from 'styled-components' import { darkTheme } from 'theme' -import { Provider } from 'react-redux' -import GamePage from './components/GamePage' +import { Provider } from 'react-redux' import configureStore from './redux/store' -import WebFont from 'webfontloader' +import GamePage from './components/GamePage' +import { RunCodeButtonStatus } from 'components/RunCodeButton' WebFont.load({ typekit: { @@ -20,14 +21,20 @@ WebFont.load({ const initialState = { editor: { - code: '' + code: { + code: '' + }, + runCodeButton: { + status: RunCodeButtonStatus.normal + } }, game: { connectionParameters: { game_id: getGameIDFromURL() || 1 }, gameDataLoaded: false, - showSnackbarForAvatarUpdated: false + showSnackbarForAvatarUpdated: false, + timeoutStatus: false }, consoleLog: { logs: [] diff --git a/game_frontend/src/redux/features/Editor/epics.js b/game_frontend/src/redux/features/Editor/epics.js index 0cf77aaa2..2f990d7a1 100644 --- a/game_frontend/src/redux/features/Editor/epics.js +++ b/game_frontend/src/redux/features/Editor/epics.js @@ -27,7 +27,7 @@ const postCodeEpic = (action$, state$, { api }) => ofType(types.POST_CODE_REQUEST), api.post( `/aimmo/api/code/${state$.value.game.connectionParameters.game_id}/`, - () => ({ code: state$.value.editor.code }) + () => ({ code: state$.value.editor.code.code }) ), map(response => actions.postCodeReceived()), catchError(error => of({ diff --git a/game_frontend/src/redux/features/Editor/epics.test.js b/game_frontend/src/redux/features/Editor/epics.test.js index 651cc61b5..4acd3ef4e 100644 --- a/game_frontend/src/redux/features/Editor/epics.test.js +++ b/game_frontend/src/redux/features/Editor/epics.test.js @@ -80,7 +80,7 @@ describe('postCodeEpic', () => { } }, editor: { - code: code + code: { code } } } diff --git a/game_frontend/src/redux/features/Editor/reducers.js b/game_frontend/src/redux/features/Editor/reducers.js index 1fc28abb0..4ff5e4cfe 100644 --- a/game_frontend/src/redux/features/Editor/reducers.js +++ b/game_frontend/src/redux/features/Editor/reducers.js @@ -1,16 +1,54 @@ +import { combineReducers } from 'redux' import types from './types' +import { gameTypes } from 'features/Game' +import { RunCodeButtonStatus } from 'components/RunCodeButton' -const editorReducer = (state = {}, action) => { +const codeReducer = (state = {}, action) => { switch (action.type) { case types.GET_CODE_SUCCESS: + return { + ...state, + code: action.payload.code, + codeOnServer: action.payload.code + } case types.CHANGE_CODE: return { ...state, code: action.payload.code } + case types.POST_CODE_SUCCESS: + return { + ...state, + codeOnServer: state.code + } + default: + return state + } +} + +const runCodeButtonReducer = (state = {}, action) => { + switch (action.type) { + case types.POST_CODE_REQUEST: + return { + ...state, + status: RunCodeButtonStatus.updating + } + case gameTypes.SOCKET_FEEDBACK_AVATAR_UPDATED: + return { + ...state, + status: RunCodeButtonStatus.done + } + case gameTypes.SNACKBAR_FOR_AVATAR_FEEDBACK_SHOWN: + return { + ...state, + status: RunCodeButtonStatus.normal + } default: return state } } -export default editorReducer +export default combineReducers({ + code: codeReducer, + runCodeButton: runCodeButtonReducer +}) diff --git a/game_frontend/src/redux/features/Editor/reducers.test.js b/game_frontend/src/redux/features/Editor/reducers.test.js index 5284b8394..339a996e2 100644 --- a/game_frontend/src/redux/features/Editor/reducers.test.js +++ b/game_frontend/src/redux/features/Editor/reducers.test.js @@ -1,15 +1,25 @@ /* eslint-env jest */ import editorReducer from './reducers' import actions from './actions' +import { actions as gameActions } from 'features/Game' +import { RunCodeButtonStatus } from 'components/RunCodeButton' describe('editorReducer', () => { it('should return the initial state', () => { - expect(editorReducer(undefined, {})).toEqual({}) + const initialState = { + code: {}, + runCodeButton: {} + } + expect(editorReducer(undefined, {})).toEqual(initialState) }) it('should handle GET_CODE_SUCCESS', () => { const expectedState = { - code: 'class Avatar' + code: { + code: 'class Avatar', + codeOnServer: 'class Avatar' + }, + runCodeButton: {} } const action = actions.getCodeReceived('class Avatar') expect(editorReducer({}, action)).toEqual(expectedState) @@ -17,9 +27,50 @@ describe('editorReducer', () => { it('should handle CHANGE_CODE', () => { const expectedState = { - code: 'class Avatar' + code: { + code: 'class Avatar' + }, + runCodeButton: {} } const action = actions.changeCode('class Avatar') expect(editorReducer({}, action)).toEqual(expectedState) }) }) + +describe('runCodeButtonReducer', () => { + it('should set the button status to updating when the code is sent to the server', () => { + const expectedState = { + code: {}, + runCodeButton: { + status: RunCodeButtonStatus.updating + } + } + + const action = actions.postCodeRequest() + expect(editorReducer({}, action)).toEqual(expectedState) + }) + + it('should set the button status to done when the avatar is updated', () => { + const expectedState = { + code: {}, + runCodeButton: { + status: RunCodeButtonStatus.done + } + } + + const action = gameActions.socketFeedbackAvatarUpdated() + expect(editorReducer({}, action)).toEqual(expectedState) + }) + + it('should set the button status to normal when the snackbar has been shown', () => { + const expectedState = { + code: {}, + runCodeButton: { + status: RunCodeButtonStatus.normal + } + } + + const action = gameActions.snackbarForAvatarUpdatedShown() + expect(editorReducer({}, action)).toEqual(expectedState) + }) +}) diff --git a/game_frontend/src/redux/features/Game/actions.js b/game_frontend/src/redux/features/Game/actions.js index 221f81bda..8a2e8a9fa 100644 --- a/game_frontend/src/redux/features/Game/actions.js +++ b/game_frontend/src/redux/features/Game/actions.js @@ -81,6 +81,10 @@ const gameDataLoaded = () => ( } ) +const setTimeout = () => ({ + type: types.SET_TIMEOUT +}) + export default { socketConnectToGameRequest, sendGameStateFail, @@ -92,5 +96,6 @@ export default { unitySendAvatarIDFail, socketFeedbackAvatarUpdated, snackbarForAvatarUpdatedShown, - gameDataLoaded + gameDataLoaded, + setTimeout } diff --git a/game_frontend/src/redux/features/Game/epics.js b/game_frontend/src/redux/features/Game/epics.js index c11a0c64c..7726f9f23 100644 --- a/game_frontend/src/redux/features/Game/epics.js +++ b/game_frontend/src/redux/features/Game/epics.js @@ -1,9 +1,16 @@ import actions from './actions' import types from './types' import { of } from 'rxjs' -import { map, mergeMap, catchError, switchMap, first, mapTo } from 'rxjs/operators' +import { map, mergeMap, catchError, switchMap, first, mapTo, debounceTime } from 'rxjs/operators' import { ofType } from 'redux-observable' +const timeoutEpic = (action$) => + action$.pipe( + ofType(types.SOCKET_GAME_STATE_RECEIVED), + debounceTime(12000), + map(action => actions.setTimeout()) + ) + const getConnectionParametersEpic = (action$, state$, { api: { get } }) => action$.pipe( ofType(types.SOCKET_CONNECT_TO_GAME_REQUEST), mergeMap(action => @@ -62,5 +69,6 @@ export default { connectToGameEpic, gameLoadedEpic, sendGameStateEpic, - sendAvatarIDEpic + sendAvatarIDEpic, + timeoutEpic } diff --git a/game_frontend/src/redux/features/Game/reducers.js b/game_frontend/src/redux/features/Game/reducers.js index cc68604bc..b2f4f83ae 100644 --- a/game_frontend/src/redux/features/Game/reducers.js +++ b/game_frontend/src/redux/features/Game/reducers.js @@ -2,10 +2,16 @@ import types from './types' const gameReducer = (state = {}, action) => { switch (action.type) { + case types.SET_TIMEOUT: + return { + ...state, + timeoutStatus: true + } case types.SOCKET_GAME_STATE_RECEIVED: return { ...state, - gameState: action.payload.gameState + gameState: action.payload.gameState, + timeoutStatus: false } case types.SOCKET_FEEDBACK_AVATAR_UPDATED: return { diff --git a/game_frontend/src/redux/features/Game/reducers.test.js b/game_frontend/src/redux/features/Game/reducers.test.js index 847aa4e87..23481efc6 100644 --- a/game_frontend/src/redux/features/Game/reducers.test.js +++ b/game_frontend/src/redux/features/Game/reducers.test.js @@ -12,7 +12,8 @@ describe('gameReducer', () => { gameState: { id: 1 }, - initialState: 'someValue' + initialState: 'someValue', + timeoutStatus: false } const action = actions.socketGameStateReceived({ id: 1 }) expect(gameReducer({ initialState: 'someValue' }, action)).toEqual(expectedState) diff --git a/game_frontend/src/redux/features/Game/types.js b/game_frontend/src/redux/features/Game/types.js index ac513f783..2c6059580 100644 --- a/game_frontend/src/redux/features/Game/types.js +++ b/game_frontend/src/redux/features/Game/types.js @@ -17,6 +17,8 @@ const SNACKBAR_FOR_AVATAR_FEEDBACK_SHOWN = 'features/Game/SNACKBAR_FOR_AVATAR_FE const GAME_DATA_LOADED = 'features/Game/GAME_DATA_LOADED' +const SET_TIMEOUT = 'features/Game/TIMEOUT' + export default { SOCKET_CONNECT_TO_GAME_REQUEST, SOCKET_CONNECT_TO_GAME_FAIL, @@ -29,5 +31,6 @@ export default { UNITY_SEND_AVATAR_ID_FAIL, SOCKET_FEEDBACK_AVATAR_UPDATED, SNACKBAR_FOR_AVATAR_FEEDBACK_SHOWN, - GAME_DATA_LOADED + GAME_DATA_LOADED, + SET_TIMEOUT } diff --git a/version.txt b/version.txt index 7486fdbc5..8adc70fdd 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.7.2 +0.8.0 \ No newline at end of file