From 4d11b6bb0e90f1cd0912adcd13466ac70111ed6d Mon Sep 17 00:00:00 2001 From: Clifton Barnes Date: Sun, 14 Oct 2018 14:46:34 -0400 Subject: [PATCH 1/5] Modify change name action to call API --- src/actions/__test__/code.test.js | 24 +++++++++++++++---- src/actions/code.js | 11 +++++++-- src/reducers/__tests__/code.test.js | 37 +++++++++++++++++++++++++++-- src/reducers/code.js | 17 ++++++++++++- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/actions/__test__/code.test.js b/src/actions/__test__/code.test.js index 354824e2..dfe173fd 100644 --- a/src/actions/__test__/code.test.js +++ b/src/actions/__test__/code.test.js @@ -38,12 +38,28 @@ describe('Code actions', () => { expect(payload).toEqual(EXECUTION_RUN); }); - test('changeName', () => { - const action = changeName('test name'); - const { type, payload } = action; + test('changeName', async () => { + const mock = new MockAdapter(axios); + const program = { + id: 1, + name: 'test name', + content: '', + user: 1, + }; + const name = { + name: 'test name', + }; + + mock.onPatch('/api/v1/block-diagrams/1/', name).reply(200, program); + + const action = changeName(1, 'test name'); + const { type } = action; + const payload = await action.payload; expect(type).toEqual('CHANGE_NAME'); - expect(payload).toEqual('test name'); + expect(payload).toEqual(program); + + mock.restore(); }); test('changeId', () => { diff --git a/src/actions/code.js b/src/actions/code.js index df3fea9f..4bc65d8e 100644 --- a/src/actions/code.js +++ b/src/actions/code.js @@ -17,6 +17,8 @@ export const UPDATE_JSCODE = 'UPDATE_JSCODE'; export const UPDATE_XMLCODE = 'UPDATE_XMLCODE'; export const CHANGE_EXECUTION_STATE = 'CHANGE_EXECUTION_STATE'; export const CHANGE_NAME = 'CHANGE_NAME'; +export const CHANGE_NAME_FULFILLED = `${CHANGE_NAME}_FULFILLED`; +export const CHANGE_NAME_REJECTED = `${CHANGE_NAME}_REJECTED`; export const CHANGE_ID = 'CHANGE_ID'; // Execution States @@ -41,9 +43,14 @@ export const changeExecutionState = state => ({ payload: state, }); -export const changeName = name => ({ +export const changeName = (id, name, xhroptions) => ({ type: CHANGE_NAME, - payload: name, + payload: axios.patch(`/api/v1/block-diagrams/${id}/`, { + name, + }, xhroptions) + .then(({ data }) => ( + data + )), }); export const changeId = id => ({ diff --git a/src/reducers/__tests__/code.test.js b/src/reducers/__tests__/code.test.js index 94402922..220360fa 100644 --- a/src/reducers/__tests__/code.test.js +++ b/src/reducers/__tests__/code.test.js @@ -5,6 +5,8 @@ import { UPDATE_JSCODE, UPDATE_XMLCODE, CHANGE_NAME, + CHANGE_NAME_FULFILLED, + CHANGE_NAME_REJECTED, CHANGE_ID, FETCH_PROGRAM, FETCH_PROGRAM_FULFILLED, @@ -55,10 +57,41 @@ describe('The code reducer', () => { expect( reducer({}, { type: CHANGE_NAME, - payload: 'test name', }), ).toEqual({ - name: 'test name', + isChangingName: true, + }); + }); + + test('should handle CHANGE_NAME_FULFILLED', () => { + const name = 'mybd'; + + expect( + reducer({}, { + type: CHANGE_NAME_FULFILLED, + payload: { + name, + }, + }), + ).toEqual({ + isChangingName: false, + name, + }); + }); + + test('should handle CHANGE_NAME_REJECTED', () => { + const detail = 'Authentication credentials were not provided.'; + + expect( + reducer({}, { + type: CHANGE_NAME_REJECTED, + payload: { + detail, + }, + }), + ).toEqual({ + isChangingName: false, + error: { detail }, }); }); diff --git a/src/reducers/code.js b/src/reducers/code.js index e7510c12..ce52b321 100644 --- a/src/reducers/code.js +++ b/src/reducers/code.js @@ -3,6 +3,8 @@ import { UPDATE_JSCODE, UPDATE_XMLCODE, CHANGE_NAME, + CHANGE_NAME_FULFILLED, + CHANGE_NAME_REJECTED, CHANGE_ID, FETCH_PROGRAM, FETCH_PROGRAM_FULFILLED, @@ -25,6 +27,7 @@ export default function code( isFetching: false, isSaving: false, isCreating: false, + isChangingName: false, error: null, }, action, @@ -48,7 +51,19 @@ export default function code( case CHANGE_NAME: return { ...state, - name: action.payload, + isChangingName: true, + }; + case CHANGE_NAME_FULFILLED: + return { + ...state, + isChangingName: false, + name: action.payload.name, + }; + case CHANGE_NAME_REJECTED: + return { + ...state, + isChangingName: false, + error: action.payload, }; case CHANGE_ID: return { From d4196d6c4c77d454ebe247d2f2ab364130e71540 Mon Sep 17 00:00:00 2001 From: Clifton Barnes Date: Sun, 14 Oct 2018 14:46:53 -0400 Subject: [PATCH 2/5] Create program name component to display and change program name --- src/components/ProgramName.js | 82 +++++++++++ src/components/__tests__/ProgramName.test.js | 58 ++++++++ .../__snapshots__/ProgramName.test.js.snap | 139 ++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 src/components/ProgramName.js create mode 100644 src/components/__tests__/ProgramName.test.js create mode 100644 src/components/__tests__/__snapshots__/ProgramName.test.js.snap diff --git a/src/components/ProgramName.js b/src/components/ProgramName.js new file mode 100644 index 00000000..3f5bb8ff --- /dev/null +++ b/src/components/ProgramName.js @@ -0,0 +1,82 @@ +import React, { Component } from 'react'; +import { Input } from 'semantic-ui-react'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; +import { withCookies } from 'react-cookie'; +import PropTypes from 'prop-types'; + +import { changeName as actionChangeName } from '@/actions/code'; + +const mapStateToProps = ({ code }) => ({ code }); +const mapDispatchToProps = (dispatch, { cookies }) => ({ + changeName: (id, name) => { + const changeNameAction = actionChangeName(id, name, { + headers: { + Authorization: `JWT ${cookies.get('auth_jwt')}`, + }, + }); + return dispatch(changeNameAction); + }, +}); + +class Console extends Component { + constructor(props) { + super(props); + + this.state = { + editingName: null, + previousPropName: null, // eslint-disable-line react/no-unused-state + }; + } + + static getDerivedStateFromProps(props, state) { + const { code } = props; + const { previousPropName } = state; + + if (code.name !== previousPropName) { + return { + editingName: code.name, + previousPropName: code.name, + }; + } + + return null; + } + + handleChange = (e) => { + this.setState({ + editingName: e.target.value, + }); + } + + handleClick = () => { + const { changeName, code } = this.props; + const { editingName } = this.state; + + changeName(code.id, editingName); + } + + render() { + const { editingName } = this.state; + + return ( + + ); + } +} + +Console.propTypes = { + code: PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string, + }).isRequired, + changeName: PropTypes.func.isRequired, +}; + +export default hot(module)(withCookies(connect(mapStateToProps, mapDispatchToProps)(Console))); diff --git a/src/components/__tests__/ProgramName.test.js b/src/components/__tests__/ProgramName.test.js new file mode 100644 index 00000000..7722ada5 --- /dev/null +++ b/src/components/__tests__/ProgramName.test.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { Input } from 'semantic-ui-react'; +import { mount, shallow } from 'enzyme'; +import toJson from 'enzyme-to-json'; +import configureStore from 'redux-mock-store'; +import { Cookies } from 'react-cookie'; + +import { changeName } from '@/actions/code'; +import ProgramName from '../ProgramName'; + +const cookiesValues = { auth_jwt: '1234' }; +const cookies = new Cookies(cookiesValues); + +describe('The Console component', () => { + const mockStore = configureStore(); + const context = { cookies }; + let store; + + beforeEach(() => { + store = mockStore({ + code: { + name: 'test name', + }, + }); + store.dispatch = jest.fn(); + }); + + test('renders on the page with no errors', () => { + const wrapper = mount(, { context }); + expect(toJson(wrapper)).toMatchSnapshot(); + }); + + test('displays name', () => { + const wrapper = shallow(, { context }).dive().dive(); + + expect(wrapper.find(Input).length).toBe(1); + expect(wrapper.find(Input).props().defaultValue).toBe('test name'); + }); + + test('handles change', () => { + const wrapper = shallow(, { context }).dive().dive(); + + wrapper.find(Input).simulate('change', { target: { value: 'new name' } }); + wrapper.update(); + + expect(wrapper.find(Input).props().defaultValue).toBe('new name'); + }); + + test('handles save', () => { + const wrapper = shallow(, { context }).dive().dive(); + + wrapper.find(Input).props().action.onClick(); + wrapper.update(); + + expect(store.dispatch).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(changeName('test name')); + }); +}); diff --git a/src/components/__tests__/__snapshots__/ProgramName.test.js.snap b/src/components/__tests__/__snapshots__/ProgramName.test.js.snap new file mode 100644 index 00000000..406b668e --- /dev/null +++ b/src/components/__tests__/__snapshots__/ProgramName.test.js.snap @@ -0,0 +1,139 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`The Console component renders on the page with no errors 1`] = ` + + + + +
+ + + + +
+ +
+
+
+`; From ada18a5b92fd6337f073da3906958612a2d527a7 Mon Sep 17 00:00:00 2001 From: Clifton Barnes Date: Sun, 14 Oct 2018 14:47:19 -0400 Subject: [PATCH 3/5] Add program name component to mission control --- src/containers/MissionControl.js | 5 +++++ src/containers/__tests__/MissionControl.test.js | 1 + .../__snapshots__/MissionControl.test.js.snap | 10 ++++++++++ 3 files changed, 16 insertions(+) diff --git a/src/containers/MissionControl.js b/src/containers/MissionControl.js index e1d21fab..2ab87590 100644 --- a/src/containers/MissionControl.js +++ b/src/containers/MissionControl.js @@ -7,6 +7,7 @@ import CodeViewer from '@/components/CodeViewer'; import Console from '@/components/Console'; import Control from '@/components/Control'; import Indicator from '@/components/Indicator'; +import ProgramName from '@/components/ProgramName'; import Workspace from '@/components/Workspace'; const MissionControl = () => ( @@ -18,6 +19,10 @@ const MissionControl = () => ( + + + +
Show Me The Code! diff --git a/src/containers/__tests__/MissionControl.test.js b/src/containers/__tests__/MissionControl.test.js index 081c2af8..d958d226 100644 --- a/src/containers/__tests__/MissionControl.test.js +++ b/src/containers/__tests__/MissionControl.test.js @@ -11,6 +11,7 @@ jest.mock('@/components/CodeViewer', () => () =>
); jest.mock('@/components/Console', () => () =>
); jest.mock('@/components/Control', () => () =>
); jest.mock('@/components/Indicator', () => () =>
); +jest.mock('@/components/ProgramName', () => () =>
); jest.mock('@/components/Workspace', () => () =>
); const cookiesValues = { auth_jwt: '1234' }; diff --git a/src/containers/__tests__/__snapshots__/MissionControl.test.js.snap b/src/containers/__tests__/__snapshots__/MissionControl.test.js.snap index 5f1fb7e9..659ab1cc 100644 --- a/src/containers/__tests__/__snapshots__/MissionControl.test.js.snap +++ b/src/containers/__tests__/__snapshots__/MissionControl.test.js.snap @@ -67,6 +67,16 @@ exports[`The MissionControl container renders on the page with no errors 1`] = `

+ +
+ +
+ +
+ +
Date: Sun, 14 Oct 2018 15:14:52 -0400 Subject: [PATCH 4/5] Add confirmation dialog for saving new name --- src/components/ProgramName.js | 36 +++++++++++++------ src/components/__tests__/ProgramName.test.js | 31 ++++++++++++++-- .../__snapshots__/ProgramName.test.js.snap | 35 ++++++++++++++++++ 3 files changed, 89 insertions(+), 13 deletions(-) diff --git a/src/components/ProgramName.js b/src/components/ProgramName.js index 3f5bb8ff..a7fb0a76 100644 --- a/src/components/ProgramName.js +++ b/src/components/ProgramName.js @@ -1,5 +1,5 @@ -import React, { Component } from 'react'; -import { Input } from 'semantic-ui-react'; +import React, { Component, Fragment } from 'react'; +import { Confirm, Input } from 'semantic-ui-react'; import { connect } from 'react-redux'; import { hot } from 'react-hot-loader'; import { withCookies } from 'react-cookie'; @@ -24,6 +24,7 @@ class Console extends Component { super(props); this.state = { + confirmOpen: false, editingName: null, previousPropName: null, // eslint-disable-line react/no-unused-state }; @@ -43,30 +44,43 @@ class Console extends Component { return null; } + closeConfirm = () => this.setState({ confirmOpen: false }) + + openConfirm = () => this.setState({ confirmOpen: true }) + handleChange = (e) => { this.setState({ editingName: e.target.value, }); } - handleClick = () => { + handleSave = () => { const { changeName, code } = this.props; const { editingName } = this.state; changeName(code.id, editingName); + this.closeConfirm(); } render() { - const { editingName } = this.state; + const { confirmOpen, editingName } = this.state; return ( - + + + + ); } } diff --git a/src/components/__tests__/ProgramName.test.js b/src/components/__tests__/ProgramName.test.js index 7722ada5..abd1e81f 100644 --- a/src/components/__tests__/ProgramName.test.js +++ b/src/components/__tests__/ProgramName.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { Input } from 'semantic-ui-react'; +import { Confirm, Input } from 'semantic-ui-react'; import { mount, shallow } from 'enzyme'; import toJson from 'enzyme-to-json'; import configureStore from 'redux-mock-store'; @@ -33,6 +33,7 @@ describe('The Console component', () => { test('displays name', () => { const wrapper = shallow(, { context }).dive().dive(); + expect(wrapper.find(Confirm).prop('open')).toBe(false); expect(wrapper.find(Input).length).toBe(1); expect(wrapper.find(Input).props().defaultValue).toBe('test name'); }); @@ -43,15 +44,41 @@ describe('The Console component', () => { wrapper.find(Input).simulate('change', { target: { value: 'new name' } }); wrapper.update(); + expect(wrapper.find(Confirm).prop('open')).toBe(false); expect(wrapper.find(Input).props().defaultValue).toBe('new name'); }); - test('handles save', () => { + test('handles save cancel', () => { const wrapper = shallow(, { context }).dive().dive(); + // User opens save confirmation modal wrapper.find(Input).props().action.onClick(); wrapper.update(); + expect(wrapper.find(Confirm).prop('open')).toBe(true); + + // User clicks 'cancel' + wrapper.find(Confirm).prop('onCancel')(); + wrapper.update(); + + expect(wrapper.find(Confirm).prop('open')).toBe(false); + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + test('handles save confirm', () => { + const wrapper = shallow(, { context }).dive().dive(); + + // User opens save confirmation modal + wrapper.find(Input).props().action.onClick(); + wrapper.update(); + + expect(wrapper.find(Confirm).prop('open')).toBe(true); + + // User clicks 'OK' + wrapper.find(Confirm).prop('onConfirm')(); + wrapper.update(); + + expect(wrapper.find(Confirm).prop('open')).toBe(false); expect(store.dispatch).toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith(changeName('test name')); }); diff --git a/src/components/__tests__/__snapshots__/ProgramName.test.js.snap b/src/components/__tests__/__snapshots__/ProgramName.test.js.snap index 406b668e..f804781c 100644 --- a/src/components/__tests__/__snapshots__/ProgramName.test.js.snap +++ b/src/components/__tests__/__snapshots__/ProgramName.test.js.snap @@ -133,6 +133,41 @@ exports[`The Console component renders on the page with no errors 1`] = `
+ + + } + onClose={[Function]} + onMount={[Function]} + onOpen={[Function]} + onUnmount={[Function]} + open={false} + openOnTriggerClick={true} + /> + + From 3152cda8093770b18a8b555aee3139e9ff2d30c2 Mon Sep 17 00:00:00 2001 From: Clifton Barnes Date: Mon, 15 Oct 2018 23:04:25 -0400 Subject: [PATCH 5/5] Only show 'Save' when the name has changed --- src/components/ProgramName.js | 15 +++++++++++-- src/components/__tests__/ProgramName.test.js | 7 ++++++ .../__snapshots__/ProgramName.test.js.snap | 22 +------------------ 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/components/ProgramName.js b/src/components/ProgramName.js index a7fb0a76..ea3e80b9 100644 --- a/src/components/ProgramName.js +++ b/src/components/ProgramName.js @@ -63,7 +63,18 @@ class Console extends Component { } render() { - const { confirmOpen, editingName } = this.state; + const { confirmOpen, editingName, previousPropName } = this.state; + let actionProp = {}; + + // Only show `Save` when the name has changed + if (editingName !== previousPropName) { + actionProp = { + action: { + content: 'Save', + onClick: this.openConfirm, + }, + }; + } return ( @@ -72,7 +83,7 @@ class Console extends Component { label="Name:" defaultValue={editingName} onChange={this.handleChange} - action={{ content: 'Save', onClick: this.openConfirm }} + {...actionProp} /> { expect(wrapper.find(Confirm).prop('open')).toBe(false); expect(wrapper.find(Input).props().defaultValue).toBe('new name'); + expect(wrapper.find(Input).props().action).toBeDefined(); }); test('handles save cancel', () => { const wrapper = shallow(, { context }).dive().dive(); + wrapper.find(Input).simulate('change', { target: { value: 'new name' } }); + wrapper.update(); + // User opens save confirmation modal wrapper.find(Input).props().action.onClick(); wrapper.update(); @@ -68,6 +72,9 @@ describe('The Console component', () => { test('handles save confirm', () => { const wrapper = shallow(, { context }).dive().dive(); + wrapper.find(Input).simulate('change', { target: { value: 'new name' } }); + wrapper.update(); + // User opens save confirmation modal wrapper.find(Input).props().action.onClick(); wrapper.update(); diff --git a/src/components/__tests__/__snapshots__/ProgramName.test.js.snap b/src/components/__tests__/__snapshots__/ProgramName.test.js.snap index f804781c..01bed67c 100644 --- a/src/components/__tests__/__snapshots__/ProgramName.test.js.snap +++ b/src/components/__tests__/__snapshots__/ProgramName.test.js.snap @@ -87,19 +87,13 @@ exports[`The Console component renders on the page with no errors 1`] = ` } >