From 27d3f45c2aaf609a28fe6383744b71f7332d85ff Mon Sep 17 00:00:00 2001 From: saseungmin Date: Sun, 29 Nov 2020 03:03:46 +0900 Subject: [PATCH 1/3] [Update] npm install immer --- package-lock.json | 13 ++++++++++--- package.json | 7 ++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index f220b1e..25f7e56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2159,6 +2159,13 @@ "redux": "^4.0.0", "redux-thunk": "^2.3.0", "reselect": "^4.0.0" + }, + "dependencies": { + "immer": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-7.0.15.tgz", + "integrity": "sha512-yM7jo9+hvYgvdCQdqvhCNRRio0SCXc8xDPzA25SvKWa7b1WVPjLwQs1VYU5JPXjcJPTqAa5NP5dqpORGYBQ2AA==" + } } }, "@sideway/address": { @@ -7478,9 +7485,9 @@ "dev": true }, "immer": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/immer/-/immer-7.0.14.tgz", - "integrity": "sha512-BxCs6pJwhgSEUEOZjywW7OA8DXVzfHjkBelSEl0A+nEu0+zS4cFVdNOONvt55N4WOm8Pu4xqSPYxhm1Lv2iBBA==" + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-8.0.0.tgz", + "integrity": "sha512-jm87NNBAIG4fHwouilCHIecFXp5rMGkiFrAuhVO685UnMAlOneEAnOyzPt8OnP47TC11q/E7vpzZe0WvwepFTg==" }, "immutable": { "version": "3.7.6", diff --git a/package.json b/package.json index 276c49d..ef00585 100644 --- a/package.json +++ b/package.json @@ -31,18 +31,19 @@ "draft-js": "^0.11.7", "draftjs-to-html": "^0.9.1", "firebase": "^8.1.1", + "immer": "^8.0.0", "moment": "^2.29.1", "moment-timezone": "^0.5.32", "qs": "^6.9.4", "react": "^17.0.1", - "redux-devtools-extension": "^2.13.8", "react-dom": "^17.0.1", "react-draft-wysiwyg": "^1.14.5", - "redux-logger": "^3.0.6", "react-moment": "^1.0.0", "react-redux": "^7.2.2", "react-router-dom": "^5.2.0", - "redux": "^4.0.5" + "redux": "^4.0.5", + "redux-devtools-extension": "^2.13.8", + "redux-logger": "^3.0.6" }, "devDependencies": { "@babel/core": "^7.12.3", From e5ef5e93a224826de210a4378fe7524e85bd55f1 Mon Sep 17 00:00:00 2001 From: saseungmin Date: Sun, 29 Nov 2020 03:04:58 +0900 Subject: [PATCH 2/3] Settings eslint error for immer draft - Add rules no-param-reassign --- .eslintrc.js | 1 + 1 file changed, 1 insertion(+) diff --git a/.eslintrc.js b/.eslintrc.js index e225af8..65a726b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -52,5 +52,6 @@ module.exports = { 'react/prop-types': 'off', 'linebreak-style': 'off', 'no-proto': 'off', + 'no-param-reassign': ['error', { props: true, ignorePropertyModificationsFor: ['draft'] }], }, }; From 2d48db61918d9a148b5446761cdde6c966d1fd55 Mon Sep 17 00:00:00 2001 From: saseungmin Date: Sun, 29 Nov 2020 03:06:38 +0900 Subject: [PATCH 3/3] [Feature] Change form action setting of login page and register page - onChange dispatch actions for redux settings --- src/App.test.jsx | 13 +++- src/components/auth/AuthForm.jsx | 60 +++++++++++++--- src/components/auth/AuthForm.test.jsx | 72 +++++++++++++++++-- src/containers/auth/LoginFormContainer.jsx | 34 +++++++++ .../auth/LoginFormContainer.test.jsx | 63 ++++++++++++++++ src/containers/auth/RegisterFormContainer.jsx | 34 +++++++++ .../auth/RegisterFormContainer.test.jsx | 66 +++++++++++++++++ src/pages/LoginPage.jsx | 4 +- src/pages/LoginPage.test.jsx | 19 ++++- src/pages/RegisterPage.jsx | 4 +- src/pages/RegisterPage.test.jsx | 20 +++++- src/reducers/slice.js | 28 ++++++++ src/reducers/slice.test.js | 54 ++++++++++++++ 13 files changed, 445 insertions(+), 26 deletions(-) create mode 100644 src/containers/auth/LoginFormContainer.jsx create mode 100644 src/containers/auth/LoginFormContainer.test.jsx create mode 100644 src/containers/auth/RegisterFormContainer.jsx create mode 100644 src/containers/auth/RegisterFormContainer.test.jsx diff --git a/src/App.test.jsx b/src/App.test.jsx index 7a1393d..d75e939 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -27,6 +27,15 @@ describe('App', () => { writeField: { tags: [], }, + register: { + userEmail: '', + password: '', + passwordConfirm: '', + }, + login: { + userEmail: '', + password: '', + }, })); }); @@ -67,7 +76,7 @@ describe('App', () => { it('renders the study login page', () => { const { container } = renderApp({ path: '/login' }); - expect(container).toHaveTextContent('Login'); + expect(container).toHaveTextContent('로그인'); }); }); @@ -75,7 +84,7 @@ describe('App', () => { it('renders the study register page', () => { const { container } = renderApp({ path: '/register' }); - expect(container).toHaveTextContent('Register'); + expect(container).toHaveTextContent('회원가입'); }); }); }); diff --git a/src/components/auth/AuthForm.jsx b/src/components/auth/AuthForm.jsx index 66679ef..cb0c1a8 100644 --- a/src/components/auth/AuthForm.jsx +++ b/src/components/auth/AuthForm.jsx @@ -1,20 +1,58 @@ import React from 'react'; import styled from '@emotion/styled'; + import Responsive from '../../styles/Responsive'; const AuthFormWrapper = styled(Responsive)``; -const AuthForm = ({ type }) => ( - -

{type}

- - - {type === 'Register' && ( - - )} - -
-); +const FORM_TYPE = { + login: '로그인', + register: '회원가입', +}; + +const AuthForm = ({ type, onChange, fields }) => { + const formType = FORM_TYPE[type]; + + const { userEmail, password } = fields; + + const handleChange = (e) => { + const { name, value } = e.target; + + onChange({ name, value }); + }; + + return ( + +

{formType}

+ + + {type === 'register' && ( + + )} +
+ ); +}; export default AuthForm; diff --git a/src/components/auth/AuthForm.test.jsx b/src/components/auth/AuthForm.test.jsx index fdf6256..48b0665 100644 --- a/src/components/auth/AuthForm.test.jsx +++ b/src/components/auth/AuthForm.test.jsx @@ -1,40 +1,98 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; import AuthForm from './AuthForm'; describe('AuthForm', () => { - const renderAuthForm = ({ type }) => render(( - + const handleChange = jest.fn(); + + beforeEach(() => { + handleChange.mockClear(); + }); + + const renderAuthForm = ({ type, fields }) => render(( + )); context('when type is login', () => { const login = { - type: 'Login', + type: 'login', + fields: { + userEmail: 'tktmdals@naver.com', + password: '1234', + }, }; it('renders login form text', () => { const { container, getByPlaceholderText } = renderAuthForm(login); - expect(container).toHaveTextContent('Login'); + expect(container).toHaveTextContent('로그인'); expect(getByPlaceholderText('이메일')).not.toBeNull(); expect(getByPlaceholderText('비밀번호')).not.toBeNull(); }); + + it('listens event call change', () => { + const { getByPlaceholderText } = renderAuthForm(login); + + const inputs = [ + { value: 'seungmin@naver.com', name: 'userEmail', placeholder: '이메일' }, + { value: '345', name: 'password', placeholder: '비밀번호' }, + ]; + + inputs.forEach(({ name, value, placeholder }) => { + const field = getByPlaceholderText(placeholder); + + expect(field).not.toBeNull(); + + fireEvent.change(field, { target: { value, name } }); + + expect(handleChange).toBeCalled(); + }); + }); }); context('when type is register', () => { const register = { - type: 'Register', + type: 'register', + fields: { + userEmail: 'tktmdals@naver.com', + password: '1234', + passwordConfirm: '1234', + }, }; it('renders register form text', () => { const { container, getByPlaceholderText } = renderAuthForm(register); - expect(container).toHaveTextContent('Register'); + expect(container).toHaveTextContent('회원가입'); expect(getByPlaceholderText('이메일')).not.toBeNull(); expect(getByPlaceholderText('비밀번호')).not.toBeNull(); expect(getByPlaceholderText('비밀번호 확인')).not.toBeNull(); }); + + it('listens event call change', () => { + const { getByPlaceholderText } = renderAuthForm(register); + + const inputs = [ + { value: 'seungmin@naver.com', name: 'userEmail', placeholder: '이메일' }, + { value: '345', name: 'password', placeholder: '비밀번호' }, + { value: '345', name: 'passwordConfirm', placeholder: '비밀번호 확인' }, + ]; + + inputs.forEach(({ name, value, placeholder }) => { + const field = getByPlaceholderText(placeholder); + + expect(field).not.toBeNull(); + + fireEvent.change(field, { target: { value, name } }); + + expect(handleChange).toBeCalled(); + }); + }); }); }); diff --git a/src/containers/auth/LoginFormContainer.jsx b/src/containers/auth/LoginFormContainer.jsx new file mode 100644 index 0000000..3d450e0 --- /dev/null +++ b/src/containers/auth/LoginFormContainer.jsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react'; + +import { useSelector, useDispatch } from 'react-redux'; + +import { get } from '../../util/utils'; +import { changeAuthField } from '../../reducers/slice'; + +import AuthForm from '../../components/auth/AuthForm'; + +const LoginFormContainer = () => { + const dispatch = useDispatch(); + + const login = useSelector(get('login')); + + const onChangeLoginField = useCallback(({ name, value }) => { + dispatch( + changeAuthField({ + form: 'login', + name, + value, + }), + ); + }); + + return ( + + ); +}; + +export default LoginFormContainer; diff --git a/src/containers/auth/LoginFormContainer.test.jsx b/src/containers/auth/LoginFormContainer.test.jsx new file mode 100644 index 0000000..08a3495 --- /dev/null +++ b/src/containers/auth/LoginFormContainer.test.jsx @@ -0,0 +1,63 @@ +import React from 'react'; + +import { useDispatch, useSelector } from 'react-redux'; + +import { render, fireEvent } from '@testing-library/react'; + +import LoginFormContainer from './LoginFormContainer'; + +describe('LoginFormContainer', () => { + const dispatch = jest.fn(); + + beforeEach(() => { + dispatch.mockClear(); + useDispatch.mockImplementation(() => dispatch); + + useSelector.mockImplementation((selector) => selector({ + login: { + userEmail: '', + password: '', + }, + })); + }); + + const renderLoginFormContainer = () => render(( + + )); + + it('renders login form text', () => { + const { container, getByPlaceholderText } = renderLoginFormContainer(); + + expect(container).toHaveTextContent('로그인'); + expect(getByPlaceholderText('이메일')).not.toBeNull(); + expect(getByPlaceholderText('비밀번호')).not.toBeNull(); + }); + + describe('action dispatch in login page', () => { + it('change event calls dispatch', () => { + const { getByPlaceholderText } = renderLoginFormContainer(); + + const inputs = [ + { value: 'seungmin@naver.com', name: 'userEmail', placeholder: '이메일' }, + { value: '345', name: 'password', placeholder: '비밀번호' }, + ]; + + inputs.forEach(({ name, value, placeholder }) => { + const field = getByPlaceholderText(placeholder); + + expect(field).not.toBeNull(); + + fireEvent.change(field, { target: { value, name } }); + + expect(dispatch).toBeCalledWith({ + type: 'application/changeAuthField', + payload: { + form: 'login', + name, + value, + }, + }); + }); + }); + }); +}); diff --git a/src/containers/auth/RegisterFormContainer.jsx b/src/containers/auth/RegisterFormContainer.jsx new file mode 100644 index 0000000..ecdbc9c --- /dev/null +++ b/src/containers/auth/RegisterFormContainer.jsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react'; + +import { useSelector, useDispatch } from 'react-redux'; + +import { get } from '../../util/utils'; +import { changeAuthField } from '../../reducers/slice'; + +import AuthForm from '../../components/auth/AuthForm'; + +const RegisterFormContainer = () => { + const dispatch = useDispatch(); + + const register = useSelector(get('register')); + + const onChangeRegisterField = useCallback(({ name, value }) => { + dispatch( + changeAuthField({ + form: 'register', + name, + value, + }), + ); + }); + + return ( + + ); +}; + +export default RegisterFormContainer; diff --git a/src/containers/auth/RegisterFormContainer.test.jsx b/src/containers/auth/RegisterFormContainer.test.jsx new file mode 100644 index 0000000..09bfc35 --- /dev/null +++ b/src/containers/auth/RegisterFormContainer.test.jsx @@ -0,0 +1,66 @@ +import React from 'react'; + +import { useDispatch, useSelector } from 'react-redux'; + +import { render, fireEvent } from '@testing-library/react'; + +import RegisterFormContainer from './RegisterFormContainer'; + +describe('RegisterFormContainer', () => { + const dispatch = jest.fn(); + + beforeEach(() => { + dispatch.mockClear(); + useDispatch.mockImplementation(() => dispatch); + + useSelector.mockImplementation((selector) => selector({ + register: { + userEmail: '', + password: '', + passwordConfirm: '', + }, + })); + }); + + const renderRegisterFormContainer = () => render(( + + )); + + it('renders register form text', () => { + const { container, getByPlaceholderText } = renderRegisterFormContainer(); + + expect(container).toHaveTextContent('회원가입'); + expect(getByPlaceholderText('이메일')).not.toBeNull(); + expect(getByPlaceholderText('비밀번호')).not.toBeNull(); + expect(getByPlaceholderText('비밀번호 확인')).not.toBeNull(); + }); + + describe('action dispatch in register page', () => { + it('change event calls dispatch', () => { + const { getByPlaceholderText } = renderRegisterFormContainer(); + + const inputs = [ + { value: 'seungmin@naver.com', name: 'userEmail', placeholder: '이메일' }, + { value: '345', name: 'password', placeholder: '비밀번호' }, + { value: '345', name: 'passwordConfirm', placeholder: '비밀번호 확인' }, + ]; + + inputs.forEach(({ name, value, placeholder }) => { + const field = getByPlaceholderText(placeholder); + + expect(field).not.toBeNull(); + + fireEvent.change(field, { target: { value, name } }); + + expect(dispatch).toBeCalledWith({ + type: 'application/changeAuthField', + payload: { + form: 'register', + name, + value, + }, + }); + }); + }); + }); +}); diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx index 2e55beb..913f426 100644 --- a/src/pages/LoginPage.jsx +++ b/src/pages/LoginPage.jsx @@ -1,9 +1,9 @@ import React from 'react'; -import AuthForm from '../components/auth/AuthForm'; +import LoginFormContainer from '../containers/auth/LoginFormContainer'; const LoginPage = () => ( - + ); export default LoginPage; diff --git a/src/pages/LoginPage.test.jsx b/src/pages/LoginPage.test.jsx index a5de1f8..17e4f76 100644 --- a/src/pages/LoginPage.test.jsx +++ b/src/pages/LoginPage.test.jsx @@ -1,10 +1,27 @@ import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + import { render } from '@testing-library/react'; import LoginPage from './LoginPage'; describe('LoginPage', () => { + const dispatch = jest.fn(); + + beforeEach(() => { + dispatch.mockClear(); + + useDispatch.mockImplementation(() => dispatch); + + useSelector.mockImplementation((selector) => selector({ + login: { + userEmail: '', + password: '', + }, + })); + }); + const renderLoginPage = () => render(( )); @@ -13,7 +30,7 @@ describe('LoginPage', () => { it('renders Login page Title', () => { const { container } = renderLoginPage(); - expect(container).toHaveTextContent('Login'); + expect(container).toHaveTextContent('로그인'); }); }); }); diff --git a/src/pages/RegisterPage.jsx b/src/pages/RegisterPage.jsx index ab98c1b..e2a582a 100644 --- a/src/pages/RegisterPage.jsx +++ b/src/pages/RegisterPage.jsx @@ -1,9 +1,9 @@ import React from 'react'; -import AuthForm from '../components/auth/AuthForm'; +import RegisterFormContainer from '../containers/auth/RegisterFormContainer'; const RegisterPage = () => ( - + ); export default RegisterPage; diff --git a/src/pages/RegisterPage.test.jsx b/src/pages/RegisterPage.test.jsx index 98cb5b2..ecd6e85 100644 --- a/src/pages/RegisterPage.test.jsx +++ b/src/pages/RegisterPage.test.jsx @@ -1,10 +1,28 @@ import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + import { render } from '@testing-library/react'; import RegisterPage from './RegisterPage'; describe('RegisterPage', () => { + const dispatch = jest.fn(); + + beforeEach(() => { + dispatch.mockClear(); + + useDispatch.mockImplementation(() => dispatch); + + useSelector.mockImplementation((selector) => selector({ + register: { + userEmail: '', + password: '', + passwordConfirm: '', + }, + })); + }); + const renderRegisterPage = () => render(( )); @@ -13,7 +31,7 @@ describe('RegisterPage', () => { it('renders Register page Title', () => { const { container } = renderRegisterPage(); - expect(container).toHaveTextContent('Register'); + expect(container).toHaveTextContent('회원가입'); }); }); }); diff --git a/src/reducers/slice.js b/src/reducers/slice.js index 9c66090..dcb2ae4 100644 --- a/src/reducers/slice.js +++ b/src/reducers/slice.js @@ -1,5 +1,7 @@ import { createSlice } from '@reduxjs/toolkit'; +import produce from 'immer'; + import { getStudyGroup, getStudyGroups, @@ -16,6 +18,18 @@ const writeInitialState = { tags: [], }; +const authInitialState = { + register: { + userEmail: '', + password: '', + passwordConfirm: '', + }, + login: { + userEmail: '', + password: '', + }, +}; + const { actions, reducer } = createSlice({ name: 'application', initialState: { @@ -23,7 +37,10 @@ const { actions, reducer } = createSlice({ group: null, groupId: null, writeField: writeInitialState, + register: authInitialState.register, + login: authInitialState.login, }, + reducers: { setStudyGroups(state, { payload: { groups, tag } }) { return { @@ -37,12 +54,14 @@ const { actions, reducer } = createSlice({ }, []) : groups, }; }, + setStudyGroup(state, { payload: group }) { return { ...state, group, }; }, + changeWriteField(state, { payload: { name, value } }) { const { writeField } = state; @@ -54,6 +73,7 @@ const { actions, reducer } = createSlice({ }, }; }, + clearWriteFields(state) { return { ...state, @@ -62,12 +82,19 @@ const { actions, reducer } = createSlice({ }, }; }, + successWrite(state, { payload: groupId }) { return { ...state, groupId, }; }, + + changeAuthField(state, { payload: { form, name, value } }) { + return produce(state, (draft) => { + draft[form][name] = value; + }); + }, }, }); @@ -77,6 +104,7 @@ export const { changeWriteField, clearWriteFields, successWrite, + changeAuthField, } = actions; export const loadStudyGroups = (tag) => async (dispatch) => { diff --git a/src/reducers/slice.test.js b/src/reducers/slice.test.js index 539dd84..158aa62 100644 --- a/src/reducers/slice.test.js +++ b/src/reducers/slice.test.js @@ -12,6 +12,7 @@ import reducer, { writeStudyGroup, clearWriteFields, successWrite, + changeAuthField, } from './slice'; import STUDY_GROUPS from '../../fixtures/study-groups'; @@ -38,6 +39,15 @@ describe('reducer', () => { personnel: 0, tags: [], }, + register: { + userEmail: '', + password: '', + passwordConfirm: '', + }, + login: { + userEmail: '', + password: '', + }, }; it('returns initialState', () => { @@ -143,6 +153,50 @@ describe('reducer', () => { expect(state.groupId).toBe('1'); }); }); + + describe('changeAuthField', () => { + const initialState = { + register: { + userEmail: '', + password: '', + passwordConfirm: '', + }, + login: { + userEmail: '', + password: '', + }, + }; + + context('When the form name is login', () => { + it('login form is change', () => { + const state = reducer(initialState, + changeAuthField( + { + form: 'login', + name: 'userEmail', + value: 'tktmdals', + }, + )); + + expect(state.login.userEmail).toBe('tktmdals'); + }); + }); + + context('When the form name is register', () => { + it('register form is change', () => { + const state = reducer(initialState, + changeAuthField( + { + form: 'register', + name: 'userEmail', + value: 'tktmdals', + }, + )); + + expect(state.register.userEmail).toBe('tktmdals'); + }); + }); + }); }); describe('async actions', () => {