├── __mocks__ ├── styleMock.js ├── fileMock.js └── react-redux.js ├── .firebaserc ├── public ├── favicon.ico └── assets │ ├── logo.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── mstile-150x150.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-384x384.png │ ├── browserconfig.xml │ ├── site.webmanifest │ └── safari-pinned-tab.svg ├── assets └── img │ ├── logo.png │ ├── mobile-demo.gif │ └── desktop-demo.gif ├── jest.setup.js ├── .husky └── pre-commit ├── src ├── util │ ├── constants │ │ ├── theme.js │ │ ├── constants.js │ │ └── messages.js │ ├── config │ │ ├── index.js │ │ ├── dev.js │ │ ├── prod.js │ │ └── index.test.js │ └── __mocks__ │ │ ├── utils.js │ │ └── matchMedia.js ├── services │ ├── __mocks__ │ │ ├── storage.js │ │ └── api.js │ ├── storage.js │ ├── firebase.js │ ├── firebase.test.js │ └── storage.test.js ├── reducers │ ├── store.js │ ├── rootSlice.js │ ├── commonSlice.js │ └── commonSlice.test.js ├── pages │ ├── myInfo │ │ ├── MyStudyInfoPage.jsx │ │ ├── ProfileSettingPage.jsx │ │ ├── MyStudyInfoPage.test.jsx │ │ ├── MyInfoPage.test.jsx │ │ ├── MyInfoPage.jsx │ │ └── ProfileSettingPage.test.jsx │ ├── NotFoundPage.jsx │ ├── CrashErrorPage.jsx │ ├── LoginPage.jsx │ ├── RegisterPage.jsx │ ├── MainPage.jsx │ ├── NotFoundPage.test.jsx │ ├── WritePage.jsx │ ├── LoginPage.test.jsx │ ├── RegisterPage.test.jsx │ ├── CrashErrorPage.test.jsx │ ├── IntroducePage.jsx │ ├── WritePage.test.jsx │ ├── IntroducePage.test.jsx │ └── MainPage.test.jsx ├── components │ ├── common │ │ ├── test │ │ │ ├── MockTheme.jsx │ │ │ └── InjectMockProviders.jsx │ │ ├── PrivateRoute.jsx │ │ ├── FormModalWindow.test.jsx │ │ ├── ModalWindow.test.jsx │ │ ├── Tags.test.jsx │ │ ├── PrivateRoute.test.jsx │ │ ├── DateTimeChange.jsx │ │ └── ModalWindow.jsx │ ├── introduce │ │ ├── modals │ │ │ ├── AskLoginModal.jsx │ │ │ ├── AskApplyCancelModal.jsx │ │ │ ├── AskArticleDeleteModal.jsx │ │ │ ├── AskLoginModal.test.jsx │ │ │ ├── AskApplyCancelModal.test.jsx │ │ │ ├── AskArticleDeleteModal.test.jsx │ │ │ └── ApplicationViewModal.test.jsx │ │ ├── IntroduceHeader.test.jsx │ │ ├── IntroduceHeader.jsx │ │ ├── IntroduceActionButtons.test.jsx │ │ ├── ReviewList.jsx │ │ ├── ReviewList.test.jsx │ │ ├── Review.test.jsx │ │ └── AverageReview.test.jsx │ ├── myInfo │ │ ├── modal │ │ │ ├── AskMembershipWithdrawalModal.jsx │ │ │ ├── AskMembershipWithdrawalModal.test.jsx │ │ │ ├── ConfirmPasswordModal.jsx │ │ │ └── ConfirmPasswordModal.test.jsx │ │ ├── MyInfoTab.jsx │ │ ├── MyInfoTab.test.jsx │ │ └── MembershipWithdrawal.jsx │ ├── loader │ │ ├── ResponsiveGroupContentLoader.jsx │ │ ├── ResponsiveGroupsContentLoader.jsx │ │ ├── ResponsiveGroupContentLoader.test.jsx │ │ ├── DesktopGroupContentLoader.jsx │ │ ├── MobileGroupContentLoader.jsx │ │ └── ResponsiveGroupsContentLoader.test.jsx │ ├── write │ │ ├── TagList.jsx │ │ ├── WriteEditor.test.jsx │ │ ├── TagList.test.jsx │ │ ├── WriteEditor.jsx │ │ └── WriteForm.test.jsx │ ├── base │ │ ├── Core.jsx │ │ ├── ThemeToggle.jsx │ │ ├── Header.jsx │ │ ├── DropDown.jsx │ │ ├── DropDown.test.jsx │ │ ├── UserHeaderStatus.test.jsx │ │ ├── ThemeToggle.test.jsx │ │ └── Header.test.jsx │ ├── main │ │ ├── EstablishStudy.jsx │ │ ├── EstablishStudy.test.jsx │ │ ├── StudyGroups.test.jsx │ │ ├── StudyGroups.jsx │ │ └── StudyGroup.test.jsx │ ├── error │ │ ├── ErrorScreenTemplate.test.jsx │ │ └── ErrorScreenTemplate.jsx │ └── auth │ │ └── AuthForm.test.jsx ├── containers │ ├── base │ │ ├── ThemeToggleContainer.jsx │ │ ├── HeaderContainer.jsx │ │ ├── HeaderContainer.test.jsx │ │ └── ThemeToggleContainer.test.jsx │ ├── write │ │ ├── TagsFormContainer.jsx │ │ ├── WriteFormContainer.jsx │ │ ├── WriteButtonsContainer.jsx │ │ ├── WriteEditorContainer.jsx │ │ ├── TagsFormContainer.test.jsx │ │ ├── WriteFormContainer.test.jsx │ │ └── WriteEditorContainer.test.jsx │ ├── error │ │ ├── CrashErrorContainer.jsx │ │ ├── NotFoundContainer.jsx │ │ ├── CrashErrorContainer.test.jsx │ │ └── NotFoundContainer.test.jsx │ ├── introduce │ │ ├── IntroduceFormContainer.jsx │ │ └── IntroduceHeaderContainer.jsx │ ├── auth │ │ ├── LoginFormContainer.jsx │ │ └── RegisterFormContainer.jsx │ └── groups │ │ └── StudyGroupsContainer.jsx ├── assets │ └── icons │ │ ├── profile-user.svg │ │ ├── close.svg │ │ ├── add.svg │ │ ├── user.svg │ │ └── get.svg ├── hooks │ ├── useTheme.js │ ├── useNotFound.js │ ├── useAuth.js │ ├── useTheme.test.js │ ├── useAuth.test.js │ └── useNotFound.test.js ├── styles │ ├── responsive.js │ ├── SubTitle.jsx │ ├── GlobalBlock.jsx │ ├── Button.test.jsx │ ├── palette.js │ ├── Textarea.jsx │ ├── ParticipantListButton.jsx │ ├── GlobalStyles.jsx │ ├── StyledApplyStatusButton.jsx │ ├── Button.jsx │ └── ThemeToggleButton.jsx ├── __mocks__ │ └── firebase │ │ └── app.js ├── ErrorBoundary.jsx ├── App.jsx └── index.jsx ├── jsconfig.json ├── fixtures ├── user-detail.js ├── write-form.js ├── study-group.js └── study-groups.js ├── firestore.rules ├── babel.config.js ├── .gitignore ├── tests ├── steps_file.js ├── steps.d.ts ├── error │ └── not_found_test.js ├── main │ └── main_test.js └── auth │ ├── login_test.js │ └── register_test.js ├── .github └── workflows │ ├── codecov-test-suites.yml │ ├── ci.yml │ └── cd.yml ├── firebase.json ├── jest.config.js ├── firestore.indexes.json ├── LICENSE ├── .eslintrc.js └── codecept.conf.js /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub'; 2 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "sweet-1cfff" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/assets/img/logo.png -------------------------------------------------------------------------------- /public/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/public/assets/logo.png -------------------------------------------------------------------------------- /assets/img/mobile-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/assets/img/mobile-demo.gif -------------------------------------------------------------------------------- /assets/img/desktop-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/assets/img/desktop-demo.gif -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | 3 | import './src/util/__mocks__/matchMedia'; 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run test:unit 5 | npm run lint 6 | -------------------------------------------------------------------------------- /__mocks__/react-redux.js: -------------------------------------------------------------------------------- 1 | export const useDispatch = jest.fn(); 2 | 3 | export const useSelector = jest.fn(); 4 | -------------------------------------------------------------------------------- /public/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/public/assets/favicon-16x16.png -------------------------------------------------------------------------------- /public/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/public/assets/favicon-32x32.png -------------------------------------------------------------------------------- /src/util/constants/theme.js: -------------------------------------------------------------------------------- 1 | const LIGHT = false; 2 | 3 | const DARK = true; 4 | 5 | export { LIGHT, DARK }; 6 | -------------------------------------------------------------------------------- /public/assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/public/assets/mstile-150x150.png -------------------------------------------------------------------------------- /public/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/public/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /public/assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/public/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/assets/android-chrome-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSoom/ConStu/HEAD/public/assets/android-chrome-384x384.png -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true, 4 | "baseUrl": "src", 5 | "jsx": "react" 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /fixtures/user-detail.js: -------------------------------------------------------------------------------- 1 | const userDetail = { 2 | email: 'test@test.com', 3 | displayName: 'test', 4 | emailVerified: false, 5 | photoURL: null, 6 | }; 7 | 8 | export default userDetail; 9 | -------------------------------------------------------------------------------- /src/services/__mocks__/storage.js: -------------------------------------------------------------------------------- 1 | const saveItem = jest.fn(); 2 | const loadItem = jest.fn(); 3 | const removeItem = jest.fn(); 4 | 5 | export { 6 | saveItem, 7 | loadItem, 8 | removeItem, 9 | }; 10 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | rules_version = '2'; 2 | service cloud.firestore { 3 | match /databases/{database}/documents { 4 | match /groups/{group} { 5 | allow read: if true; 6 | allow write: if request.auth.uid != null; 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | '@babel/preset-react', 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Mac OS x 2 | .DS_* 3 | 4 | ## VS Code 5 | .vscode 6 | 7 | ## Node.js 8 | node_modules 9 | 10 | ## Jest 11 | coverage 12 | 13 | ## build 14 | build 15 | 16 | ## .env 17 | .env 18 | 19 | ## .firebase 20 | .firebase 21 | 22 | ## codeceptjs 23 | output 24 | -------------------------------------------------------------------------------- /src/util/config/index.js: -------------------------------------------------------------------------------- 1 | import devConfig from './dev'; 2 | import prodConfig from './prod'; 3 | 4 | const config = (env) => { 5 | if (env === 'production') { 6 | return prodConfig; 7 | } 8 | 9 | return devConfig; 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /public/assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/reducers/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | 3 | import rootReducer from './rootSlice'; 4 | 5 | import { isDevLevel } from '../util/utils'; 6 | 7 | const store = configureStore({ 8 | reducer: rootReducer, 9 | devTools: isDevLevel(process.env.NODE_ENV), 10 | }); 11 | 12 | export default store; 13 | -------------------------------------------------------------------------------- /src/services/storage.js: -------------------------------------------------------------------------------- 1 | export const saveItem = (key, value) => { 2 | localStorage.setItem(key, JSON.stringify(value)); 3 | }; 4 | 5 | export const loadItem = (key) => { 6 | const item = localStorage.getItem(key); 7 | 8 | return JSON.parse(item); 9 | }; 10 | 11 | export const removeItem = (key) => localStorage.removeItem(key); 12 | -------------------------------------------------------------------------------- /src/pages/myInfo/MyStudyInfoPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | const MyStudyInfoPageWrapper = styled.div` 6 | 7 | `; 8 | 9 | const MyStudyInfoPage = () => ( 10 | 11 | 내 스터디 정보 12 | 13 | ); 14 | 15 | export default MyStudyInfoPage; 16 | -------------------------------------------------------------------------------- /tests/steps_file.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable func-names */ 2 | // in this file you can append custom step methods to 'I' object 3 | 4 | module.exports = function () { 5 | return actor({ 6 | 7 | // Define custom steps here, use 'this' to access default methods of I. 8 | // It is recommended to place a general 'login' function here. 9 | 10 | }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/reducers/rootSlice.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from '@reduxjs/toolkit'; 2 | 3 | import authReducer from './authSlice'; 4 | import groupReducer from './groupSlice'; 5 | import commonReducer from './commonSlice'; 6 | 7 | const rootReducer = combineReducers({ 8 | authReducer, 9 | groupReducer, 10 | commonReducer, 11 | }); 12 | 13 | export default rootReducer; 14 | -------------------------------------------------------------------------------- /tests/steps.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | type steps_file = typeof import('./steps_file.js'); 3 | 4 | declare namespace CodeceptJS { 5 | interface SupportObject { I: I, current: any } 6 | interface Methods extends Playwright {} 7 | interface I extends ReturnType {} 8 | namespace Translation { 9 | interface Actions {} 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/codecov-test-suites.yml: -------------------------------------------------------------------------------- 1 | name: Codecov 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 # Check out your repository 10 | - run: yarn install # Install dependencies 11 | - run: yarn run coverage # Run test 12 | - run: bash <(curl -s https://codecov.io/bash) # Upload to Codecov 13 | -------------------------------------------------------------------------------- /src/components/common/test/MockTheme.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ThemeProvider } from '@emotion/react'; 4 | 5 | import { lightTheme } from '../../../styles/theme'; 6 | 7 | function MockTheme({ children }) { 8 | return ( 9 | 10 | {children} 11 | 12 | ); 13 | } 14 | 15 | export default MockTheme; 16 | -------------------------------------------------------------------------------- /src/pages/NotFoundPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Helmet } from 'react-helmet-async'; 4 | 5 | import NotFoundContainer from '../containers/error/NotFoundContainer'; 6 | 7 | const NotFoundPage = () => ( 8 | <> 9 | 10 | 404 11 | 12 | 13 | 14 | ); 15 | 16 | export default NotFoundPage; 17 | -------------------------------------------------------------------------------- /src/util/config/dev.js: -------------------------------------------------------------------------------- 1 | const devConfig = { 2 | apiKey: 'AIzaSyBCJVlgrTQNQn6wYPqytGO_aq3I9BFPz1Q', 3 | authDomain: 'dev-constu.firebaseapp.com', 4 | projectId: 'dev-constu', 5 | storageBucket: 'dev-constu.appspot.com', 6 | messagingSenderId: '378868374126', 7 | appId: '1:378868374126:web:8c873302a4dc204269647f', 8 | measurementId: 'G-4SBWMNRM23', 9 | }; 10 | 11 | export default devConfig; 12 | -------------------------------------------------------------------------------- /src/components/introduce/modals/AskLoginModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ModalWindow from '../../common/ModalWindow'; 4 | 5 | const AskLoginModal = ({ visible, onCancel }) => ( 6 | 13 | ); 14 | 15 | export default AskLoginModal; 16 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "hosting": { 7 | "public": "build", 8 | "ignore": [ 9 | "firebase.json", 10 | "**/.*", 11 | "**/node_modules/**" 12 | ], 13 | "rewrites": [ 14 | { 15 | "source": "**", 16 | "destination": "/index.html" 17 | } 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | setupFilesAfterEnv: [ 3 | 'given2/setup', 4 | 'jest-plugin-context/setup', 5 | './jest.setup', 6 | ], 7 | moduleNameMapper: { 8 | '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/__mocks__/fileMock.js', 9 | '\\.(css|less)$': '/__mocks__/styleMock.js', 10 | }, 11 | setupFiles: ['dotenv/config'], 12 | }; 13 | -------------------------------------------------------------------------------- /fixtures/write-form.js: -------------------------------------------------------------------------------- 1 | import { tomorrow, toStringEndDateFormat } from '../src/util/utils'; 2 | 3 | const writeForm = { 4 | title: '스터디를 소개합니다.1', 5 | contents: '

우리는 이것저것 합니다.1

', 6 | moderatorId: 'user1', 7 | applyEndDate: toStringEndDateFormat(tomorrow), 8 | participants: [], 9 | personnel: '1', 10 | tags: [ 11 | 'JavaScript', 12 | 'Algorithm', 13 | ], 14 | }; 15 | 16 | export default writeForm; 17 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [ 3 | { 4 | "collectionGroup": "groups", 5 | "queryScope": "COLLECTION", 6 | "fields": [ 7 | { 8 | "fieldPath": "tags", 9 | "arrayConfig": "CONTAINS" 10 | }, 11 | { 12 | "fieldPath": "applyEndDate", 13 | "order": "ASCENDING" 14 | } 15 | ] 16 | } 17 | ], 18 | "fieldOverrides": [] 19 | } 20 | -------------------------------------------------------------------------------- /src/pages/CrashErrorPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Helmet } from 'react-helmet-async'; 4 | 5 | import CrashErrorContainer from '../containers/error/CrashErrorContainer'; 6 | 7 | const CrashErrorPage = ({ onResolve }) => ( 8 | <> 9 | 10 | 이런.. 오류가 발생했어요! 11 | 12 | 13 | 14 | ); 15 | 16 | export default CrashErrorPage; 17 | -------------------------------------------------------------------------------- /src/components/introduce/modals/AskApplyCancelModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ModalWindow from '../../common/ModalWindow'; 4 | 5 | const AskApplyCancelModal = ({ visible, onCancel, onConfirm }) => ( 6 | 13 | ); 14 | 15 | export default AskApplyCancelModal; 16 | -------------------------------------------------------------------------------- /src/components/introduce/modals/AskArticleDeleteModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ModalWindow from '../../common/ModalWindow'; 4 | 5 | const AskArticleDeleteModal = ({ visible, onCancel, onConfirm }) => ( 6 | 13 | ); 14 | 15 | export default AskArticleDeleteModal; 16 | -------------------------------------------------------------------------------- /src/containers/base/ThemeToggleContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import useTheme from '../../hooks/useTheme'; 4 | 5 | import ThemeToggle from '../../components/base/ThemeToggle'; 6 | 7 | const ThemeToggleContainer = () => { 8 | const { theme, changeMode } = useTheme(); 9 | 10 | return ( 11 | 15 | ); 16 | }; 17 | 18 | export default ThemeToggleContainer; 19 | -------------------------------------------------------------------------------- /src/components/myInfo/modal/AskMembershipWithdrawalModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import ModalWindow from '../../common/ModalWindow'; 4 | 5 | const AskMembershipWithdrawalModal = ({ visible, onCancel, onConfirm }) => ( 6 | 13 | ); 14 | 15 | export default AskMembershipWithdrawalModal; 16 | -------------------------------------------------------------------------------- /fixtures/study-group.js: -------------------------------------------------------------------------------- 1 | import { tomorrow } from '../src/util/utils'; 2 | 3 | const studyGroup = { 4 | id: 1, 5 | title: '스터디를 소개합니다.2', 6 | moderatorId: 'user2', 7 | applyEndDate: tomorrow, 8 | participants: [], 9 | personnel: 2, 10 | contents: '우리는 이것저것 합니다.2', 11 | tags: [ 12 | 'JavaScript', 13 | 'React', 14 | 'Algorithm', 15 | ], 16 | reviews: [], 17 | createDate: new Date('2020/12/06'), 18 | }; 19 | 20 | export default studyGroup; 21 | -------------------------------------------------------------------------------- /src/pages/myInfo/ProfileSettingPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import ProfileSettingContainer from '../../containers/myInfo/ProfileSettingContainer'; 6 | 7 | const ProfileSettingPageWrapper = styled.div` 8 | 9 | `; 10 | 11 | const ProfileSettingPage = () => ( 12 | 13 | 계정 설정 14 | 15 | 16 | ); 17 | export default ProfileSettingPage; 18 | -------------------------------------------------------------------------------- /src/assets/icons/profile-user.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/components/loader/ResponsiveGroupContentLoader.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import DesktopGroupContentLoader from './DesktopGroupContentLoader'; 4 | import MobileGroupContentLoader from './MobileGroupContentLoader'; 5 | 6 | const ResponsiveGroupContentLoader = ({ isDesktop, isMobile }) => ( 7 | <> 8 | {isDesktop && } 9 | {isMobile && } 10 | 11 | ); 12 | 13 | export default ResponsiveGroupContentLoader; 14 | -------------------------------------------------------------------------------- /src/components/write/TagList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Tags from '../common/Tags'; 4 | 5 | const TagList = ({ tags, onRemove }) => { 6 | const handleRemove = (removeTag) => { 7 | const removeTags = tags.filter((tag) => tag !== removeTag); 8 | 9 | onRemove(removeTags); 10 | }; 11 | 12 | return ( 13 | 18 | ); 19 | }; 20 | 21 | export default TagList; 22 | -------------------------------------------------------------------------------- /src/assets/icons/close.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/util/config/prod.js: -------------------------------------------------------------------------------- 1 | const prodConfig = { 2 | apiKey: process.env.FIREBASE_API_KEY, 3 | authDomain: process.env.FIREBASE_AUTH_DOMAIN, 4 | databaseURL: process.env.FIREBASE_DATA_BASEURL, 5 | projectId: process.env.FIREBASE_PROJECT_ID, 6 | storageBucket: process.env.FIREBASE_STORAGE_BUCKET, 7 | messagingSenderId: process.env.FIREBASE_MESSAGING_SENDER_ID, 8 | appId: process.env.FIREBASE_APP_ID, 9 | measurementId: process.env.FIREBASE_MEASUREMENT_ID, 10 | }; 11 | 12 | export default prodConfig; 13 | -------------------------------------------------------------------------------- /src/pages/myInfo/MyStudyInfoPage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import MyStudyInfoPage from './MyStudyInfoPage'; 6 | 7 | describe('MyStudyInfoPage', () => { 8 | const renderMyStudyInfoPage = () => render(( 9 | 10 | )); 11 | 12 | it('renders My Study Info Text Contents', () => { 13 | const { container } = renderMyStudyInfoPage(); 14 | 15 | expect(container).toHaveTextContent('내 스터디 정보'); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/util/config/index.test.js: -------------------------------------------------------------------------------- 1 | import config from '.'; 2 | 3 | import devConfig from './dev'; 4 | import prodConfig from './prod'; 5 | 6 | describe('config', () => { 7 | context('When ENV is Production', () => { 8 | it('should be prodConfig', () => { 9 | expect(config('production')).toBe(prodConfig); 10 | }); 11 | }); 12 | 13 | context('When ENV is Development', () => { 14 | it('should be devConfig', () => { 15 | expect(config('development')).toBe(devConfig); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/base/Core.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ToastContainer, Bounce } from 'react-toastify'; 4 | 5 | import GlobalStyles from '../../styles/GlobalStyles'; 6 | 7 | import 'react-toastify/dist/ReactToastify.css'; 8 | 9 | const Core = () => ( 10 | <> 11 | 12 | 19 | 20 | ); 21 | 22 | export default Core; 23 | -------------------------------------------------------------------------------- /public/assets/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ConStu", 3 | "short_name": "ConStu", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-384x384.png", 12 | "sizes": "384x384", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /src/hooks/useTheme.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { getCommon } from '../util/utils'; 6 | import { changeTheme } from '../reducers/commonSlice'; 7 | 8 | function useTheme() { 9 | const dispatch = useDispatch(); 10 | 11 | const theme = useSelector(getCommon('theme')); 12 | 13 | const changeMode = useCallback(() => dispatch(changeTheme()), [dispatch]); 14 | 15 | return { 16 | theme, 17 | changeMode, 18 | }; 19 | } 20 | 21 | export default useTheme; 22 | -------------------------------------------------------------------------------- /src/pages/LoginPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Helmet } from 'react-helmet-async'; 4 | 5 | import { LOGIN } from '../util/constants/constants'; 6 | 7 | import HeaderContainer from '../containers/base/HeaderContainer'; 8 | import LoginFormContainer from '../containers/auth/LoginFormContainer'; 9 | 10 | const LoginPage = () => ( 11 | <> 12 | 13 | {`ConStu | ${LOGIN}`} 14 | 15 | 16 | 17 | 18 | ); 19 | 20 | export default LoginPage; 21 | -------------------------------------------------------------------------------- /src/pages/RegisterPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Helmet } from 'react-helmet-async'; 4 | 5 | import { REGISTER } from '../util/constants/constants'; 6 | 7 | import HeaderContainer from '../containers/base/HeaderContainer'; 8 | import RegisterFormContainer from '../containers/auth/RegisterFormContainer'; 9 | 10 | const RegisterPage = () => ( 11 | <> 12 | 13 | {`ConStu | ${REGISTER}`} 14 | 15 | 16 | 17 | 18 | ); 19 | 20 | export default RegisterPage; 21 | -------------------------------------------------------------------------------- /src/styles/responsive.js: -------------------------------------------------------------------------------- 1 | import facepaint from 'facepaint'; 2 | 3 | const mq = facepaint([ 4 | '@media(min-width: 450px)', 5 | '@media(min-width: 650px)', 6 | '@media(min-width: 1050px)', 7 | ]); 8 | 9 | export const mq2 = facepaint([ 10 | '@media(min-width: 1050px)', 11 | ]); 12 | 13 | export const mq3 = facepaint([ 14 | '@media(min-width: 450px)', 15 | '@media(min-width: 650px)', 16 | '@media(min-width: 820px)', 17 | '@media(min-width: 1050px)', 18 | ]); 19 | 20 | export const mq4 = facepaint([ 21 | '@media(min-width: 450px)', 22 | '@media(min-width: 700px)', 23 | ]); 24 | 25 | export default mq; 26 | -------------------------------------------------------------------------------- /src/components/myInfo/MyInfoTab.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import { NavLink } from 'react-router-dom'; 6 | 7 | const MyInfoTabWrapper = styled.div` 8 | 9 | `; 10 | 11 | const MyInfoTab = () => { 12 | const activeStyle = { 13 | color: 'skyBlue', 14 | }; 15 | 16 | return ( 17 | 18 | 계정 설정 19 | 내 스터디 정보 20 | 21 | ); 22 | }; 23 | 24 | export default MyInfoTab; 25 | -------------------------------------------------------------------------------- /src/pages/myInfo/MyInfoPage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import MyInfoPage from './MyInfoPage'; 6 | import InjectMockProviders from '../../components/common/test/InjectMockProviders'; 7 | 8 | describe('MyInfoPage', () => { 9 | const renderMyInfoPage = () => render(( 10 | 11 | 12 | 13 | )); 14 | 15 | it('renders My Info Page text contents', () => { 16 | const { container } = renderMyInfoPage(); 17 | 18 | expect(container).toHaveTextContent('내 정보'); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/components/loader/ResponsiveGroupsContentLoader.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import DesktopGroupsContentLoader from './DesktopGroupsContentLoader'; 4 | import TabletGroupsContentLoader from './TabletGroupsContentLoader'; 5 | import MobileGroupsContentLoader from './MobileGroupsContentLoader'; 6 | 7 | const ResponsiveGroupsContentLoader = ({ isDesktop, isTablet, isMobile }) => ( 8 | <> 9 | {isDesktop && } 10 | {isTablet && } 11 | {isMobile && } 12 | 13 | ); 14 | 15 | export default ResponsiveGroupsContentLoader; 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | test: 9 | name: CI 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [14.x] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: Install 21 | run: yarn install 22 | - name: Unit & e2e Test 23 | run: yarn run test 24 | env: 25 | HEADLESS: true 26 | - name: Lint 27 | run: yarn run lint 28 | -------------------------------------------------------------------------------- /src/components/write/WriteEditor.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import WriteEditor from './WriteEditor'; 6 | import MockTheme from '../common/test/MockTheme'; 7 | 8 | describe('WriteEditor', () => { 9 | const handleChange = jest.fn(); 10 | const renderWriteButtons = () => render(( 11 | 12 | 15 | 16 | )); 17 | 18 | it('render Write Editor', () => { 19 | const { container } = renderWriteButtons(); 20 | 21 | expect(container).toHaveTextContent('내용을 작성해주세요.'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/styles/SubTitle.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import palette from './palette'; 6 | import mq from './responsive'; 7 | 8 | const SubTitleWrapper = styled.div` 9 | ${mq({ 10 | fontSize: ['1.2rem', '1.3rem', '1.4rem'], 11 | })}; 12 | 13 | font-weight: bold; 14 | text-align: center; 15 | margin-bottom: 0; 16 | margin-top: 1rem; 17 | padding: 7px 2rem 7px 2rem; 18 | border-bottom: 2px solid ${palette.violet[3]}; 19 | width: 17%; 20 | `; 21 | 22 | const SubTitle = ({ title }) => ( 23 | 24 | {title} 25 | 26 | ); 27 | 28 | export default SubTitle; 29 | -------------------------------------------------------------------------------- /src/util/__mocks__/utils.js: -------------------------------------------------------------------------------- 1 | export const dateToString = jest.fn((date) => date); 2 | 3 | export const toStringEndDateFormat = jest.fn(); 4 | 5 | export const formatReviewDate = (reviews) => reviews.map((review) => ({ 6 | ...review, 7 | createDate: dateToString(review.createDate), 8 | })); 9 | 10 | export const formatGroup = (group) => { 11 | const { applyEndDate, createDate, reviews } = group.data(); 12 | 13 | return { 14 | ...group.data(), 15 | id: group.id, 16 | applyEndDate: dateToString(applyEndDate), 17 | createDate: dateToString(createDate), 18 | reviews: reviews && [...formatReviewDate(reviews)], 19 | }; 20 | }; 21 | 22 | export const getInitTheme = jest.fn(); 23 | -------------------------------------------------------------------------------- /src/styles/GlobalBlock.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import React from 'react'; 3 | 4 | import facepaint from 'facepaint'; 5 | import styled from '@emotion/styled'; 6 | 7 | const mq = facepaint([ 8 | '@media(min-width: 1050px)', 9 | '@media(min-width: 1150px)', 10 | ]); 11 | 12 | const AppBlockWrapper = styled.div(() => mq({ 13 | marginLeft: 'auto', 14 | marginRight: 'auto', 15 | paddingLeft: '1rem', 16 | paddingRight: '1rem', 17 | width: ['calc(100% - 3rem)', '1024px'], 18 | })); 19 | 20 | const GlobalBlock = ({ children, ...rest }) => ( 21 | 22 | {children} 23 | 24 | ); 25 | 26 | export default GlobalBlock; 27 | -------------------------------------------------------------------------------- /src/components/base/ThemeToggle.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import { mq2 } from '../../styles/responsive'; 6 | 7 | import ThemeToggleButton from '../../styles/ThemeToggleButton'; 8 | 9 | const ThemeToggleButtonWrapper = styled.div` 10 | ${mq2({ 11 | width: ['100%', '1024px'], 12 | })}; 13 | 14 | margin-bottom: 2rem; 15 | display: flex; 16 | justify-content: flex-end; 17 | `; 18 | 19 | const ThemeToggle = ({ theme, onChange }) => ( 20 | 21 | 25 | 26 | ); 27 | 28 | export default ThemeToggle; 29 | -------------------------------------------------------------------------------- /src/hooks/useNotFound.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { getCommon } from '../util/utils'; 6 | import { setNotFound, resetError } from '../reducers/commonSlice'; 7 | 8 | function useNotFound() { 9 | const dispatch = useDispatch(); 10 | 11 | const errorType = useSelector(getCommon('errorType')); 12 | 13 | const showNotFound = useCallback(() => dispatch(setNotFound()), [dispatch]); 14 | 15 | const reset = useCallback(() => dispatch(resetError()), [dispatch]); 16 | 17 | return { 18 | reset, 19 | showNotFound, 20 | isNotFound: errorType === 'NOT_FOUND', 21 | }; 22 | } 23 | 24 | export default useNotFound; 25 | -------------------------------------------------------------------------------- /src/pages/MainPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Helmet } from 'react-helmet-async'; 4 | 5 | import GlobalBlock from '../styles/GlobalBlock'; 6 | 7 | import HeaderContainer from '../containers/base/HeaderContainer'; 8 | import ThemeToggleContainer from '../containers/base/ThemeToggleContainer'; 9 | import StudyGroupsContainer from '../containers/groups/StudyGroupsContainer'; 10 | 11 | const MainPage = () => ( 12 | <> 13 | 14 | ConStu 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | 24 | export default React.memo(MainPage); 25 | -------------------------------------------------------------------------------- /src/components/introduce/IntroduceHeader.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import IntroduceHeader from './IntroduceHeader'; 6 | import InjectMockProviders from '../common/test/InjectMockProviders'; 7 | 8 | describe('IntroduceHeader', () => { 9 | const renderIntroduceHeader = (title) => render(( 10 | 11 | 14 | 15 | )); 16 | 17 | it('renders study group title and contents', () => { 18 | const { container } = renderIntroduceHeader('스터디를 소개합니다.2'); 19 | 20 | expect(container).toHaveTextContent('스터디를 소개합니다.2'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/containers/base/HeaderContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { getAuth } from '../../util/utils'; 6 | import { requestLogout } from '../../reducers/authSlice'; 7 | 8 | import Header from '../../components/base/Header'; 9 | 10 | const HeaderContainer = () => { 11 | const dispatch = useDispatch(); 12 | 13 | const user = useSelector(getAuth('user')); 14 | 15 | const onLogout = useCallback(() => { 16 | dispatch(requestLogout()); 17 | }, [dispatch]); 18 | 19 | return ( 20 |
24 | ); 25 | }; 26 | 27 | export default React.memo(HeaderContainer); 28 | -------------------------------------------------------------------------------- /src/components/common/PrivateRoute.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import React from 'react'; 3 | 4 | import { useSelector } from 'react-redux'; 5 | import { Route, Redirect } from 'react-router-dom'; 6 | 7 | import { getAuth } from '../../util/utils'; 8 | 9 | const PrivateRoute = ({ component: Component, ...rest }) => { 10 | const user = useSelector(getAuth('user')); 11 | 12 | return ( 13 | (user ? ( 16 | 17 | ) : ( 18 | 23 | ))} 24 | /> 25 | ); 26 | }; 27 | 28 | export default PrivateRoute; 29 | -------------------------------------------------------------------------------- /src/services/firebase.js: -------------------------------------------------------------------------------- 1 | import firebase from 'firebase/app'; 2 | 3 | import 'firebase/auth'; 4 | import 'firebase/firestore'; 5 | 6 | import config from '../util/config'; 7 | 8 | firebase.initializeApp(config(process.env.NODE_ENV)); 9 | 10 | firebase.auth().languageCode = 'ko'; 11 | 12 | export const fireStore = firebase.firestore; 13 | 14 | export const db = firebase.firestore(); 15 | 16 | export const auth = firebase.auth(); 17 | 18 | export const authProvider = firebase.auth; 19 | 20 | export const actionCodeSettings = (isDevLevel) => { 21 | const { currentUser } = auth; 22 | 23 | const urlPrefix = isDevLevel ? 'http://localhost:8080' : 'https://sweet-1cfff.firebaseapp.com'; 24 | 25 | return { 26 | url: `${urlPrefix}/myinfo/setting/?email=${currentUser.email}`, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /tests/error/not_found_test.js: -------------------------------------------------------------------------------- 1 | Feature('사용자는 존재하지 않는 페이지에 접근할 수 있다.'); 2 | 3 | const step = codeceptjs.container.plugins('commentStep'); 4 | 5 | const Given = (given) => step`${given}`; 6 | const When = (when) => step`${when}`; 7 | const Then = (then) => step`${then}`; 8 | 9 | Scenario('존재하지 않는 페이지로 이동한 경우', ({ I }) => { 10 | Given('메인 페이지에서'); 11 | I.amOnPage('/'); 12 | 13 | When('존재하지 않는 페이지로 이동하면'); 14 | I.amOnPage('/not-found'); 15 | 16 | Then('404 페이지로 이동한다'); 17 | I.see('아무것도 없어요!'); 18 | I.see('홈으로'); 19 | }); 20 | 21 | Scenario('존재하지 않는 페이지에서 "홈으로" 버튼을 클릭한 경우', ({ I }) => { 22 | Given('존재하지 않는 페이지에서'); 23 | I.amOnPage('/not-found'); 24 | 25 | When('"홈으로" 버튼을 클릭하면'); 26 | I.click('홈으로'); 27 | 28 | Then('메인 페이지로 이동한다.'); 29 | I.seeCurrentUrlEquals('/'); 30 | }); 31 | -------------------------------------------------------------------------------- /src/containers/write/TagsFormContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { getGroup } from '../../util/utils'; 6 | import { changeWriteField } from '../../reducers/groupSlice'; 7 | 8 | import TagsForm from '../../components/write/TagsForm'; 9 | 10 | const TagsFormContainer = () => { 11 | const dispatch = useDispatch(); 12 | 13 | const { tags } = useSelector(getGroup('writeField')); 14 | 15 | const onChangeTags = useCallback((nextTags) => { 16 | dispatch( 17 | changeWriteField({ 18 | name: 'tags', 19 | value: nextTags, 20 | }), 21 | ); 22 | }, [dispatch]); 23 | 24 | return ( 25 | 26 | ); 27 | }; 28 | 29 | export default TagsFormContainer; 30 | -------------------------------------------------------------------------------- /src/pages/NotFoundPage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import NotFoundPage from './NotFoundPage'; 6 | import InjectMockProviders from '../components/common/test/InjectMockProviders'; 7 | 8 | describe('NotFoundPage', () => { 9 | const renderNotFoundPage = () => render(( 10 | 11 | 12 | 13 | )); 14 | 15 | describe('Renders NotFound(404) Contents', () => { 16 | it('should be renders 404 Image and Message Text', () => { 17 | const { container, getByTestId } = renderNotFoundPage(); 18 | 19 | expect(container).toHaveTextContent('아무것도 없어요!'); 20 | expect(container).toHaveTextContent('홈으로'); 21 | expect(getByTestId('not-found-image')).not.toBeNull(); 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/__mocks__/firebase/app.js: -------------------------------------------------------------------------------- 1 | const firebase = jest.genMockFromModule('firebase/app'); 2 | 3 | const mockOrderBy = { 4 | orderBy: jest.fn().mockImplementation(() => ({ 5 | get: jest.fn().mockReturnValue({ 6 | docs: [], 7 | }), 8 | })), 9 | }; 10 | 11 | firebase.firestore = jest.fn().mockImplementation(() => ({ 12 | collection: jest.fn().mockImplementation(() => ({ 13 | doc: jest.fn().mockImplementation(() => ({ 14 | update: jest.fn(), 15 | delete: jest.fn(), 16 | get: jest.fn(), 17 | set: jest.fn(), 18 | })), 19 | add: jest.fn().mockReturnValue({ id: 'id' }), 20 | where: jest.fn().mockReturnValue(mockOrderBy), 21 | ...mockOrderBy, 22 | })), 23 | })); 24 | 25 | firebase.auth = jest.fn().mockImplementation(() => ({ 26 | onAuthStateChanged: jest.fn(), 27 | })); 28 | 29 | export default firebase; 30 | -------------------------------------------------------------------------------- /fixtures/study-groups.js: -------------------------------------------------------------------------------- 1 | const studyGroups = [ 2 | { 3 | id: 0, 4 | title: '스터디를 소개합니다.1', 5 | moderatorId: 'user1', 6 | applyEndDate: '2020-12-13', 7 | participants: [ 8 | 'user1', 9 | 'user2', 10 | 'user3', 11 | 'user4', 12 | 'user5', 13 | 'user6', 14 | 'user7', 15 | ], 16 | personnel: 10, 17 | contents: '우리는 이것저것 합니다.1', 18 | tags: [ 19 | 'JavaScript', 20 | 'React', 21 | 'Algorithm', 22 | ], 23 | }, 24 | { 25 | id: 1, 26 | title: '스터디를 소개합니다.2', 27 | moderatorId: 'user2', 28 | applyEndDate: '2020-12-23', 29 | participants: [ 30 | 'user2', 31 | ], 32 | personnel: 2, 33 | contents: '우리는 이것저것 합니다.2', 34 | tags: [ 35 | 'React', 36 | 'Algorithm', 37 | ], 38 | }, 39 | ]; 40 | 41 | export default studyGroups; 42 | -------------------------------------------------------------------------------- /src/hooks/useAuth.js: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { getAuth } from '../util/utils'; 6 | import { clearAuth, logout } from '../reducers/authSlice'; 7 | 8 | function useAuth() { 9 | const dispatch = useDispatch(); 10 | 11 | const auth = useSelector(getAuth('auth')); 12 | const user = useSelector(getAuth('user')); 13 | const authError = useSelector(getAuth('authError')); 14 | const userDetail = useSelector(getAuth('userDetail')); 15 | 16 | const clearAuthState = useCallback(() => dispatch(clearAuth()), [dispatch]); 17 | 18 | const logoutUser = useCallback(() => dispatch(logout()), [dispatch]); 19 | 20 | return { 21 | auth, 22 | user, 23 | authError, 24 | userDetail, 25 | logoutUser, 26 | clearAuthState, 27 | }; 28 | } 29 | 30 | export default useAuth; 31 | -------------------------------------------------------------------------------- /src/components/common/test/InjectMockProviders.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ThemeProvider } from '@emotion/react'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | import { HelmetProvider } from 'react-helmet-async'; 6 | import { Context as ResponsiveContext } from 'react-responsive'; 7 | 8 | import { lightTheme } from '../../../styles/theme'; 9 | 10 | function InjectMockProviders({ 11 | width = 700, path = '', theme = lightTheme, children, 12 | }) { 13 | return ( 14 | 15 | 16 | 17 | 18 | {children} 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | export default InjectMockProviders; 27 | -------------------------------------------------------------------------------- /src/components/main/EstablishStudy.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import { Link } from 'react-router-dom'; 6 | 7 | import Button from '../../styles/Button'; 8 | import PlusSvg from '../../assets/icons/add.svg'; 9 | 10 | const PlusIcon = styled(PlusSvg)` 11 | cursor: pointer; 12 | width: 35px; 13 | height: 35px; 14 | `; 15 | 16 | const EstablishStudy = ({ isMobile }) => { 17 | if (isMobile) { 18 | return ( 19 |
20 | 23 | 26 | 27 |
28 | ); 29 | } 30 | 31 | return ( 32 | 38 | ); 39 | }; 40 | 41 | export default EstablishStudy; 42 | -------------------------------------------------------------------------------- /src/services/firebase.test.js: -------------------------------------------------------------------------------- 1 | import { auth, actionCodeSettings } from './firebase'; 2 | 3 | describe('actionCodeSettings', () => { 4 | beforeEach(() => { 5 | auth.currentUser = { 6 | email: 'test@test.com', 7 | }; 8 | }); 9 | 10 | context('When Development Level', () => { 11 | it('should return dev level action code settings config', () => { 12 | const result = actionCodeSettings(true); 13 | 14 | expect(result).toEqual({ 15 | url: 'http://localhost:8080/myinfo/setting/?email=test@test.com', 16 | }); 17 | }); 18 | }); 19 | 20 | context('When Production Level', () => { 21 | it('should return Pro level action code settings config', () => { 22 | const result = actionCodeSettings(false); 23 | 24 | expect(result).toEqual({ 25 | url: 'https://sweet-1cfff.firebaseapp.com/myinfo/setting/?email=test@test.com', 26 | }); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/util/__mocks__/matchMedia.js: -------------------------------------------------------------------------------- 1 | const setMatchMedia = (matches) => Object.defineProperty(window, 'matchMedia', { 2 | writable: true, 3 | value: jest.fn().mockImplementation((query) => ({ 4 | matches, 5 | media: query, 6 | onchange: null, 7 | addListener: jest.fn(), // deprecated 8 | removeListener: jest.fn(), // deprecated 9 | addEventListener: jest.fn(), 10 | removeEventListener: jest.fn(), 11 | dispatchEvent: jest.fn(), 12 | })), 13 | }); 14 | 15 | Object.defineProperty(window, 'matchMedia', { 16 | writable: true, 17 | value: jest.fn().mockImplementation((query) => ({ 18 | matches: false, 19 | media: query, 20 | onchange: null, 21 | addListener: jest.fn(), // deprecated 22 | removeListener: jest.fn(), // deprecated 23 | addEventListener: jest.fn(), 24 | removeEventListener: jest.fn(), 25 | dispatchEvent: jest.fn(), 26 | })), 27 | }); 28 | 29 | export default setMatchMedia; 30 | -------------------------------------------------------------------------------- /src/reducers/commonSlice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | import { saveItem } from '../services/storage'; 4 | import { getInitTheme } from '../util/utils'; 5 | 6 | const { actions, reducer } = createSlice({ 7 | name: 'common', 8 | initialState: { 9 | theme: getInitTheme(), 10 | errorType: null, 11 | }, 12 | 13 | reducers: { 14 | changeTheme(state) { 15 | saveItem('theme', !state.theme); 16 | 17 | return { 18 | ...state, 19 | theme: !state.theme, 20 | }; 21 | }, 22 | setNotFound(state) { 23 | return { 24 | ...state, 25 | errorType: 'NOT_FOUND', 26 | }; 27 | }, 28 | resetError(state) { 29 | return { 30 | ...state, 31 | errorType: null, 32 | }; 33 | }, 34 | }, 35 | }); 36 | 37 | export const { 38 | changeTheme, 39 | setNotFound, 40 | resetError, 41 | } = actions; 42 | 43 | export default reducer; 44 | -------------------------------------------------------------------------------- /src/containers/write/WriteFormContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react'; 2 | 3 | import { useUnmount } from 'react-use'; 4 | import { useDispatch, useSelector } from 'react-redux'; 5 | 6 | import { getGroup } from '../../util/utils'; 7 | import { changeWriteField, clearWriteFields } from '../../reducers/groupSlice'; 8 | 9 | import WriteForm from '../../components/write/WriteForm'; 10 | 11 | const WriteFormContainer = () => { 12 | const dispatch = useDispatch(); 13 | 14 | const writeField = useSelector(getGroup('writeField')); 15 | 16 | const onChangeWriteField = useCallback(({ name, value }) => { 17 | dispatch( 18 | changeWriteField({ 19 | name, 20 | value, 21 | }), 22 | ); 23 | }, [dispatch]); 24 | 25 | useUnmount(() => dispatch(clearWriteFields())); 26 | 27 | return ( 28 | 32 | ); 33 | }; 34 | 35 | export default WriteFormContainer; 36 | -------------------------------------------------------------------------------- /src/services/__mocks__/api.js: -------------------------------------------------------------------------------- 1 | export const getStudyGroups = jest.fn(); 2 | 3 | export const getStudyGroup = jest.fn(); 4 | 5 | export const postStudyGroup = jest.fn(); 6 | 7 | export const postUserRegister = jest.fn(); 8 | 9 | export const postUserLogin = jest.fn(); 10 | 11 | export const postUserLogout = jest.fn(); 12 | 13 | export const updatePostParticipant = jest.fn(); 14 | 15 | export const deletePostParticipant = jest.fn(); 16 | 17 | export const updateConfirmPostParticipant = jest.fn(); 18 | 19 | export const deletePostGroup = jest.fn(); 20 | 21 | export const editPostStudyGroup = jest.fn(); 22 | 23 | export const postUpdateStudyReview = jest.fn(); 24 | 25 | export const deletePostReview = jest.fn(); 26 | 27 | export const sendEmailVerification = jest.fn(); 28 | 29 | export const sendPasswordResetEmail = jest.fn(); 30 | 31 | export const deleteUser = jest.fn(); 32 | 33 | export const postReauthenticateWithCredential = jest.fn(); 34 | 35 | export const updateUserProfile = jest.fn(); 36 | -------------------------------------------------------------------------------- /src/assets/icons/add.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/util/constants/constants.js: -------------------------------------------------------------------------------- 1 | export const NOT_MEMBER_YET = '아직 회원이 아니신가요?'; 2 | 3 | export const REGISTER = '회원가입'; 4 | export const LOGIN = '로그인'; 5 | export const LOGOUT = '로그아웃'; 6 | 7 | export const FORM_TYPE = { 8 | login: '로그인', 9 | register: '회원가입', 10 | }; 11 | 12 | export const APPLY_FORM_TITLE = { 13 | APPLY_REASON: '신청하게 된 이유', 14 | WANT_TO_GET: '스터디를 통해 얻고 싶은 것은 무엇인가요?', 15 | }; 16 | 17 | export const BUTTON_NAME = { 18 | CONFIRM: '확인', 19 | CANCEL: '취소', 20 | CLOSE: '닫기', 21 | EDIT: '수정', 22 | DELETE: '삭제', 23 | }; 24 | 25 | export const PARTICIPANT_FORM = { 26 | PARTICIPANT_EMAIL: '신청자 이메일', 27 | VIEW_APPLICATION: '신청서 보기', 28 | CANCEL: '취소하기', 29 | CONFIRM: '승인하기', 30 | CONFIRM_YN: '승인 여부', 31 | NO_EXIST_PARTICIPANT: '신청자가 존재하지 않습니다.', 32 | }; 33 | 34 | export const APPLY_STATUS = { 35 | DEAD_LINE: '모집 마감', 36 | APPLY: '신청하기', 37 | WAIT: '승인 대기 중..', 38 | CANCEL: '신청 취소', 39 | CONFIRM: '승인 완료!', 40 | REJECT: '승인 거절', 41 | COMPLETE: '신청 완료', 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/main/EstablishStudy.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { MemoryRouter } from 'react-router-dom'; 6 | 7 | import EstablishStudy from './EstablishStudy'; 8 | 9 | describe('EstablishStudy', () => { 10 | const renderEstablishStudy = (isMobile) => render(( 11 | 12 | 15 | 16 | )); 17 | 18 | context('When desktop screen', () => { 19 | it('renders "스터디 개설하기" button', () => { 20 | const { container } = renderEstablishStudy(false); 21 | 22 | expect(container).toHaveTextContent('스터디 개설하기'); 23 | }); 24 | }); 25 | 26 | context('When mobile screen', () => { 27 | it('renders "+" button', () => { 28 | const { getByTestId, container } = renderEstablishStudy(true); 29 | 30 | expect(container.innerHTML).toContain(' ( 15 | <> 16 | 17 | ConStu | 스터디 개설하기 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | export default WritePage; 30 | -------------------------------------------------------------------------------- /src/containers/error/CrashErrorContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useHistory } from 'react-router-dom'; 4 | 5 | import styled from '@emotion/styled'; 6 | 7 | import mq from '../../styles/responsive'; 8 | 9 | import ComputerSvg from '../../assets/icons/computer.svg'; 10 | import ErrorScreenTemplate from '../../components/error/ErrorScreenTemplate'; 11 | 12 | const ComputerImage = styled(ComputerSvg)` 13 | ${mq({ 14 | width: ['500px', '800px', '900px'], 15 | })}; 16 | 17 | height: auto; 18 | `; 19 | 20 | const CrashErrorContainer = ({ onResolve }) => { 21 | const history = useHistory(); 22 | 23 | const onClick = () => { 24 | history.push('/'); 25 | onResolve(); 26 | }; 27 | 28 | return ( 29 | 34 | 37 | 38 | ); 39 | }; 40 | 41 | export default CrashErrorContainer; 42 | -------------------------------------------------------------------------------- /src/containers/error/NotFoundContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useHistory } from 'react-router-dom'; 4 | 5 | import styled from '@emotion/styled'; 6 | 7 | import mq from '../../styles/responsive'; 8 | 9 | import useNotFound from '../../hooks/useNotFound'; 10 | 11 | import NotFoundSvg from '../../assets/icons/404.svg'; 12 | import ErrorScreenTemplate from '../../components/error/ErrorScreenTemplate'; 13 | 14 | const NotFoundImage = styled(NotFoundSvg)` 15 | ${mq({ 16 | width: ['500px', '700px', '800px'], 17 | })}; 18 | 19 | height: auto; 20 | `; 21 | 22 | const NotFoundContainer = () => { 23 | const history = useHistory(); 24 | const { reset } = useNotFound(); 25 | 26 | const onClick = () => { 27 | history.push('/'); 28 | reset(); 29 | }; 30 | 31 | return ( 32 | 37 | 40 | 41 | ); 42 | }; 43 | 44 | export default NotFoundContainer; 45 | -------------------------------------------------------------------------------- /src/pages/LoginPage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { render } from '@testing-library/react'; 6 | 7 | import LoginPage from './LoginPage'; 8 | import InjectMockProviders from '../components/common/test/InjectMockProviders'; 9 | 10 | describe('LoginPage', () => { 11 | const dispatch = jest.fn(); 12 | 13 | beforeEach(() => { 14 | dispatch.mockClear(); 15 | 16 | useDispatch.mockImplementation(() => dispatch); 17 | 18 | useSelector.mockImplementation((selector) => selector({ 19 | authReducer: { 20 | login: { 21 | userEmail: '', 22 | password: '', 23 | }, 24 | }, 25 | })); 26 | }); 27 | 28 | const renderLoginPage = () => render(( 29 | 30 | 31 | 32 | 33 | )); 34 | 35 | describe('renders Login page text contents', () => { 36 | it('renders Login page Title', () => { 37 | const { container } = renderLoginPage(); 38 | 39 | expect(container).toHaveTextContent('로그인'); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 saseungmin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/myInfo/MyInfoTab.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | import { render, fireEvent } from '@testing-library/react'; 6 | 7 | import MyInfoTab from './MyInfoTab'; 8 | 9 | describe('MyInfoTab', () => { 10 | const renderMyInfoTab = () => render(( 11 | 12 | 13 | 14 | )); 15 | 16 | const linkInfo = [ 17 | { name: '내 스터디 정보', url: '/myinfo/study' }, 18 | { name: '계정 설정', url: '/myinfo/setting' }, 19 | ]; 20 | 21 | it('render My Info Tab Contents', () => { 22 | const { container, getByText } = renderMyInfoTab(); 23 | 24 | linkInfo.forEach(({ name, url }) => { 25 | expect(container).toHaveTextContent(name); 26 | expect(getByText(name)).toHaveAttribute('href', url); 27 | }); 28 | }); 29 | 30 | it('Click on the link to change the color', () => { 31 | const { getByText } = renderMyInfoTab(); 32 | 33 | linkInfo.forEach(({ name }) => { 34 | fireEvent.click(getByText(name)); 35 | 36 | expect(getByText(name)).toHaveStyle('color: skyBlue'); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/pages/RegisterPage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { render } from '@testing-library/react'; 6 | 7 | import RegisterPage from './RegisterPage'; 8 | import InjectMockProviders from '../components/common/test/InjectMockProviders'; 9 | 10 | describe('RegisterPage', () => { 11 | const dispatch = jest.fn(); 12 | 13 | beforeEach(() => { 14 | dispatch.mockClear(); 15 | 16 | useDispatch.mockImplementation(() => dispatch); 17 | 18 | useSelector.mockImplementation((selector) => selector({ 19 | authReducer: { 20 | register: { 21 | userEmail: '', 22 | password: '', 23 | passwordConfirm: '', 24 | }, 25 | }, 26 | })); 27 | }); 28 | 29 | const renderRegisterPage = () => render(( 30 | 31 | 32 | 33 | )); 34 | 35 | describe('renders Register page text contents', () => { 36 | it('renders Register page Title', () => { 37 | const { container } = renderRegisterPage(); 38 | 39 | expect(container).toHaveTextContent('회원가입'); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /tests/main/main_test.js: -------------------------------------------------------------------------------- 1 | Feature('Main Page'); 2 | 3 | const step = codeceptjs.container.plugins('commentStep'); 4 | 5 | const Given = (given) => step`${given}`; 6 | const When = (when) => step`${when}`; 7 | const Then = (then) => step`${then}`; 8 | 9 | Scenario('로그인 후 메인 페이지에 대한 정보가 올바르게 보이는 경우', ({ I, login }) => { 10 | Given('메인 페이지에서'); 11 | 12 | When('로그인 후'); 13 | login('user'); 14 | 15 | Then('"스터디 개설하기" 버튼과 사용자의 아이디가 보인다.'); 16 | I.see('스터디 개설하기'); 17 | I.see('test@test.com'); 18 | }); 19 | 20 | Scenario('로그아웃 버튼 클릭 후 메인 페이지에 대한 정보가 올바르게 보이는 경우', ({ I, login }) => { 21 | Given('로그인한 사용자가 메인 페이지에서'); 22 | login('user'); 23 | 24 | When('로그아웃 후'); 25 | I.click('로그아웃'); 26 | 27 | Then('"스터디 개설하기" 버튼과 "로그아웃" 버튼 보이지 않는다.'); 28 | I.dontSee('스터디 개설하기'); 29 | I.dontSee('로그아웃'); 30 | }); 31 | 32 | Scenario('올바르게 다크 모드로 변경되는 경우', ({ I }) => { 33 | Given('메인 페이지에서'); 34 | I.amOnPage('/'); 35 | 36 | When('theme toggle 버튼을 클릭하면'); 37 | I.click({ css: '.react-toggle' }); 38 | 39 | Then('body 배경색이 어두운 색으로 바뀐다.'); 40 | I.seeCssPropertiesOnElements('body', { 41 | background: 'rgb(40, 44, 53) none repeat scroll 0% 0% / auto padding-box border-box', 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/components/loader/ResponsiveGroupContentLoader.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import MockTheme from '../common/test/MockTheme'; 6 | import ResponsiveGroupContentLoader from './ResponsiveGroupContentLoader'; 7 | 8 | describe('ResponsiveGroupContentLoader', () => { 9 | const renderResponsiveGroupContentLoader = ({ isDesktop, isMobile }) => render(( 10 | 11 | 15 | 16 | )); 17 | 18 | it('render desktop content loader', () => { 19 | const state = { 20 | isDesktop: true, 21 | isMobile: false, 22 | }; 23 | 24 | const { container } = renderResponsiveGroupContentLoader(state); 25 | 26 | expect(container).toHaveTextContent('desktop loading..'); 27 | }); 28 | 29 | it('render mobile content loader', () => { 30 | const state = { 31 | isDesktop: false, 32 | isMobile: true, 33 | }; 34 | 35 | const { container } = renderResponsiveGroupContentLoader(state); 36 | 37 | expect(container).toHaveTextContent('mobile loading..'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/pages/CrashErrorPage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import CrashErrorPage from './CrashErrorPage'; 6 | import InjectMockProviders from '../components/common/test/InjectMockProviders'; 7 | 8 | describe('CrashErrorPage', () => { 9 | const handleResolve = jest.fn(); 10 | 11 | beforeEach(() => { 12 | handleResolve.mockClear(); 13 | }); 14 | 15 | const renderCrashErrorPage = () => render(( 16 | 17 | 20 | 21 | )); 22 | 23 | describe('Renders CrashError Contents', () => { 24 | it('should be renders Message Text and "홈으로" Button', () => { 25 | const { container } = renderCrashErrorPage(); 26 | 27 | expect(container).toHaveTextContent('이런.. 오류가 발생했어요!'); 28 | expect(container).toHaveTextContent('홈으로'); 29 | }); 30 | 31 | it('Click "홈으로" button calls resolve event', () => { 32 | const { getByText } = renderCrashErrorPage(); 33 | 34 | fireEvent.click(getByText('홈으로')); 35 | 36 | expect(handleResolve).toBeCalled(); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/components/error/ErrorScreenTemplate.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import ErrorScreenTemplate from './ErrorScreenTemplate'; 6 | 7 | describe('ErrorScreenTemplate', () => { 8 | const handleClick = jest.fn(); 9 | 10 | beforeEach(() => { 11 | handleClick.mockClear(); 12 | }); 13 | 14 | const renderErrorScreenTemplate = ({ message, buttonText }) => render(( 15 | 20 | )); 21 | 22 | it('should be renders error template contents', () => { 23 | const { container } = renderErrorScreenTemplate({ 24 | message: '아무것도 없어요!', 25 | buttonText: '홈으로', 26 | }); 27 | 28 | expect(container).toHaveTextContent('아무것도 없어요!'); 29 | expect(container).toHaveTextContent('홈으로'); 30 | }); 31 | 32 | it('handle Click event', () => { 33 | const { getByText } = renderErrorScreenTemplate({ 34 | message: '아무것도 없어요!', 35 | buttonText: '홈으로', 36 | }); 37 | 38 | fireEvent.click(getByText('홈으로')); 39 | 40 | expect(handleClick).toBeCalled(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/styles/Button.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | import { render } from '@testing-library/react'; 6 | 7 | import Button from './Button'; 8 | 9 | describe('Button', () => { 10 | const renderButton = ({ to, warn }) => render(( 11 | 12 | 18 | 19 | )); 20 | 21 | context('with to, link', () => { 22 | const to = '/login'; 23 | it('renders tags name', () => { 24 | const { getByText } = renderButton({ to, warn: null }); 25 | 26 | expect(getByText('button').href).toBe('http://localhost/login'); 27 | }); 28 | 29 | it('check props true to be 1', () => { 30 | const { getByText } = renderButton({ to, warn: true }); 31 | 32 | expect(getByText('button')).toHaveStyle('color: white;'); 33 | }); 34 | }); 35 | 36 | context('without to', () => { 37 | it('nothing renders tags name', () => { 38 | const { getByText } = renderButton({}); 39 | 40 | expect(getByText('button').href).not.toBe('http://localhost/login'); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/components/introduce/modals/AskLoginModal.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import AskLoginModal from './AskLoginModal'; 6 | import MockTheme from '../../common/test/MockTheme'; 7 | 8 | describe('AskLoginModal', () => { 9 | const handleCancel = jest.fn(); 10 | 11 | const renderAskLoginModal = ({ visible }) => render(( 12 | 13 | 17 | 18 | )); 19 | 20 | context('with visible', () => { 21 | const modal = { 22 | visible: true, 23 | }; 24 | 25 | it('renders Modal text', () => { 26 | const { container } = renderAskLoginModal(modal); 27 | 28 | expect(container).toHaveTextContent('신청 실패'); 29 | expect(container).toHaveTextContent('로그인 후 신청 가능합니다.'); 30 | }); 31 | }); 32 | 33 | context('without visible', () => { 34 | const modal = { 35 | visible: false, 36 | }; 37 | 38 | it("doesn't renders Modal text", () => { 39 | const { container } = renderAskLoginModal(modal); 40 | 41 | expect(container).toBeEmptyDOMElement(); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/reducers/commonSlice.test.js: -------------------------------------------------------------------------------- 1 | import reducer, { 2 | changeTheme, setNotFound, resetError, 3 | } from './commonSlice'; 4 | 5 | jest.mock('../util/utils'); 6 | describe('reducer', () => { 7 | context('when previous state is undefined', () => { 8 | const initialState = { 9 | theme: undefined, 10 | errorType: null, 11 | }; 12 | 13 | it('returns initialState', () => { 14 | const state = reducer(undefined, { type: 'action' }); 15 | 16 | expect(state).toEqual(initialState); 17 | }); 18 | }); 19 | 20 | describe('changeTheme', () => { 21 | it('change theme state', () => { 22 | const state = reducer({ theme: false }, changeTheme()); 23 | 24 | expect(state.theme).toBe(true); 25 | }); 26 | }); 27 | 28 | describe('setNotFound', () => { 29 | it('should be set "NOT_FOUND" error type', () => { 30 | const state = reducer({ errorType: null }, setNotFound()); 31 | 32 | expect(state.errorType).toBe('NOT_FOUND'); 33 | }); 34 | }); 35 | 36 | describe('resetError', () => { 37 | it('should be reset error type', () => { 38 | const state = reducer({ errorType: 'NOT_FOUND' }, resetError()); 39 | 40 | expect(state.errorType).toBeNull(); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/hooks/useTheme.test.js: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux'; 2 | 3 | import { renderHook, act } from '@testing-library/react-hooks'; 4 | 5 | import useTheme from './useTheme'; 6 | 7 | import { LIGHT } from '../util/constants/theme'; 8 | 9 | describe('useTheme', () => { 10 | const dispatch = jest.fn(); 11 | 12 | beforeEach(() => { 13 | dispatch.mockClear(); 14 | 15 | useDispatch.mockImplementation(() => dispatch); 16 | 17 | useSelector.mockImplementation((state) => state({ 18 | commonReducer: { 19 | theme: LIGHT, 20 | }, 21 | })); 22 | }); 23 | 24 | const renderUseTheme = () => renderHook(() => useTheme()); 25 | 26 | describe('theme', () => { 27 | it('should return LIGHT(false)', () => { 28 | const { result } = renderUseTheme(); 29 | 30 | expect(result.current.theme).toBe(LIGHT); 31 | }); 32 | }); 33 | 34 | describe('Calls changeMode', () => { 35 | it('should be listens dispatch action', () => { 36 | const { result } = renderUseTheme(); 37 | 38 | act(() => { 39 | result.current.changeMode(); 40 | }); 41 | 42 | expect(dispatch).toBeCalledWith({ 43 | type: 'common/changeTheme', 44 | }); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/introduce/IntroduceHeader.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Helmet } from 'react-helmet-async'; 4 | 5 | import styled from '@emotion/styled'; 6 | 7 | import mq from '../../styles/responsive'; 8 | 9 | const IntroduceHeaderWrapper = styled.div` 10 | ${mq({ 11 | flexDirection: ['column', 'row'], 12 | paddingBottom: ['1rem', '1.5rem'], 13 | marginBottom: ['1rem', '2rem'], 14 | marginTop: ['2rem', '3rem'], 15 | })}; 16 | 17 | display: flex; 18 | justify-content: space-between; 19 | align-items: center; 20 | border-bottom: 2px solid ${({ theme }) => theme.borderTone[0]}; 21 | 22 | h1 { 23 | ${mq({ 24 | fontSize: ['1.7rem', '1.8rem', '2rem', '2.3rem'], 25 | width: ['100%', '50%'], 26 | })}; 27 | 28 | word-break: keep-all; 29 | overflow-wrap: break-word; 30 | text-rendering: optimizeLegibility; 31 | line-height: 1.5; 32 | margin: 0; 33 | } 34 | `; 35 | 36 | const IntroduceHeader = ({ title, children }) => ( 37 | <> 38 | 39 | {`ConStu | ${title}`} 40 | 41 | 42 |

{title}

43 | {children} 44 |
45 | 46 | ); 47 | 48 | export default IntroduceHeader; 49 | -------------------------------------------------------------------------------- /src/pages/IntroducePage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | import { useDispatch } from 'react-redux'; 4 | import { useParams } from 'react-router-dom'; 5 | 6 | import { loadStudyGroup } from '../reducers/groupSlice'; 7 | 8 | import GlobalBlock from '../styles/GlobalBlock'; 9 | import HeaderContainer from '../containers/base/HeaderContainer'; 10 | import ReviewContainer from '../containers/introduce/ReviewContainer'; 11 | import ThemeToggleContainer from '../containers/base/ThemeToggleContainer'; 12 | import IntroduceFormContainer from '../containers/introduce/IntroduceFormContainer'; 13 | import IntroduceHeaderContainer from '../containers/introduce/IntroduceHeaderContainer'; 14 | 15 | const IntroducePage = ({ params }) => { 16 | const { id } = params || useParams(); 17 | 18 | const dispatch = useDispatch(); 19 | 20 | useEffect(() => { 21 | dispatch(loadStudyGroup(id)); 22 | }, [dispatch, id]); 23 | 24 | return ( 25 | <> 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | export default React.memo(IntroducePage); 37 | -------------------------------------------------------------------------------- /src/pages/myInfo/MyInfoPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import { Helmet } from 'react-helmet-async'; 6 | import { Switch, Route } from 'react-router-dom'; 7 | 8 | import GlobalBlock from '../../styles/GlobalBlock'; 9 | 10 | import MyStudyInfoPage from './MyStudyInfoPage'; 11 | import ProfileSettingPage from './ProfileSettingPage'; 12 | import MyInfoTab from '../../components/myInfo/MyInfoTab'; 13 | import HeaderContainer from '../../containers/base/HeaderContainer'; 14 | 15 | const MyInfoPageWrapper = styled.div` 16 | 17 | `; 18 | 19 | const MyInfoTitle = styled.div` 20 | margin-bottom: 2rem; 21 | `; 22 | 23 | const MyInfoPage = () => ( 24 | <> 25 | 26 | ConStu | 내 정보 27 | 28 | 29 | 30 | 31 | 32 | 33 | 내 정보 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | 44 | export default MyInfoPage; 45 | -------------------------------------------------------------------------------- /src/pages/myInfo/ProfileSettingPage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { render } from '@testing-library/react'; 6 | 7 | import USER_DETAIL from '../../../fixtures/user-detail'; 8 | 9 | import ProfileSettingPage from './ProfileSettingPage'; 10 | import InjectMockProviders from '../../components/common/test/InjectMockProviders'; 11 | 12 | jest.mock('react-redux'); 13 | 14 | describe('ProfileSettingPage', () => { 15 | const dispatch = jest.fn(); 16 | 17 | beforeEach(() => { 18 | dispatch.mockClear(); 19 | 20 | useDispatch.mockImplementation(() => dispatch); 21 | 22 | useSelector.mockImplementation((selector) => selector({ 23 | authReducer: { 24 | user: USER_DETAIL.email, 25 | auth: null, 26 | authError: null, 27 | userDetail: USER_DETAIL, 28 | }, 29 | })); 30 | }); 31 | 32 | const renderProfileSettingPage = () => render(( 33 | 34 | 35 | 36 | )); 37 | 38 | it('renders My Info Setting Text Contents', () => { 39 | const { container } = renderProfileSettingPage(); 40 | 41 | expect(container).toHaveTextContent('계정 설정'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/services/storage.test.js: -------------------------------------------------------------------------------- 1 | import { saveItem, loadItem, removeItem } from './storage'; 2 | 3 | describe('storage', () => { 4 | jest.spyOn(window.localStorage.__proto__, 'setItem'); 5 | jest.spyOn(window.localStorage.__proto__, 'getItem'); 6 | 7 | beforeEach(() => { 8 | const mockStorage = {}; 9 | 10 | window.localStorage = { 11 | setItem: (key, val) => Object.assign(mockStorage, { [key]: val }), 12 | getItem: (key) => mockStorage[key], 13 | }; 14 | 15 | window.localStorage.__proto__.removeItem = jest.fn(); 16 | }); 17 | 18 | describe('saveItem', () => { 19 | const value = { 20 | value: 'value', 21 | }; 22 | it('calls localStorage setItem', () => { 23 | saveItem('key', value); 24 | 25 | expect(localStorage.setItem).toBeCalledWith('key', JSON.stringify(value)); 26 | }); 27 | }); 28 | 29 | describe('loadItem', () => { 30 | it('calls localStorage getItem', () => { 31 | loadItem('key'); 32 | 33 | expect(localStorage.getItem).toBeCalledWith('key'); 34 | }); 35 | }); 36 | 37 | describe('removeItem', () => { 38 | it('calls localStorage removeItem', () => { 39 | removeItem('key'); 40 | 41 | expect(localStorage.removeItem).toBeCalled(); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/util/constants/messages.js: -------------------------------------------------------------------------------- 1 | export const ERROR_MESSAGE = { 2 | NO_INPUT: '입력이 안된 사항이 있습니다.', 3 | NOT_MATCH_PASSWORD: '비밀번호가 일치하지 않습니다.', 4 | NO_TAG: '태그를 입력하세요.', 5 | FAST_APPLY_DEADLINE: '접수 마감날짜가 현재 시간보다 빠릅니다.', 6 | FAILURE_REGISTER: '회원가입에 실패하였습니다.', 7 | FAILURE_LOGIN: '로그인에 실패하였습니다.', 8 | NO_LOGGED_IN: '로그인 후 이용하세요.', 9 | NO_CONTENTS: '내용을 입력해주세요.', 10 | NO_TITLE: '제목을 입력해주세요.', 11 | NO_APPLY_DATE: '모집 마감 일자를 입력해주세요.', 12 | ERROR_PERSONNEL: '참여 인원 수를 입력하지 않았거나, 잘못된 값을 입력하였습니다.', 13 | FAILURE_OPEN_STUDY: '스터디 개설에 실패하였습니다.', 14 | FAILURE_EDIT_STUDY: '수정에 실패하였습니다.', 15 | FAILURE_SEND_EMAIL: '메일 전송에 실패하였습니다.', 16 | UNKNOWN: '알 수 없는 오류가 발생했습니다.', 17 | }; 18 | 19 | export const FIREBASE_AUTH_ERROR_MESSAGE = { 20 | 'auth/email-already-in-use': '이미 가입된 사용자입니다.', 21 | 'auth/weak-password': '6자리 이상의 비밀번호를 입력하세요.', 22 | 'auth/too-many-requests': '잠시 후 다시 시도해 주세요.', 23 | 'auth/wrong-password': '비밀번호가 일치하지 않습니다.', 24 | 'auth/user-not-found': '가입된 사용자가 아닙니다.', 25 | 'auth/invalid-email': '이메일 형식으로 입력하세요.', 26 | }; 27 | 28 | export const FIREBASE_GROUP_ERROR_MESSAGE = { 29 | 'permission-denied': '권한이 거부되었습니다.', 30 | }; 31 | 32 | export const SUCCESS_AUTH_MESSAGE = { 33 | CONFIRM_EMAIL: '이메일을 확인해주세요!', 34 | MEMBERSHIP_WITHDRAWAL: '탈퇴되었습니다.', 35 | UPDATE_PROFILE: '정상적으로 저장되었습니다', 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/loader/DesktopGroupContentLoader.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import React from 'react'; 3 | 4 | import { useTheme } from '@emotion/react'; 5 | 6 | import ContentLoader from 'react-content-loader'; 7 | 8 | const DesktopGroupContentLoader = (props) => { 9 | const theme = useTheme(); 10 | 11 | return ( 12 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | }; 37 | 38 | export default DesktopGroupContentLoader; 39 | -------------------------------------------------------------------------------- /src/containers/error/CrashErrorContainer.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import CrashErrorContainer from './CrashErrorContainer'; 6 | 7 | const mockPush = jest.fn(); 8 | 9 | jest.mock('react-router-dom', () => ({ 10 | ...jest.requireActual('react-router-dom'), 11 | useHistory() { 12 | return { 13 | push: mockPush, 14 | }; 15 | }, 16 | })); 17 | 18 | describe('CrashErrorContainer', () => { 19 | const handleResolve = jest.fn(); 20 | 21 | beforeEach(() => { 22 | handleResolve.mockClear(); 23 | }); 24 | 25 | const renderCrashErrorContainer = () => render(( 26 | 29 | )); 30 | 31 | it('should be renders error template contents', () => { 32 | const { container, getByTestId } = renderCrashErrorContainer(); 33 | 34 | expect(container).toHaveTextContent('이런.. 오류가 발생했어요!'); 35 | expect(container).toHaveTextContent('홈으로'); 36 | expect(getByTestId('computer-image')).not.toBeNull(); 37 | }); 38 | 39 | it('handle Click event', () => { 40 | const { getByText } = renderCrashErrorContainer(); 41 | 42 | fireEvent.click(getByText('홈으로')); 43 | 44 | expect(mockPush).toBeCalledWith('/'); 45 | expect(handleResolve).toBeCalled(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/containers/error/NotFoundContainer.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch } from 'react-redux'; 4 | 5 | import { render, fireEvent } from '@testing-library/react'; 6 | 7 | import NotFoundContainer from './NotFoundContainer'; 8 | 9 | const mockPush = jest.fn(); 10 | 11 | jest.mock('react-router-dom', () => ({ 12 | ...jest.requireActual('react-router-dom'), 13 | useHistory() { 14 | return { 15 | push: mockPush, 16 | }; 17 | }, 18 | })); 19 | 20 | describe('NotFoundContainer', () => { 21 | const dispatch = jest.fn(); 22 | 23 | beforeEach(() => { 24 | dispatch.mockClear(); 25 | 26 | useDispatch.mockImplementation(() => dispatch); 27 | }); 28 | 29 | const renderNotFoundContainer = () => render(( 30 | 31 | )); 32 | 33 | it('should be renders error template contents', () => { 34 | const { container } = renderNotFoundContainer(); 35 | 36 | expect(container).toHaveTextContent('아무것도 없어요!'); 37 | expect(container).toHaveTextContent('홈으로'); 38 | }); 39 | 40 | it('handle Click event', () => { 41 | const { getByText } = renderNotFoundContainer(); 42 | 43 | fireEvent.click(getByText('홈으로')); 44 | 45 | expect(mockPush).toBeCalledWith('/'); 46 | expect(dispatch).toBeCalledWith({ 47 | type: 'common/resetError', 48 | }); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/assets/icons/user.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/styles/palette.js: -------------------------------------------------------------------------------- 1 | const palette = { 2 | gray: [ 3 | '#f8f9fa', 4 | '#f1f3f5', 5 | '#e9ecef', 6 | '#dee2e6', 7 | '#ced4da', 8 | '#adb5bd', 9 | '#868e96', 10 | '#495057', 11 | '#343a40', 12 | '#212529', 13 | ], 14 | cyan: [ 15 | '#e3fafc', 16 | '#c5f6fa', 17 | '#99e9f2', 18 | '#66d9e8', 19 | '#3bc9db', 20 | '#22b8cf', 21 | '#15aabf', 22 | '#1098ad', 23 | '#0c8599', 24 | '#0b7285', 25 | ], 26 | orange: [ 27 | '#fff4e6', 28 | '#ffe8cc', 29 | '#ffd8a8', 30 | '#ffc078', 31 | '#ffa94d', 32 | '#ff922b', 33 | '#fd7e14', 34 | '#f76707', 35 | '#e8590c', 36 | '#d9480f', 37 | ], 38 | teal: [ 39 | '#e6fcf5', 40 | '#c3fae8', 41 | '#96f2d7', 42 | '#63e6be', 43 | '#38d9a9', 44 | '#20c997', 45 | '#12b886', 46 | '#0ca678', 47 | '#099268', 48 | '#087f5b', 49 | ], 50 | violet: [ 51 | '#f3f0ff', 52 | '#e5dbff', 53 | '#d0bfff', 54 | '#b197fc', 55 | '#9775fa', 56 | '#845ef7', 57 | '#7950f2', 58 | '#7048e8', 59 | '#6741d9', 60 | '#5f3dc4', 61 | ], 62 | warn: [ 63 | '#ffa8a8', 64 | '#ff8787', 65 | '#ff6b6b', 66 | ], 67 | globalPaint: [ 68 | '#FCF6F5', 69 | '#c9c3c2', 70 | ], 71 | fontColor: [ 72 | '#222426', 73 | ], 74 | }; 75 | 76 | export default palette; 77 | -------------------------------------------------------------------------------- /src/containers/introduce/IntroduceFormContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { useInterval } from 'react-use'; 4 | import { useHistory } from 'react-router-dom'; 5 | import { useDispatch, useSelector } from 'react-redux'; 6 | 7 | import { getAuth, getGroup } from '../../util/utils'; 8 | 9 | import { deleteGroup, setOriginalArticle } from '../../reducers/groupSlice'; 10 | 11 | import IntroduceForm from '../../components/introduce/IntroduceForm'; 12 | 13 | const IntroduceFormContainer = () => { 14 | const [realTime, setRealTime] = useState(Date.now()); 15 | 16 | const history = useHistory(); 17 | const dispatch = useDispatch(); 18 | 19 | const group = useSelector(getGroup('group')); 20 | const user = useSelector(getAuth('user')); 21 | 22 | useInterval(() => setRealTime(Date.now()), 1000); 23 | 24 | const onRemove = (id) => { 25 | dispatch(deleteGroup(id)); 26 | 27 | history.push('/'); 28 | }; 29 | 30 | const onEdit = () => { 31 | dispatch(setOriginalArticle(group)); 32 | 33 | history.push('/write'); 34 | }; 35 | 36 | if (!group) { 37 | return null; 38 | } 39 | 40 | return ( 41 | 48 | ); 49 | }; 50 | 51 | export default React.memo(IntroduceFormContainer); 52 | -------------------------------------------------------------------------------- /public/assets/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/components/common/FormModalWindow.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import FormModalWindow from './FormModalWindow'; 6 | import MockTheme from './test/MockTheme'; 7 | 8 | describe('FormModalWindow', () => { 9 | const handleConfirm = jest.fn(); 10 | const handleCancel = jest.fn(); 11 | 12 | const renderFormModalWindow = ({ visible, title }) => render(( 13 | 14 | 20 |

test

21 |
22 |
23 | )); 24 | 25 | context('with visible', () => { 26 | const modal = { 27 | visible: true, 28 | title: '타이틀', 29 | }; 30 | 31 | it('renders Modal text', () => { 32 | const { container } = renderFormModalWindow(modal); 33 | 34 | expect(container).toHaveTextContent('타이틀'); 35 | expect(container).toHaveTextContent('test'); 36 | }); 37 | }); 38 | 39 | context('without visible', () => { 40 | const modal = { 41 | visible: false, 42 | title: '타이틀', 43 | }; 44 | 45 | it("doesn't renders Modal text", () => { 46 | const { container } = renderFormModalWindow(modal); 47 | 48 | expect(container).toBeEmptyDOMElement(); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/components/common/ModalWindow.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import ModalWindow from './ModalWindow'; 6 | import MockTheme from './test/MockTheme'; 7 | 8 | describe('ModalWindow', () => { 9 | const handleConfirm = jest.fn(); 10 | const handleCancel = jest.fn(); 11 | 12 | const renderModalWindow = ({ visible, title, description }) => render(( 13 | 14 | 21 | 22 | )); 23 | 24 | context('with visible', () => { 25 | const modal = { 26 | visible: true, 27 | title: '타이틀', 28 | description: '내용', 29 | }; 30 | 31 | it('renders Modal text', () => { 32 | const { container } = renderModalWindow(modal); 33 | 34 | expect(container).toHaveTextContent('타이틀'); 35 | expect(container).toHaveTextContent('내용'); 36 | }); 37 | }); 38 | 39 | context('without visible', () => { 40 | const modal = { 41 | visible: false, 42 | title: '타이틀', 43 | description: '내용', 44 | }; 45 | 46 | it("doesn't renders Modal text", () => { 47 | const { container } = renderModalWindow(modal); 48 | 49 | expect(container).toBeEmptyDOMElement(); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/ErrorBoundary.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | /* eslint-disable react/destructuring-assignment */ 3 | import React from 'react'; 4 | 5 | import * as Sentry from '@sentry/react'; 6 | 7 | import useNotFound from './hooks/useNotFound'; 8 | 9 | import NotFoundPage from './pages/NotFoundPage'; 10 | import CrashErrorPage from './pages/CrashErrorPage'; 11 | 12 | function ErrorBoundaryWrapper({ children }) { 13 | const { isNotFound } = useNotFound(); 14 | 15 | if (isNotFound) { 16 | return ; 17 | } 18 | 19 | return <>{children}; 20 | } 21 | 22 | class ErrorBoundary extends React.Component { 23 | constructor(props) { 24 | super(props); 25 | this.state = { 26 | hasError: false, 27 | }; 28 | this.handleResolveError = this.handleResolveError.bind(this); 29 | } 30 | 31 | static getDerivedStateFromError(error) { 32 | return { 33 | hasError: true, 34 | }; 35 | } 36 | 37 | componentDidCatch(error, errorInfo) { 38 | Sentry.captureException(error); 39 | } 40 | 41 | handleResolveError() { 42 | this.setState({ 43 | hasError: false, 44 | }); 45 | } 46 | 47 | render() { 48 | if (this.state.hasError) { 49 | return ; 50 | } 51 | 52 | return ( 53 | 54 | {this.props.children} 55 | 56 | ); 57 | } 58 | } 59 | 60 | export default ErrorBoundary; 61 | -------------------------------------------------------------------------------- /src/components/write/TagList.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { fireEvent, render } from '@testing-library/react'; 4 | 5 | import TagList from './TagList'; 6 | import MockTheme from '../common/test/MockTheme'; 7 | 8 | describe('TagList', () => { 9 | const handleRemove = jest.fn(); 10 | 11 | beforeEach(() => { 12 | handleRemove.mockClear(); 13 | }); 14 | 15 | const renderTagList = (tags) => render(( 16 | 17 | 21 | 22 | )); 23 | 24 | context('without tags', () => { 25 | it('nothing renders tags', () => { 26 | const { container } = renderTagList([]); 27 | 28 | expect(container).not.toHaveTextContent('#JavaScript'); 29 | }); 30 | }); 31 | 32 | context('with tags', () => { 33 | const tags = ['JavaScript', 'React']; 34 | 35 | it('renders tags', () => { 36 | const { container } = renderTagList(tags); 37 | 38 | tags.forEach((tag) => { 39 | expect(container).toHaveTextContent(`#${tag}`); 40 | }); 41 | }); 42 | 43 | it('listens click event to remove', () => { 44 | const { getByText } = renderTagList(tags); 45 | 46 | tags.forEach((tag) => { 47 | expect(getByText(`#${tag}`)).not.toBeNull(); 48 | 49 | fireEvent.click(getByText(`#${tag}`).nextElementSibling); 50 | }); 51 | expect(handleRemove).toBeCalledTimes(2); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/components/introduce/IntroduceActionButtons.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { fireEvent, render } from '@testing-library/react'; 4 | 5 | import MockTheme from '../common/test/MockTheme'; 6 | import IntroduceActionButtons from './IntroduceActionButtons'; 7 | 8 | describe('IntroduceActionButtons', () => { 9 | const handleRemove = jest.fn(); 10 | 11 | beforeEach(() => { 12 | handleRemove.mockClear(); 13 | }); 14 | 15 | const renderIntroduceActionButtons = () => render(( 16 | 17 | 20 | 21 | )); 22 | 23 | it('renders revise button and delete button', () => { 24 | const { container } = renderIntroduceActionButtons(); 25 | 26 | expect(container).toHaveTextContent('수정'); 27 | expect(container).toHaveTextContent('삭제'); 28 | }); 29 | 30 | context('When the Cancel button is pressed', () => { 31 | it("doesn't remove call", () => { 32 | const { getByText } = renderIntroduceActionButtons(); 33 | 34 | fireEvent.click(getByText('삭제')); 35 | 36 | fireEvent.click(getByText('취소')); 37 | 38 | expect(handleRemove).not.toBeCalled(); 39 | }); 40 | }); 41 | 42 | context('When the Confirm button is pressed', () => { 43 | it('call remove event', () => { 44 | const { getByText } = renderIntroduceActionButtons(); 45 | 46 | fireEvent.click(getByText('삭제')); 47 | 48 | fireEvent.click(getByText('확인')); 49 | 50 | expect(handleRemove).toBeCalledTimes(1); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/components/introduce/ReviewList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import _ from 'lodash'; 4 | 5 | import styled from '@emotion/styled'; 6 | 7 | import mq from '../../styles/responsive'; 8 | 9 | import Review from './Review'; 10 | import AverageReview from './AverageReview'; 11 | 12 | const ReviewWrapper = styled.div` 13 | ${mq({ 14 | margin: ['1rem 0 2rem 0', '2rem 0 3rem 0'], 15 | })}; 16 | `; 17 | 18 | const EmptyReviewWrapper = styled.div` 19 | ${mq({ 20 | fontSize: ['1rem', '1.1rem'], 21 | fontWeight: ['lighter', 'bold'], 22 | padding: ['35px', '45px'], 23 | margin: ['1rem 0 2rem 0', '2rem 0 3rem 0'], 24 | })}; 25 | 26 | background-color: ${({ theme }) => theme.reviewColor[0]}; 27 | color: ${({ theme }) => theme.hoverFontColor[0]}; 28 | display: flex; 29 | align-items: center; 30 | flex-direction: column; 31 | border: 1px solid ${({ theme }) => theme.borderTone[3]}; 32 | border-radius: 5px; 33 | `; 34 | 35 | const ReviewList = ({ user, reviews, onDelete }) => { 36 | if (_.isEmpty(reviews)) { 37 | return ( 38 | 39 | 등록된 후기가 존재하지 않습니다! 40 | 41 | ); 42 | } 43 | 44 | return ( 45 | 46 | 49 | {reviews.map((review) => ( 50 | 56 | ))} 57 | 58 | ); 59 | }; 60 | 61 | export default ReviewList; 62 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Switch, Route } from 'react-router-dom'; 4 | 5 | import { ThemeProvider } from '@emotion/react'; 6 | 7 | import useTheme from './hooks/useTheme'; 8 | 9 | import { lightTheme, darkTheme } from './styles/theme'; 10 | 11 | import Core from './components/base/Core'; 12 | import ErrorBoundary from './ErrorBoundary'; 13 | import PrivateRoute from './components/common/PrivateRoute'; 14 | 15 | import MainPage from './pages/MainPage'; 16 | import WritePage from './pages/WritePage'; 17 | import LoginPage from './pages/LoginPage'; 18 | import MyInfoPage from './pages/myInfo/MyInfoPage'; 19 | import RegisterPage from './pages/RegisterPage'; 20 | import NotFoundPage from './pages/NotFoundPage'; 21 | import IntroducePage from './pages/IntroducePage'; 22 | 23 | const App = () => { 24 | const { theme } = useTheme(); 25 | 26 | return ( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default App; 45 | -------------------------------------------------------------------------------- /src/components/common/Tags.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | import { render } from '@testing-library/react'; 6 | 7 | import Tags from './Tags'; 8 | import MockTheme from './test/MockTheme'; 9 | 10 | describe('Tags', () => { 11 | const renderTags = ({ tags = [], type }) => render(( 12 | 13 | 14 | 18 | 19 | 20 | )); 21 | 22 | context('with tags', () => { 23 | const tags = ['JavaScript', 'C', 'Python']; 24 | it('renders tags name', () => { 25 | const { container } = renderTags({ tags }); 26 | 27 | tags.forEach((tag) => { 28 | expect(container).toHaveTextContent(tag); 29 | 30 | expect(container.innerHTML).toContain(' { 36 | it('nothing renders tags name', () => { 37 | const tags = []; 38 | 39 | const { container } = renderTags({ tags }); 40 | 41 | expect(container).toBeEmptyDOMElement(); 42 | }); 43 | }); 44 | 45 | context('with type introduce', () => { 46 | const tags = ['JavaScript', 'C', 'Python']; 47 | const type = 'introduce'; 48 | 49 | it('renders tags name', () => { 50 | const { container } = renderTags({ tags, type }); 51 | 52 | tags.forEach((tag) => { 53 | expect(container).toHaveTextContent(tag); 54 | 55 | expect(container.innerHTML).not.toContain(' { 9 | const theme = useTheme(); 10 | 11 | return ( 12 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default MobileGroupContentLoader; 46 | -------------------------------------------------------------------------------- /src/components/introduce/ReviewList.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import ReviewList from './ReviewList'; 6 | import MockTheme from '../common/test/MockTheme'; 7 | 8 | describe('ReviewList', () => { 9 | const mockReviews = [{ 10 | id: 'test@test.com', 11 | rating: 3, 12 | content: 'review', 13 | createdDate: new Date(), 14 | }]; 15 | 16 | const handleClick = jest.fn(); 17 | 18 | const renderReviewList = (reviews) => render(( 19 | 20 | 25 | 26 | )); 27 | 28 | context('With reviews', () => { 29 | it('Render reviews', () => { 30 | const { container } = renderReviewList(mockReviews); 31 | 32 | expect(container).toHaveTextContent('스터디를 참여한 1명의 회원 평균평점'); 33 | expect(container).toHaveTextContent('6.0'); 34 | expect(container).toHaveTextContent('review'); 35 | expect(container).toHaveTextContent('test@test.com'); 36 | }); 37 | 38 | it('Listen delete click events', () => { 39 | const { getByTestId } = renderReviewList(mockReviews); 40 | 41 | fireEvent.click(getByTestId('close-icon')); 42 | 43 | expect(handleClick).toBeCalledTimes(1); 44 | }); 45 | }); 46 | 47 | context('Without reviews', () => { 48 | it('Render nothing review message', () => { 49 | const { container } = renderReviewList([]); 50 | 51 | expect(container).toHaveTextContent('등록된 후기가 존재하지 않습니다!'); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/components/loader/ResponsiveGroupsContentLoader.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import MockTheme from '../common/test/MockTheme'; 6 | import ResponsiveGroupsContentLoader from './ResponsiveGroupsContentLoader'; 7 | 8 | describe('ResponsiveGroupsContentLoader', () => { 9 | const renderResponsiveGroupsContentLoader = ({ isDesktop, isTablet, isMobile }) => render(( 10 | 11 | 16 | 17 | )); 18 | 19 | it('render desktop content loader', () => { 20 | const state = { 21 | isDesktop: true, 22 | isTablet: false, 23 | isMobile: false, 24 | }; 25 | 26 | const { container } = renderResponsiveGroupsContentLoader(state); 27 | 28 | expect(container).toHaveTextContent('desktop loading..'); 29 | }); 30 | 31 | it('render tablet content loader', () => { 32 | const state = { 33 | isDesktop: false, 34 | isTablet: true, 35 | isMobile: false, 36 | }; 37 | 38 | const { container } = renderResponsiveGroupsContentLoader(state); 39 | 40 | expect(container).toHaveTextContent('tablet loading..'); 41 | }); 42 | 43 | it('render mobile content loader', () => { 44 | const state = { 45 | isDesktop: false, 46 | isTablet: false, 47 | isMobile: true, 48 | }; 49 | 50 | const { container } = renderResponsiveGroupsContentLoader(state); 51 | 52 | expect(container).toHaveTextContent('mobile loading..'); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/containers/write/WriteButtonsContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useCallback } from 'react'; 2 | 3 | import { useUnmount } from 'react-use'; 4 | import { useHistory } from 'react-router-dom'; 5 | import { useDispatch, useSelector } from 'react-redux'; 6 | 7 | import { getGroup } from '../../util/utils'; 8 | import { clearWriteFields, editStudyGroup, writeStudyGroup } from '../../reducers/groupSlice'; 9 | 10 | import WriteButtons from '../../components/write/WriteButtons'; 11 | 12 | const WriteButtonsContainer = () => { 13 | const history = useHistory(); 14 | const dispatch = useDispatch(); 15 | 16 | const groupId = useSelector(getGroup('groupId')); 17 | const groupError = useSelector(getGroup('groupError')); 18 | const writeField = useSelector(getGroup('writeField')); 19 | const originalArticleId = useSelector(getGroup('originalArticleId')); 20 | 21 | const onSubmit = () => { 22 | if (originalArticleId) { 23 | dispatch(editStudyGroup(originalArticleId)); 24 | return; 25 | } 26 | 27 | dispatch(writeStudyGroup()); 28 | }; 29 | 30 | useEffect(() => { 31 | if (groupId) { 32 | history.push(`/introduce/${groupId}`); 33 | } 34 | }, [history, groupId]); 35 | 36 | const onCancel = useCallback(() => { 37 | history.push('/'); 38 | }, [history]); 39 | 40 | useUnmount(() => dispatch(clearWriteFields())); 41 | 42 | return ( 43 | 50 | ); 51 | }; 52 | 53 | export default WriteButtonsContainer; 54 | -------------------------------------------------------------------------------- /src/containers/write/WriteEditorContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import htmlToDraft from 'html-to-draftjs'; 6 | import draftToHtml from 'draftjs-to-html'; 7 | import { ContentState, convertToRaw, EditorState } from 'draft-js'; 8 | 9 | import { getGroup } from '../../util/utils'; 10 | import { changeWriteField } from '../../reducers/groupSlice'; 11 | 12 | import WriteEditor from '../../components/write/WriteEditor'; 13 | 14 | const WriteEditorContainer = () => { 15 | const [editorState, setEditorState] = useState(EditorState.createEmpty()); 16 | 17 | const dispatch = useDispatch(); 18 | 19 | const { contents } = useSelector(getGroup('writeField')); 20 | 21 | const onChangeContent = useCallback((state) => { 22 | const value = draftToHtml(convertToRaw(state.getCurrentContent())); 23 | 24 | setEditorState(state); 25 | 26 | dispatch( 27 | changeWriteField({ 28 | name: 'contents', 29 | value, 30 | }), 31 | ); 32 | }, [dispatch]); 33 | 34 | useEffect(() => { 35 | if (contents) { 36 | const { contentBlocks, entityMap } = htmlToDraft(contents); 37 | 38 | const contentState = ContentState.createFromBlockArray(contentBlocks, entityMap); 39 | const editorValueState = EditorState.createWithContent(contentState); 40 | setEditorState(editorValueState); 41 | } 42 | }, []); 43 | 44 | return ( 45 | 49 | ); 50 | }; 51 | 52 | export default WriteEditorContainer; 53 | -------------------------------------------------------------------------------- /src/components/introduce/Review.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import Review from './Review'; 6 | import MockTheme from '../common/test/MockTheme'; 7 | 8 | describe('Review', () => { 9 | const mockReview = { 10 | id: 'test@test.com', 11 | rating: 3, 12 | content: 'review', 13 | createDate: new Date(), 14 | }; 15 | 16 | const handleClick = jest.fn(); 17 | 18 | const renderReview = (review) => render(( 19 | 20 | 25 | 26 | )); 27 | 28 | context("When it's my review", () => { 29 | given('user', () => ('test@test.com')); 30 | 31 | it('Render review contents', () => { 32 | const { container, getByTestId } = renderReview(mockReview); 33 | 34 | expect(container).toHaveTextContent('review'); 35 | expect(container).toHaveTextContent('test@test.com'); 36 | expect(getByTestId('close-icon')).not.toBeNull(); 37 | }); 38 | 39 | it('Listen delete click events', () => { 40 | const { getByTestId } = renderReview(mockReview); 41 | 42 | fireEvent.click(getByTestId('close-icon')); 43 | 44 | expect(handleClick).toBeCalledTimes(1); 45 | }); 46 | }); 47 | 48 | context("When it's my review", () => { 49 | given('user', () => ('test')); 50 | 51 | it('Render review contents', () => { 52 | const { container } = renderReview(mockReview); 53 | 54 | expect(container).toHaveTextContent('review'); 55 | expect(container).toHaveTextContent('test@test.com'); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import { Provider } from 'react-redux'; 5 | import { BrowserRouter } from 'react-router-dom'; 6 | 7 | import { HelmetProvider } from 'react-helmet-async'; 8 | 9 | import * as Sentry from '@sentry/react'; 10 | import { Integrations } from '@sentry/tracing'; 11 | 12 | import store from './reducers/store'; 13 | import { setUser, setUserDetail } from './reducers/authSlice'; 14 | 15 | import { isDevLevel } from './util/utils'; 16 | import { auth } from './services/firebase'; 17 | import { loadItem } from './services/storage'; 18 | 19 | import App from './App'; 20 | 21 | Sentry.init({ 22 | dsn: !isDevLevel(process.env.NODE_ENV) && process.env.SENTRY_DSN, 23 | integrations: [new Integrations.BrowserTracing()], 24 | environment: process.env.NODE_ENV, 25 | tracesSampleRate: 1.0, 26 | }); 27 | 28 | const loadUser = () => { 29 | const user = loadItem('user'); 30 | 31 | if (!user) { 32 | return; 33 | } 34 | 35 | const { email } = user; 36 | 37 | store.dispatch(setUser(email)); 38 | }; 39 | 40 | loadUser(); 41 | 42 | auth.onAuthStateChanged((user) => { 43 | if (!user) { 44 | return; 45 | } 46 | 47 | const { 48 | email, emailVerified, displayName, photoURL, 49 | } = user; 50 | 51 | store.dispatch(setUserDetail({ 52 | email, 53 | emailVerified, 54 | displayName, 55 | photoURL, 56 | })); 57 | }); 58 | 59 | ReactDOM.render( 60 | ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | ), 69 | document.getElementById('app'), 70 | ); 71 | -------------------------------------------------------------------------------- /src/styles/Textarea.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import React from 'react'; 3 | 4 | import styled from '@emotion/styled'; 5 | import { css } from '@emotion/react'; 6 | 7 | import mq from './responsive'; 8 | import palette from './palette'; 9 | 10 | const TextareaWrapper = styled.textarea` 11 | ${({ theme }) => mq({ 12 | border: [`1px solid ${theme.reviewColor[2]}`, `2px solid ${theme.reviewColor[2]}`], 13 | })}; 14 | 15 | font-family: 'Nanum Gothic', sans-serif; 16 | font-size: .9rem; 17 | background: ${({ theme }) => theme.baseTone}; 18 | color: ${({ theme }) => theme.reviewColor[1]}; 19 | resize: none; 20 | outline: none; 21 | line-height: 17px; 22 | display: block; 23 | margin-bottom: 0.7rem; 24 | padding: 6px; 25 | border-radius: 3px; 26 | transition-property: all; 27 | transition-delay: initial; 28 | transition-duration: 0.15s; 29 | transition-timing-function: ease-in-out; 30 | 31 | &:focus { 32 | ${({ error }) => !error && css` 33 | ${mq({ border: [`1px solid ${palette.teal[5]}`, `2px solid ${palette.teal[5]}`] })}; 34 | `}; 35 | } 36 | 37 | ${({ error }) => error && css` 38 | @keyframes shake { 39 | 0% { left: -5px; } 40 | 100% { right: -5px; } 41 | }; 42 | ${mq({ border: [`1px solid ${palette.warn[1]}`, `2px solid ${palette.warn[1]}`] })}; 43 | 44 | position: relative; 45 | animation: shake .1s linear; 46 | animation-iteration-count: 3; 47 | 48 | &::placeholder { 49 | color: ${palette.warn[1]}; 50 | } 51 | `}; 52 | `; 53 | 54 | const Textarea = React.forwardRef((props, ref) => ( 55 | 56 | )); 57 | 58 | export default Textarea; 59 | -------------------------------------------------------------------------------- /src/components/common/PrivateRoute.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useSelector } from 'react-redux'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | 6 | import { render } from '@testing-library/react'; 7 | 8 | import PrivateRoute from './PrivateRoute'; 9 | 10 | jest.mock('react-redux'); 11 | 12 | jest.mock('react-router-dom', () => ({ 13 | ...jest.requireActual('react-router-dom'), 14 | Redirect: jest.fn(({ to: { pathname } }) => (`Redirected to ${pathname}`)), 15 | })); 16 | 17 | describe('PrivateRoute', () => { 18 | const renderPrivateRoute = ({ component }) => render(( 19 | 20 | 23 | 24 | )); 25 | 26 | beforeEach(() => { 27 | useSelector.mockImplementation((selector) => selector({ 28 | authReducer: { 29 | user: given.user, 30 | }, 31 | })); 32 | }); 33 | 34 | context('with user', () => { 35 | given('user', () => 'mockUser'); 36 | const MockComponent = () => ( 37 |

정상입니다!

38 | ); 39 | 40 | it('renders component TextContent "정상입니다!"', () => { 41 | const { container } = renderPrivateRoute({ 42 | component: MockComponent, 43 | }); 44 | 45 | expect(container).toHaveTextContent('정상입니다'); 46 | }); 47 | }); 48 | 49 | context('without user', () => { 50 | given('user', () => null); 51 | const MockComponent = () => ( 52 |

실패입니다!

53 | ); 54 | 55 | it('redirect to "/login"', () => { 56 | const { container } = renderPrivateRoute({ 57 | component: MockComponent, 58 | }); 59 | 60 | expect(container).toHaveTextContent('Redirected to /login'); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/components/main/StudyGroups.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import STUDY_GROUPS from '../../../fixtures/study-groups'; 6 | 7 | import StudyGroups from './StudyGroups'; 8 | import InjectMockProviders from '../common/test/InjectMockProviders'; 9 | 10 | describe('StudyGroups', () => { 11 | const handleThemeChange = jest.fn(); 12 | 13 | const renderStudyGroups = ({ groups, width = 700, user = 'test' }) => render(( 14 | 15 | 21 | 22 | )); 23 | 24 | context('When desktop screen', () => { 25 | it('renders "스터디 개설하기" button', () => { 26 | const { container } = renderStudyGroups({ groups: STUDY_GROUPS }); 27 | 28 | expect(container).toHaveTextContent('스터디 개설하기'); 29 | }); 30 | 31 | it('renders study group list text contents', () => { 32 | const { container } = renderStudyGroups({ groups: STUDY_GROUPS }); 33 | 34 | STUDY_GROUPS.forEach(({ moderatorId, title, personnel }) => { 35 | expect(container).toHaveTextContent(title); 36 | expect(container).toHaveTextContent(personnel); 37 | expect(container).toHaveTextContent(moderatorId); 38 | }); 39 | }); 40 | }); 41 | 42 | context('When mobile screen', () => { 43 | it('renders "+" button', () => { 44 | const { getByTestId, container } = renderStudyGroups({ groups: STUDY_GROUPS, width: 400 }); 45 | 46 | expect(container.innerHTML).toContain(' sky && css` 25 | border-bottom: 2px solid #4dabf7; 26 | box-shadow: 0px 1px 2px #4dabf7; 27 | background: #74c0fc; 28 | 29 | &:hover { 30 | box-shadow: none; 31 | border-bottom: 2px solid #74c0fc; 32 | } 33 | `}; 34 | 35 | ${({ confirm }) => confirm && css` 36 | border-bottom: 2px solid ${palette.teal[6]}; 37 | box-shadow: 0px 1px 2px ${palette.teal[6]}; 38 | background: ${palette.teal[5]}; 39 | 40 | &:hover { 41 | box-shadow: none; 42 | border-bottom: 2px solid ${palette.teal[5]}; 43 | } 44 | `}; 45 | 46 | ${({ cancel }) => cancel && css` 47 | border-bottom: 2px solid ${palette.warn[2]}; 48 | box-shadow: 0px 1px 2px ${palette.warn[2]}; 49 | background: ${palette.warn[1]}; 50 | 51 | &:hover { 52 | box-shadow: none; 53 | border-bottom: 2px solid ${palette.warn[1]}; 54 | } 55 | `}; 56 | `; 57 | 58 | const ParticipantListButton = (props) => ( 59 | 60 | ); 61 | 62 | export default ParticipantListButton; 63 | -------------------------------------------------------------------------------- /src/components/base/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Link } from 'react-router-dom'; 4 | 5 | import styled from '@emotion/styled'; 6 | 7 | import mq from '../../styles/responsive'; 8 | 9 | import { LOGIN, REGISTER } from '../../util/constants/constants'; 10 | 11 | import Button from '../../styles/Button'; 12 | import GlobalBlock from '../../styles/GlobalBlock'; 13 | import UserHeaderStatus from './UserHeaderStatus'; 14 | 15 | const HeaderWrapper = styled.div` 16 | position: fixed; 17 | width: 100%; 18 | z-index: 100; 19 | box-shadow: 0px 2px 4px ${({ theme }) => theme.headShadowColor}; 20 | background: ${({ theme }) => theme.subBaseTone[0]}; 21 | `; 22 | 23 | const Wrapper = styled(GlobalBlock)` 24 | display: flex; 25 | align-items: center; 26 | justify-content: space-between; 27 | ${mq({ 28 | height: ['4rem', '5rem'], 29 | })}; 30 | `; 31 | 32 | const TitleWrapper = styled(Link)` 33 | ${mq({ 34 | fontSize: ['1.9rem', '2.2rem', '2.4rem'], 35 | })}; 36 | `; 37 | 38 | const Spacer = styled.div` 39 | ${mq({ 40 | height: ['6rem', '7rem'], 41 | })}; 42 | `; 43 | 44 | const Header = ({ user, onLogout }) => ( 45 | <> 46 | 47 | 48 | ConStu 49 | {user ? ( 50 | 54 | ) : ( 55 |
56 | 57 | 58 |
59 | )} 60 |
61 |
62 | 63 | 64 | ); 65 | 66 | export default React.memo(Header); 67 | -------------------------------------------------------------------------------- /src/components/error/ErrorScreenTemplate.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import mq from '../../styles/responsive'; 6 | import palette from '../../styles/palette'; 7 | 8 | import Button from '../../styles/Button'; 9 | 10 | const ErrorScreenTemplateBlock = styled.div` 11 | top: -40px; 12 | left: 0px; 13 | position: fixed; 14 | width: 100%; 15 | height: 100%; 16 | display: flex; 17 | align-items: center; 18 | justify-content: center; 19 | `; 20 | 21 | const ErrorScreenWrapper = styled.div` 22 | display: flex; 23 | width: 100%; 24 | height: auto; 25 | align-items: center; 26 | justify-content: center; 27 | flex-direction: column; 28 | -webkit-box-align: center; 29 | -webkit-box-pack: center; 30 | 31 | .message { 32 | ${mq({ fontSize: ['2rem', '3rem'] })}; 33 | 34 | text-align: center; 35 | line-height: 1.5; 36 | margin-bottom: 2rem; 37 | } 38 | `; 39 | 40 | const StyledButton = styled(Button)` 41 | ${mq({ 42 | fontSize: ['1.2rem', '1.4rem'], 43 | padding: ['0.4rem 1rem', '0.6rem 1.2rem'], 44 | 45 | })}; 46 | 47 | transition: none; 48 | 49 | &:hover { 50 | background: ${palette.teal[4]}; 51 | color: white; 52 | border: 2px solid ${palette.teal[4]}; 53 | } 54 | `; 55 | 56 | const ErrorScreenTemplate = ({ 57 | message, buttonText, onClick, children, 58 | }) => ( 59 | 60 | 61 | {children} 62 |
63 | {message} 64 |
65 | 66 | {buttonText} 67 | 68 |
69 |
70 | ); 71 | 72 | export default ErrorScreenTemplate; 73 | -------------------------------------------------------------------------------- /src/components/base/DropDown.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import { useHistory } from 'react-router-dom'; 6 | 7 | import palette from '../../styles/palette'; 8 | 9 | const DropDownWrapper = styled.div` 10 | position: absolute; 11 | top: 100%; 12 | margin-top: .5rem; 13 | right: 5px; 14 | 15 | .menu-wrapper { 16 | position: relative; 17 | z-index: 5; 18 | width: 12rem; 19 | background: ${({ theme }) => theme.dropDownColor[1]}; 20 | box-shadow: rgb(0 0 0 / 20%) 0px 0px 8px; 21 | } 22 | `; 23 | 24 | const MenuContent = styled.div` 25 | color: ${palette.gray[8]}; 26 | padding: 0.75rem 1rem; 27 | line-height: 1.5; 28 | font-weight: 500; 29 | cursor: pointer; 30 | transition: background-color .2s; 31 | 32 | &:hover { 33 | background: ${({ theme }) => theme.dropDownColor[2]}; 34 | } 35 | 36 | &.user-id { 37 | cursor: unset; 38 | background: ${({ theme }) => theme.dropDownColor[0]}; 39 | 40 | &:hover{ 41 | background: ${({ theme }) => theme.dropDownColor[0]}; 42 | } 43 | } 44 | `; 45 | 46 | const DropDown = ({ visible, onLogout, user }) => { 47 | const history = useHistory(); 48 | 49 | if (!visible) { 50 | return null; 51 | } 52 | 53 | return ( 54 | 55 |
56 | 57 | {user} 58 | 59 | history.push('/myinfo')} 61 | > 62 | 내 정보 63 | 64 | 67 | 로그아웃 68 | 69 |
70 |
71 | ); 72 | }; 73 | 74 | export default DropDown; 75 | -------------------------------------------------------------------------------- /src/components/write/WriteEditor.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import { Editor } from 'react-draft-wysiwyg'; 6 | 7 | import palette from '../../styles/palette'; 8 | 9 | import 'react-draft-wysiwyg/dist/react-draft-wysiwyg.css'; 10 | 11 | const WriteEditorWrapper = styled.div` 12 | font-family: 'Nanum Gothic', sans-serif; 13 | margin-top: 1rem; 14 | 15 | .wrapper-class { 16 | width: 100%; 17 | } 18 | 19 | .toolbar { 20 | background: ${({ theme }) => theme.writeEditorColor[0]}; 21 | border: 1px solid ${({ theme }) => theme.writeEditorColor[1]}; 22 | color: #3d3d3d; 23 | padding: 6px 5px; 24 | box-shadow: rgba(0, 0, 0, 0.04) 0px 0px 5px 0px; 25 | } 26 | 27 | .editor { 28 | margin-left: 1rem; 29 | } 30 | `; 31 | 32 | const SpaceBlock = styled.div` 33 | width: 100%; 34 | height: 2px; 35 | margin-top: 1rem; 36 | margin-bottom: 1rem; 37 | border-radius: 1px; 38 | background: ${palette.gray[3]}; 39 | `; 40 | 41 | const WriteEditor = ({ editorState, onChange }) => ( 42 | <> 43 | 44 | 62 | 63 | 64 | 65 | ); 66 | 67 | export default WriteEditor; 68 | -------------------------------------------------------------------------------- /src/components/base/DropDown.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import DropDown from './DropDown'; 6 | import MockTheme from '../common/test/MockTheme'; 7 | 8 | const mockPush = jest.fn(); 9 | 10 | jest.mock('react-router-dom', () => ({ 11 | ...jest.requireActual('react-router-dom'), 12 | useHistory() { 13 | return { 14 | push: mockPush, 15 | }; 16 | }, 17 | })); 18 | describe('DropDown', () => { 19 | const handleClick = jest.fn(); 20 | 21 | const renderDropDown = (visible) => render(( 22 | 23 | 28 | 29 | )); 30 | 31 | context('Is Visible', () => { 32 | it('Renders DropDown content', () => { 33 | const { container } = renderDropDown(true); 34 | 35 | expect(container).toHaveTextContent('test'); 36 | expect(container).toHaveTextContent('로그아웃'); 37 | expect(container).toHaveTextContent('내 정보'); 38 | }); 39 | 40 | it('Click logout button calls handleClick', () => { 41 | const { getByText } = renderDropDown(true); 42 | 43 | fireEvent.click(getByText('로그아웃')); 44 | 45 | expect(handleClick).toBeCalledTimes(1); 46 | }); 47 | 48 | it('Click myInfo button calls history push', () => { 49 | const { getByText } = renderDropDown(true); 50 | 51 | fireEvent.click(getByText('내 정보')); 52 | 53 | expect(mockPush).toBeCalledWith('/myinfo'); 54 | }); 55 | }); 56 | 57 | context("Isn't visible", () => { 58 | it('Renders logout button and user id', () => { 59 | const { container } = renderDropDown(false); 60 | 61 | expect(container).toBeEmptyDOMElement(); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/components/base/UserHeaderStatus.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import UserHeaderStatus from './UserHeaderStatus'; 6 | import InjectMockProviders from '../common/test/InjectMockProviders'; 7 | 8 | describe('UserHeaderStatus', () => { 9 | const handleClick = jest.fn(); 10 | 11 | const renderUserHeaderStatus = (width) => render(( 12 | 13 | 17 | 18 | )); 19 | 20 | context('Is mobile Screen', () => { 21 | given('user', () => 'test'); 22 | it('Renders user icon', () => { 23 | const { getByTestId } = renderUserHeaderStatus(400); 24 | 25 | expect(getByTestId('user-icon')).not.toBeNull(); 26 | }); 27 | 28 | it('Click on the user icon renders a drop-down menu', () => { 29 | const { getByTestId, container } = renderUserHeaderStatus(400); 30 | 31 | fireEvent.click(getByTestId('user-icon')); 32 | 33 | expect(container).toHaveTextContent('로그아웃'); 34 | }); 35 | }); 36 | 37 | context('Is desktop Screen', () => { 38 | it('Renders logout button and user id', () => { 39 | given('user', () => 'test'); 40 | 41 | const { container } = renderUserHeaderStatus(700); 42 | 43 | expect(container).toHaveTextContent('로그아웃'); 44 | expect(container).toHaveTextContent('test'); 45 | expect(container).toHaveTextContent('내 정보'); 46 | }); 47 | 48 | it('Click logout button calls handleClick', () => { 49 | given('user', () => null); 50 | 51 | const { getByText } = renderUserHeaderStatus(700); 52 | 53 | fireEvent.click(getByText('로그아웃')); 54 | 55 | expect(handleClick).toBeCalledTimes(1); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/components/main/StudyGroups.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import _ from 'lodash'; 4 | 5 | import { css } from '@emotion/react'; 6 | import styled from '@emotion/styled'; 7 | 8 | import { useMediaQuery } from 'react-responsive'; 9 | 10 | import mq from '../../styles/responsive'; 11 | 12 | import StudyGroup from './StudyGroup'; 13 | import EstablishStudy from './EstablishStudy'; 14 | 15 | const StudyGroupsWrapper = styled.div` 16 | display: flex; 17 | flex-wrap: wrap; 18 | margin: 2rem 0; 19 | 20 | ${({ isMobile }) => isMobile && css` 21 | margin: 1rem 0; 22 | `}; 23 | `; 24 | 25 | const headerSize = mq({ 26 | fontSize: ['1.3rem', '1.5rem', '1.7rem'], 27 | }); 28 | 29 | const TitleHeader = styled.div` 30 | display: flex; 31 | align-items: center; 32 | justify-content: space-between; 33 | 34 | ${({ isMobile }) => isMobile && css` 35 | flex-direction: column; 36 | 37 | .plus-icon { 38 | margin-top: 1.5rem; 39 | } 40 | `}; 41 | 42 | h2 { 43 | font-weight: inherit; 44 | margin-bottom: .5rem; 45 | ${headerSize} 46 | } 47 | `; 48 | 49 | const StudyGroups = ({ groups, realTime, user }) => { 50 | const isMobileScreen = useMediaQuery({ query: '(max-width: 450px)' }); 51 | 52 | return ( 53 | <> 54 | 55 |

스터디를 직접 개설하거나 참여해보세요!

56 | {user && ( 57 | 58 | )} 59 |
60 | 61 | {!_.isEmpty(groups) && groups.map((group) => ( 62 | 67 | ))} 68 | 69 | 70 | ); 71 | }; 72 | 73 | export default React.memo(StudyGroups); 74 | -------------------------------------------------------------------------------- /src/containers/write/TagsFormContainer.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { fireEvent, render } from '@testing-library/react'; 4 | 5 | import { useDispatch, useSelector } from 'react-redux'; 6 | 7 | import TagsFormContainer from './TagsFormContainer'; 8 | import MockTheme from '../../components/common/test/MockTheme'; 9 | 10 | describe('TagsFormContainer', () => { 11 | const dispatch = jest.fn(); 12 | 13 | beforeEach(() => { 14 | dispatch.mockClear(); 15 | 16 | useDispatch.mockImplementation(() => dispatch); 17 | 18 | useSelector.mockImplementation((state) => state({ 19 | groupReducer: { 20 | writeField: { 21 | tags: [], 22 | }, 23 | }, 24 | })); 25 | }); 26 | 27 | const renderTagsFormContainer = () => render(( 28 | 29 | 30 | 31 | )); 32 | 33 | describe('render Tag Form Container contents text', () => { 34 | it('renders tag form text', () => { 35 | const { getByPlaceholderText } = renderTagsFormContainer(); 36 | 37 | expect(getByPlaceholderText('태그를 입력하세요')).not.toBeNull(); 38 | }); 39 | }); 40 | 41 | describe('calls dispatch tags change action', () => { 42 | const tags = ['JavaScript', 'React']; 43 | it('change tags', () => { 44 | const { getByPlaceholderText } = renderTagsFormContainer(); 45 | 46 | const input = getByPlaceholderText('태그를 입력하세요'); 47 | 48 | tags.forEach((tag) => { 49 | fireEvent.change(input, { target: { value: tag } }); 50 | 51 | fireEvent.keyPress(input, { key: 'Enter', code: 13, charCode: 13 }); 52 | 53 | expect(input).toHaveValue(''); 54 | }); 55 | expect(dispatch).toBeCalledWith({ 56 | type: 'group/changeWriteField', 57 | payload: { name: 'tags', value: tags }, 58 | }); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/components/introduce/modals/AskApplyCancelModal.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { fireEvent, render } from '@testing-library/react'; 4 | 5 | import MockTheme from '../../common/test/MockTheme'; 6 | import AskApplyCancelModal from './AskApplyCancelModal'; 7 | 8 | describe('AskApplyCancelModal', () => { 9 | const handleCancel = jest.fn(); 10 | const handleConfirm = jest.fn(); 11 | 12 | const renderAskApplyCancelModal = ({ visible }) => render(( 13 | 14 | 19 | 20 | )); 21 | 22 | context('with visible', () => { 23 | const modal = { 24 | visible: true, 25 | }; 26 | 27 | it('renders Modal text', () => { 28 | const { container } = renderAskApplyCancelModal(modal); 29 | 30 | expect(container).toHaveTextContent('신청 취소'); 31 | expect(container).toHaveTextContent('스터디 그룹 신청을 취소하시겠습니까?'); 32 | }); 33 | 34 | it('calls confirm event action', () => { 35 | const { getByText } = renderAskApplyCancelModal(modal); 36 | 37 | const button = getByText('확인'); 38 | 39 | fireEvent.click(button); 40 | 41 | expect(handleConfirm).toBeCalled(); 42 | }); 43 | 44 | it('calls cancel event action', () => { 45 | const { getByText } = renderAskApplyCancelModal(modal); 46 | 47 | const button = getByText('취소'); 48 | 49 | fireEvent.click(button); 50 | 51 | expect(handleCancel).toBeCalled(); 52 | }); 53 | }); 54 | 55 | context('without visible', () => { 56 | const modal = { 57 | visible: false, 58 | }; 59 | 60 | it("doesn't renders Modal text", () => { 61 | const { container } = renderAskApplyCancelModal(modal); 62 | 63 | expect(container).toBeEmptyDOMElement(); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/components/introduce/modals/AskArticleDeleteModal.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { fireEvent, render } from '@testing-library/react'; 4 | 5 | import MockTheme from '../../common/test/MockTheme'; 6 | import AskArticleDeleteModal from './AskArticleDeleteModal'; 7 | 8 | describe('AskArticleDeleteModal', () => { 9 | const handleCancel = jest.fn(); 10 | const handleConfirm = jest.fn(); 11 | 12 | const renderAskArticleDeleteModal = ({ visible }) => render(( 13 | 14 | 19 | 20 | )); 21 | 22 | context('with visible', () => { 23 | const modal = { 24 | visible: true, 25 | }; 26 | 27 | it('renders Modal text', () => { 28 | const { container } = renderAskArticleDeleteModal(modal); 29 | 30 | expect(container).toHaveTextContent('스터디 소개글 삭제'); 31 | expect(container).toHaveTextContent('스터디 소개글을 삭제하시겠습니까?'); 32 | }); 33 | 34 | it('calls confirm event action', () => { 35 | const { getByText } = renderAskArticleDeleteModal(modal); 36 | 37 | const button = getByText('확인'); 38 | 39 | fireEvent.click(button); 40 | 41 | expect(handleConfirm).toBeCalled(); 42 | }); 43 | 44 | it('calls cancel event action', () => { 45 | const { getByText } = renderAskArticleDeleteModal(modal); 46 | 47 | const button = getByText('취소'); 48 | 49 | fireEvent.click(button); 50 | 51 | expect(handleCancel).toBeCalled(); 52 | }); 53 | }); 54 | 55 | context('without visible', () => { 56 | const modal = { 57 | visible: false, 58 | }; 59 | 60 | it("doesn't renders Modal text", () => { 61 | const { container } = renderAskArticleDeleteModal(modal); 62 | 63 | expect(container).toBeEmptyDOMElement(); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/styles/GlobalStyles.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import emotionReset from 'emotion-reset'; 4 | 5 | import { Global, css, useTheme } from '@emotion/react'; 6 | 7 | import mq from './responsive'; 8 | 9 | const setGlobalStyles = ({ 10 | baseFont, baseTone, baseShadow, preColor, 11 | }) => css` 12 | ${emotionReset} 13 | 14 | * { 15 | box-sizing: inherit; 16 | } 17 | 18 | body { 19 | color: ${baseFont}; 20 | background: ${baseTone}; 21 | text-shadow: ${baseShadow}; 22 | font-family: 'Jua', sans-serif; 23 | transition: all 0.25s linear 0s; 24 | } 25 | 26 | input { 27 | border: none; 28 | outline:none; 29 | } 30 | 31 | a { 32 | color: inherit; 33 | text-decoration: none; 34 | } 35 | 36 | button { 37 | outline: none; 38 | cursor: pointer; 39 | } 40 | 41 | code { 42 | line-height: 25px; 43 | font-size: 85%; 44 | font-family: 'D2Coding', monospace; 45 | margin: 0 .2rem 0 0; 46 | border-radius: 6px; 47 | white-space: normal; 48 | background: #ece5f1; 49 | color: #4b2043; 50 | padding: .2rem .4rem; 51 | border-radius: .2rem; 52 | } 53 | 54 | pre { 55 | ${mq({ 56 | padding: ['.7rem 1rem !important', '1rem 1.5rem !important'], 57 | })}; 58 | 59 | background: ${preColor[0]} !important; 60 | color: ${preColor[2]} !important; 61 | page-break-inside: avoid; 62 | font-family: 'D2Coding', monospace; 63 | word-wrap: break-word; 64 | line-height: 1.6; 65 | max-width: 100%; 66 | overflow: auto; 67 | display: block; 68 | border: 1px solid ${preColor[1]}; 69 | border-radius: 0 !important; 70 | border-left: 3px solid #38d9a9; 71 | margin: 1rem 0; 72 | } 73 | `; 74 | 75 | const GlobalStyles = () => { 76 | const theme = useTheme(); 77 | 78 | return ( 79 | 80 | ); 81 | }; 82 | 83 | export default GlobalStyles; 84 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Repo 13 | uses: actions/checkout@v2 14 | - name: Install Dependencies 15 | run: yarn install 16 | - name: Create env file 17 | run: | 18 | touch .env 19 | echo FIREBASE_API_KEY= ${{ secrets.FIREBASE_API_KEY }} >> .env 20 | echo FIREBASE_APP_ID= ${{ secrets.FIREBASE_APP_ID }} >> .env 21 | echo FIREBASE_AUTH_DOMAIN= ${{ secrets.FIREBASE_AUTH_DOMAIN }} >> .env 22 | echo FIREBASE_DATA_BASEURL= ${{ secrets.FIREBASE_DATA_BASEURL }} >> .env 23 | echo FIREBASE_MEASUREMENT_ID= ${{ secrets.FIREBASE_MEASUREMENT_ID }} >> .env 24 | echo FIREBASE_MESSAGING_SENDER_ID= ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }} >> .env 25 | echo FIREBASE_PROJECT_ID= ${{ secrets.FIREBASE_PROJECT_ID }} >> .env 26 | echo FIREBASE_STORAGE_BUCKET= ${{ secrets.FIREBASE_STORAGE_BUCKET }} >> .env 27 | echo SENTRY_DSN= ${{ secrets.SENTRY_DSN }} >> .env 28 | cat .env 29 | - name: Build 30 | run: yarn run build 31 | - name: Archive Production Artifact 32 | uses: actions/upload-artifact@v2 33 | with: 34 | name: build 35 | path: build 36 | deploy: 37 | name: Deploy 38 | needs: build 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: Checkout Repo 42 | uses: actions/checkout@v2 43 | - name: Download Artifact 44 | uses: actions/download-artifact@v2 45 | with: 46 | name: build 47 | path: build 48 | - name: Deploy to Firebase 49 | uses: w9jds/firebase-action@master 50 | with: 51 | args: deploy --only hosting 52 | env: 53 | FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} 54 | -------------------------------------------------------------------------------- /src/containers/auth/LoginFormContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | 3 | import { useUnmount } from 'react-use'; 4 | import { useForm } from 'react-hook-form'; 5 | import { useHistory } from 'react-router-dom'; 6 | import { useSelector, useDispatch } from 'react-redux'; 7 | 8 | import { clearAuth, requestLogin } from '../../reducers/authSlice'; 9 | 10 | import { getAuth, isNullFields } from '../../util/utils'; 11 | import { ERROR_MESSAGE, FIREBASE_AUTH_ERROR_MESSAGE } from '../../util/constants/messages'; 12 | 13 | import AuthForm from '../../components/auth/AuthForm'; 14 | 15 | const { NO_INPUT, FAILURE_LOGIN } = ERROR_MESSAGE; 16 | 17 | const LoginFormContainer = () => { 18 | const [error, setError] = useState(null); 19 | 20 | const dispatch = useDispatch(); 21 | const history = useHistory(); 22 | 23 | const user = useSelector(getAuth('user')); 24 | const authError = useSelector(getAuth('authError')); 25 | 26 | const { register, handleSubmit, setValue } = useForm(); 27 | 28 | const onSubmit = useCallback((formData) => { 29 | if (isNullFields(formData)) { 30 | setError(NO_INPUT); 31 | return; 32 | } 33 | 34 | dispatch(requestLogin(formData)); 35 | }, [dispatch]); 36 | 37 | useEffect(() => { 38 | if (user) { 39 | history.push('/'); 40 | } 41 | }, [user, history]); 42 | 43 | useEffect(() => { 44 | if (authError) { 45 | setError( 46 | FIREBASE_AUTH_ERROR_MESSAGE[authError] 47 | || FAILURE_LOGIN, 48 | ); 49 | setValue('password', ''); 50 | 51 | return; 52 | } 53 | 54 | setError(null); 55 | }, [authError, dispatch]); 56 | 57 | useUnmount(() => { 58 | dispatch(clearAuth()); 59 | }); 60 | 61 | return ( 62 | 68 | ); 69 | }; 70 | 71 | export default LoginFormContainer; 72 | -------------------------------------------------------------------------------- /src/components/myInfo/modal/AskMembershipWithdrawalModal.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { fireEvent, render } from '@testing-library/react'; 4 | 5 | import MockTheme from '../../common/test/MockTheme'; 6 | import AskMembershipWithdrawalModal from './AskMembershipWithdrawalModal'; 7 | 8 | describe('AskMembershipWithdrawalModal', () => { 9 | const handleCancel = jest.fn(); 10 | const handleConfirm = jest.fn(); 11 | 12 | const renderAskMembershipWithdrawalModal = ({ visible }) => render(( 13 | 14 | 19 | 20 | )); 21 | 22 | context('with visible', () => { 23 | const modal = { 24 | visible: true, 25 | }; 26 | 27 | it('renders Modal text', () => { 28 | const { container } = renderAskMembershipWithdrawalModal(modal); 29 | 30 | expect(container).toHaveTextContent('회원 탈퇴'); 31 | expect(container).toHaveTextContent('회원을 탈퇴하시겠습니까?'); 32 | }); 33 | 34 | it('calls confirm event action', () => { 35 | const { getByText } = renderAskMembershipWithdrawalModal(modal); 36 | 37 | const button = getByText('확인'); 38 | 39 | fireEvent.click(button); 40 | 41 | expect(handleConfirm).toBeCalled(); 42 | }); 43 | 44 | it('calls cancel event action', () => { 45 | const { getByText } = renderAskMembershipWithdrawalModal(modal); 46 | 47 | const button = getByText('취소'); 48 | 49 | fireEvent.click(button); 50 | 51 | expect(handleCancel).toBeCalled(); 52 | }); 53 | }); 54 | 55 | context('without visible', () => { 56 | const modal = { 57 | visible: false, 58 | }; 59 | 60 | it("doesn't renders Modal text", () => { 61 | const { container } = renderAskMembershipWithdrawalModal(modal); 62 | 63 | expect(container).toBeEmptyDOMElement(); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/containers/base/HeaderContainer.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { MemoryRouter } from 'react-router-dom'; 4 | import { useDispatch, useSelector } from 'react-redux'; 5 | 6 | import { fireEvent, render } from '@testing-library/react'; 7 | 8 | import HeaderContainer from './HeaderContainer'; 9 | import MockTheme from '../../components/common/test/MockTheme'; 10 | 11 | describe('HeaderContainer', () => { 12 | const dispatch = jest.fn(); 13 | 14 | beforeEach(() => { 15 | dispatch.mockClear(); 16 | 17 | useDispatch.mockImplementation(() => dispatch); 18 | 19 | useSelector.mockImplementation((selector) => selector({ 20 | authReducer: { 21 | user: given.user, 22 | }, 23 | })); 24 | }); 25 | 26 | const renderHeaderContainer = () => render(( 27 | 28 | 29 | 30 | 31 | 32 | )); 33 | 34 | context('with user', () => { 35 | given('user', () => ('seungmin@naver.com')); 36 | 37 | it('renders Header text', () => { 38 | const { container } = renderHeaderContainer(); 39 | 40 | expect(container).toHaveTextContent('ConStu'); 41 | expect(container).toHaveTextContent('로그아웃'); 42 | }); 43 | 44 | it('logout event calls dispatch actions and redirection go to main page', () => { 45 | const { getByText } = renderHeaderContainer(); 46 | 47 | const button = getByText('로그아웃'); 48 | 49 | expect(button).not.toBeNull(); 50 | 51 | fireEvent.click(button); 52 | 53 | expect(dispatch).toBeCalled(); 54 | }); 55 | }); 56 | 57 | context('without user', () => { 58 | given('user', () => (null)); 59 | 60 | it('renders Header text', () => { 61 | const { container } = renderHeaderContainer(); 62 | 63 | expect(container).toHaveTextContent('ConStu'); 64 | expect(container).toHaveTextContent('로그인'); 65 | expect(container).toHaveTextContent('회원가입'); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/containers/groups/StudyGroupsContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | 3 | import { useInterval } from 'react-use'; 4 | import { useLocation } from 'react-router-dom'; 5 | import { useSelector, useDispatch } from 'react-redux'; 6 | 7 | import { useMediaQuery } from 'react-responsive'; 8 | 9 | import qs from 'qs'; 10 | 11 | import _ from 'lodash'; 12 | 13 | import { getAuth, getGroup } from '../../util/utils'; 14 | 15 | import { loadStudyGroups } from '../../reducers/groupSlice'; 16 | 17 | import StudyGroups from '../../components/main/StudyGroups'; 18 | import ResponsiveGroupsContentLoader from '../../components/loader/ResponsiveGroupsContentLoader'; 19 | 20 | const StudyGroupsContainer = () => { 21 | const { search } = useLocation(); 22 | const [realTime, setRealTime] = useState(Date.now()); 23 | const [tagState, setTagState] = useState(null); 24 | 25 | const dispatch = useDispatch(); 26 | 27 | const groups = useSelector(getGroup('groups')); 28 | const user = useSelector(getAuth('user')); 29 | 30 | useInterval(() => setRealTime(Date.now()), 1000); 31 | 32 | useEffect(() => { 33 | const { tag } = qs.parse(search, { 34 | ignoreQueryPrefix: true, 35 | }); 36 | 37 | setTagState(tag); 38 | dispatch(loadStudyGroups(tag)); 39 | }, [dispatch, search]); 40 | 41 | const isDesktop = useMediaQuery({ 42 | minWidth: 1051, 43 | }); 44 | 45 | const isTablet = useMediaQuery({ 46 | minWidth: 650, maxWidth: 1050, 47 | }); 48 | 49 | const isMobile = useMediaQuery({ 50 | maxWidth: 450, 51 | }); 52 | 53 | if (!tagState && _.isEmpty(groups)) { 54 | return ( 55 | 60 | ); 61 | } 62 | 63 | return ( 64 | 69 | ); 70 | }; 71 | 72 | export default React.memo(StudyGroupsContainer); 73 | -------------------------------------------------------------------------------- /src/components/introduce/modals/ApplicationViewModal.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { fireEvent, render } from '@testing-library/react'; 4 | 5 | import MockTheme from '../../common/test/MockTheme'; 6 | import ApplicationViewModal from './ApplicationViewModal'; 7 | 8 | describe('ApplicationViewModal', () => { 9 | const handleClose = jest.fn(); 10 | 11 | beforeEach(() => { 12 | jest.clearAllMocks(); 13 | }); 14 | 15 | const renderApplicationViewModal = ({ visible, participant }) => render(( 16 | 17 | 22 | 23 | )); 24 | 25 | context('with visible', () => { 26 | const modal = { 27 | visible: true, 28 | participant: { 29 | id: 'test', 30 | reason: 'reason', 31 | wantToGet: 'wantToGet', 32 | }, 33 | }; 34 | 35 | it('renders Modal text', () => { 36 | const { container } = renderApplicationViewModal(modal); 37 | 38 | const { participant } = modal; 39 | 40 | expect(container).toHaveTextContent(`${participant.id} 신청서`); 41 | expect(container).toHaveTextContent('신청하게 된 이유'); 42 | expect(container).toHaveTextContent('스터디를 통해 얻고 싶은 것은 무엇인가요?'); 43 | }); 44 | 45 | it('calls confirm event action', () => { 46 | const { getByText } = renderApplicationViewModal(modal); 47 | 48 | const button = getByText('닫기'); 49 | 50 | fireEvent.click(button); 51 | 52 | expect(handleClose).toBeCalled(); 53 | }); 54 | }); 55 | 56 | context('without visible', () => { 57 | const modal = { 58 | visible: false, 59 | participant: { 60 | reason: '', 61 | wantToGet: '', 62 | }, 63 | }; 64 | 65 | it("doesn't renders Modal text", () => { 66 | const { container } = renderApplicationViewModal(modal); 67 | 68 | expect(container).toBeEmptyDOMElement(); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/hooks/useAuth.test.js: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux'; 2 | 3 | import { renderHook, act } from '@testing-library/react-hooks'; 4 | 5 | import useAuth from './useAuth'; 6 | 7 | describe('useAuth', () => { 8 | const dispatch = jest.fn(); 9 | 10 | beforeEach(() => { 11 | dispatch.mockClear(); 12 | 13 | useDispatch.mockImplementation(() => dispatch); 14 | 15 | useSelector.mockImplementation((state) => state({ 16 | authReducer: { 17 | user: 'test', 18 | auth: null, 19 | authError: null, 20 | userDetail: { 21 | email: 'test@test.com', 22 | displayName: 'test', 23 | }, 24 | }, 25 | })); 26 | }); 27 | 28 | const renderAuthHook = () => renderHook(() => useAuth()); 29 | 30 | describe('renders auth reducer state', () => { 31 | it('should about auth state', () => { 32 | const { result: { current } } = renderAuthHook(); 33 | 34 | const { 35 | auth, user, authError, userDetail, 36 | } = current; 37 | 38 | expect(auth).toBeNull(); 39 | expect(user).toBe('test'); 40 | expect(authError).toBeNull(); 41 | expect(userDetail).toEqual({ 42 | email: 'test@test.com', 43 | displayName: 'test', 44 | }); 45 | }); 46 | }); 47 | 48 | describe('calls logoutUser', () => { 49 | it('should be listens dispatch action', () => { 50 | const { result } = renderAuthHook(); 51 | 52 | act(() => { 53 | result.current.logoutUser(); 54 | }); 55 | 56 | expect(dispatch).toBeCalledWith({ 57 | type: 'auth/logout', 58 | }); 59 | }); 60 | }); 61 | 62 | describe('calls clearAuthState', () => { 63 | it('should be listens dispatch action', () => { 64 | const { result } = renderAuthHook(); 65 | 66 | act(() => { 67 | result.current.clearAuthState(); 68 | }); 69 | 70 | expect(dispatch).toBeCalledWith({ 71 | type: 'auth/clearAuth', 72 | }); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/styles/StyledApplyStatusButton.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import React from 'react'; 3 | 4 | import styled from '@emotion/styled'; 5 | 6 | import mq from './responsive'; 7 | import palette from './palette'; 8 | 9 | const StyledApplyStatusButtonWrapper = styled.button` 10 | ${mq({ 11 | fontSize: ['.8rem', '1rem'], 12 | height: ['25px', '30px', '33px'], 13 | padding: ['0.2rem 1rem', '0.25rem 2rem', '0.25rem 3rem', '0.25rem 5rem'], 14 | margin: [0, '.5rem 0 .5rem 0'], 15 | })}; 16 | 17 | font-family: 'Jua', sans-serif; 18 | font-weight: lighter; 19 | display: inline-flex; 20 | align-items: center; 21 | border: none; 22 | border-radius: 0.4rem; 23 | line-height: 0; 24 | 25 | &.deadline { 26 | cursor: not-allowed; 27 | color: ${palette.gray[5]}; 28 | background: ${palette.gray[3]}; 29 | } 30 | 31 | &.apply-cancel { 32 | color: white; 33 | background: ${palette.orange[4]}; 34 | 35 | &:hover { 36 | background: ${palette.orange[3]}; 37 | } 38 | } 39 | 40 | &.apply-reject { 41 | cursor: not-allowed; 42 | border: 2px solid ${palette.warn[0]}; 43 | color: ${palette.warn[1]}; 44 | background: ${({ theme }) => theme.subBaseTone[0]}; 45 | } 46 | 47 | &.apply-complete { 48 | cursor: unset; 49 | border: 2px solid #a5d8ff; 50 | color: #74c0fc; 51 | background: ${({ theme }) => theme.subBaseTone[0]}; 52 | } 53 | 54 | &.apply { 55 | color: white; 56 | background: ${palette.teal[5]}; 57 | 58 | &:hover { 59 | background: ${palette.teal[4]}; 60 | } 61 | } 62 | 63 | &.confirm { 64 | ${mq({ padding: ['0.2rem 1rem', '0.25rem 2rem', '0.25rem 3rem', '0.25rem 4rem'] })}; 65 | color: white; 66 | background: #4dabf7; 67 | 68 | &:hover { 69 | background: #74c0fc; 70 | } 71 | } 72 | `; 73 | 74 | const StyledApplyStatusButton = (props) => ( 75 | 76 | ); 77 | 78 | export default StyledApplyStatusButton; 79 | -------------------------------------------------------------------------------- /src/components/myInfo/modal/ConfirmPasswordModal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { css } from '@emotion/react'; 4 | import styled from '@emotion/styled'; 5 | 6 | import mq from '../../../styles/responsive'; 7 | import palette from '../../../styles/palette'; 8 | 9 | import FormModalWindow from '../../common/FormModalWindow'; 10 | 11 | const VerificationPasswordInput = styled.input` 12 | ${({ theme }) => mq({ 13 | height: ['28px', '18px'], 14 | width: ['272px', '290px'], 15 | padding: ['4px 12px', '8px 12px'], 16 | fontSize: ['.8rem', '1rem'], 17 | border: [`1px solid ${theme.borderTone[1]}`, `2px solid ${theme.borderTone[1]}`], 18 | })}; 19 | 20 | margin-bottom: 1rem; 21 | line-height: 24px; 22 | box-shadow: none; 23 | border-radius: 0.25rem; 24 | color: #5f5f5f; 25 | background: ${({ theme }) => theme.authColor[0]}; 26 | transition-duration: 0.08s; 27 | transition-property: all; 28 | transition-timing-function: ease-in-out; 29 | transition-delay: initial; 30 | 31 | ${({ error }) => error && css` 32 | @keyframes shake { 33 | 0% { left: -5px; } 34 | 100% { right: -5px; } 35 | }; 36 | ${mq({ border: [`1px solid ${palette.warn[1]}`, `2px solid ${palette.warn[1]}`] })}; 37 | 38 | position: relative; 39 | animation: shake .1s linear; 40 | animation-iteration-count: 3; 41 | 42 | &::placeholder { 43 | color: ${palette.warn[1]}; 44 | } 45 | `}; 46 | `; 47 | 48 | const ConfirmPasswordModal = ({ 49 | visible, onConfirm, onCancel, handleChange, password, error, 50 | }) => ( 51 | 57 | 65 | 66 | ); 67 | 68 | export default ConfirmPasswordModal; 69 | -------------------------------------------------------------------------------- /src/components/base/ThemeToggle.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import ThemeToggle from './ThemeToggle'; 6 | 7 | jest.mock('../../services/storage'); 8 | describe('ThemeToggle', () => { 9 | const handleChange = jest.fn(); 10 | 11 | const renderThemeToggle = (theme) => render(( 12 | 16 | )); 17 | 18 | beforeEach(() => { 19 | jest.clearAllMocks(); 20 | }); 21 | 22 | context('When theme is Light', () => { 23 | it('renders theme toggle button', () => { 24 | const { getByTestId } = renderThemeToggle(false); 25 | 26 | expect(getByTestId('theme-toggle')).not.toBeNull(); 27 | expect(getByTestId('theme-toggle')).toHaveAttribute('title', 'light'); 28 | }); 29 | 30 | describe('Click toggle button', () => { 31 | it('should be calls change event', () => { 32 | const { getByTestId } = renderThemeToggle(false); 33 | 34 | const button = getByTestId('theme-toggle'); 35 | 36 | expect(button).toHaveAttribute('title', 'light'); 37 | 38 | fireEvent.click(button); 39 | 40 | expect(handleChange).toBeCalledTimes(1); 41 | }); 42 | }); 43 | }); 44 | 45 | context('When theme is Dark', () => { 46 | it('renders theme toggle button', () => { 47 | const { getByTestId } = renderThemeToggle(true); 48 | 49 | expect(getByTestId('theme-toggle')).not.toBeNull(); 50 | expect(getByTestId('theme-toggle')).toHaveAttribute('title', 'dark'); 51 | }); 52 | 53 | describe('Click toggle button', () => { 54 | it('should be calls change event', () => { 55 | const { getByTestId } = renderThemeToggle(true); 56 | 57 | const button = getByTestId('theme-toggle'); 58 | 59 | expect(button).toHaveAttribute('title', 'dark'); 60 | 61 | fireEvent.click(button); 62 | 63 | expect(handleChange).toBeCalledTimes(1); 64 | }); 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/pages/WritePage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { render } from '@testing-library/react'; 6 | 7 | import WritePage from './WritePage'; 8 | import InjectMockProviders from '../components/common/test/InjectMockProviders'; 9 | 10 | describe('WritePage', () => { 11 | const dispatch = jest.fn(); 12 | 13 | beforeEach(() => { 14 | dispatch.mockClear(); 15 | 16 | useDispatch.mockImplementation(() => dispatch); 17 | 18 | useSelector.mockImplementation((state) => state({ 19 | groupReducer: { 20 | writeField: { 21 | tags: [], 22 | }, 23 | }, 24 | authReducer: { 25 | user: 'user1', 26 | }, 27 | commonReducer: { 28 | theme: false, 29 | }, 30 | })); 31 | }); 32 | 33 | const renderWritePage = () => render(( 34 | 35 | 36 | 37 | )); 38 | 39 | describe('render Write Page contents text', () => { 40 | it('renders theme toggle button', () => { 41 | const { getByTestId } = renderWritePage(); 42 | 43 | expect(getByTestId('theme-toggle')).not.toBeNull(); 44 | }); 45 | 46 | it('renders Write Editor placeholder text', () => { 47 | const { container } = renderWritePage(); 48 | 49 | expect(container).toHaveTextContent('내용을 작성해주세요.'); 50 | }); 51 | 52 | it('renders write form tag', () => { 53 | const { getByPlaceholderText } = renderWritePage(); 54 | 55 | expect(getByPlaceholderText('제목을 입력하세요')).not.toBeNull(); 56 | }); 57 | 58 | it('renders tag form text', () => { 59 | const { getByPlaceholderText } = renderWritePage(); 60 | 61 | expect(getByPlaceholderText('태그를 입력하세요')).not.toBeNull(); 62 | }); 63 | 64 | it('renders buttons', () => { 65 | const { getByText } = renderWritePage(); 66 | 67 | expect(getByText('등록하기')).not.toBeNull(); 68 | expect(getByText('취소')).not.toBeNull(); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | jest: true, 6 | }, 7 | extends: [ 8 | 'plugin:react/recommended', 9 | 'airbnb', 10 | ], 11 | globals: { 12 | secret: 'readonly', 13 | session: 'readonly', 14 | After: 'readonly', 15 | codeceptjs: 'readonly', 16 | Before: 'readonly', 17 | context: 'readonly', 18 | Feature: 'readonly', 19 | Scenario: 'readonly', 20 | actor: 'readonly', 21 | given: 'readonly', 22 | }, 23 | parserOptions: { 24 | ecmaFeatures: { 25 | jsx: true, 26 | }, 27 | ecmaVersion: 11, 28 | sourceType: 'module', 29 | }, 30 | plugins: [ 31 | 'react', 32 | ], 33 | ignorePatterns: ['build/', 'node_modules/'], 34 | rules: { 35 | indent: ['error', 2], 36 | 'no-trailing-spaces': 'error', 37 | curly: 'error', 38 | 'brace-style': 'error', 39 | 'no-multi-spaces': 'error', 40 | 'space-infix-ops': 'error', 41 | 'space-unary-ops': 'error', 42 | 'no-whitespace-before-property': 'error', 43 | 'func-call-spacing': 'error', 44 | 'space-before-blocks': 'error', 45 | 'keyword-spacing': ['error', { before: true, after: true }], 46 | 'comma-spacing': ['error', { before: false, after: true }], 47 | 'comma-style': ['error', 'last'], 48 | 'comma-dangle': ['error', 'always-multiline'], 49 | 'space-in-parens': ['error', 'never'], 50 | 'block-spacing': 'error', 51 | 'array-bracket-spacing': ['error', 'never'], 52 | 'object-curly-spacing': ['error', 'always'], 53 | 'key-spacing': ['error', { mode: 'strict' }], 54 | 'arrow-spacing': ['error', { before: true, after: true }], 55 | 'jsx-a11y/label-has-associated-control': ['error', { assert: 'either' }], 56 | 'react/prop-types': 'off', 57 | 'linebreak-style': 'off', 58 | 'no-proto': 'off', 59 | 'no-param-reassign': ['error', { props: true, ignorePropertyModificationsFor: ['draft'] }], 60 | 'react/jsx-props-no-spreading': ['error', { exceptions: ['InputWrapper', 'Textarea'] }], 61 | }, 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/write/WriteForm.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { fireEvent, render } from '@testing-library/react'; 4 | 5 | import WRITE_FORM from '../../../fixtures/write-form'; 6 | 7 | import WriteForm from './WriteForm'; 8 | import MockTheme from '../common/test/MockTheme'; 9 | 10 | describe('WriteForm', () => { 11 | const handleChange = jest.fn(); 12 | 13 | beforeEach(() => { 14 | handleChange.mockClear(); 15 | }); 16 | 17 | const renderWriteForm = (fields) => render(( 18 | 19 | 23 | 24 | )); 25 | 26 | it('renders input write form text', () => { 27 | const { getByLabelText, getByPlaceholderText } = renderWriteForm({ 28 | ...WRITE_FORM, 29 | applyEndDate: new Date('2021-04-01 09:00'), 30 | }); 31 | 32 | const { title, personnel } = WRITE_FORM; 33 | 34 | expect(getByPlaceholderText('제목을 입력하세요')).toHaveValue(title); 35 | expect(getByLabelText('모집 마감 날짜')).toHaveValue('2021-04-01 09:00 AM'); 36 | expect(getByLabelText('참여 인원 수')).toHaveValue(parseInt(personnel, 10)); 37 | }); 38 | 39 | describe('listens change event', () => { 40 | it('write fields value change', () => { 41 | const { getByPlaceholderText } = renderWriteForm(WRITE_FORM); 42 | 43 | const { title } = WRITE_FORM; 44 | 45 | const inputTitle = getByPlaceholderText('제목을 입력하세요'); 46 | expect(inputTitle).toHaveValue(title); 47 | 48 | fireEvent.change(inputTitle, { 49 | target: { 50 | value: '안녕하세요!', 51 | name: 'title', 52 | }, 53 | }); 54 | 55 | expect(handleChange).toBeCalled(); 56 | }); 57 | }); 58 | 59 | it('date-picker change event', () => { 60 | const { container, getByLabelText } = renderWriteForm({ 61 | ...WRITE_FORM, 62 | applyEndDate: '', 63 | }); 64 | 65 | fireEvent.click(getByLabelText('모집 마감 날짜')); 66 | 67 | expect(container).toHaveTextContent('Time'); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /codecept.conf.js: -------------------------------------------------------------------------------- 1 | const { setHeadlessWhen } = require('@codeceptjs/configure'); 2 | 3 | // turn on headless mode when running with HEADLESS=true environment variable 4 | // export HEADLESS=true && npx codeceptjs run 5 | setHeadlessWhen(process.env.HEADLESS); 6 | 7 | exports.config = { 8 | tests: './tests/**/*_test.js', 9 | output: './output', 10 | helpers: { 11 | Playwright: { 12 | url: 'http://localhost:8080', 13 | show: true, 14 | browser: 'chromium', 15 | }, 16 | }, 17 | include: { 18 | I: './tests/steps_file.js', 19 | }, 20 | bootstrap: null, 21 | mocha: {}, 22 | name: 'ConStu', 23 | plugins: { 24 | pauseOnFail: {}, 25 | retryFailedStep: { 26 | enabled: true, 27 | }, 28 | tryTo: { 29 | enabled: true, 30 | }, 31 | screenshotOnFail: { 32 | enabled: true, 33 | }, 34 | commentStep: { 35 | enabled: true, 36 | }, 37 | autoDelay: { 38 | enabled: true, 39 | }, 40 | autoLogin: { 41 | enabled: true, 42 | saveToFile: true, 43 | inject: 'login', 44 | users: { 45 | user: { 46 | login: (I) => { 47 | I.amOnPage('/login'); 48 | I.fillField('input[name=userEmail]', 'test@test.com'); 49 | I.fillField('input[name=password]', secret('123123')); 50 | I.click('button[type=submit]'); 51 | }, 52 | check: (I) => { 53 | I.seeCurrentUrlEquals('/'); 54 | I.see('로그아웃'); 55 | I.see('test@test.com'); 56 | }, 57 | }, 58 | user2: { 59 | login: (I) => { 60 | I.amOnPage('/login'); 61 | I.fillField('input[name=userEmail]', 'test1@test.com'); 62 | I.fillField('input[name=password]', secret('123123')); 63 | I.click('button[type=submit]'); 64 | }, 65 | check: (I) => { 66 | I.seeCurrentUrlEquals('/'); 67 | I.see('로그아웃'); 68 | I.see('test3@test.com'); 69 | }, 70 | }, 71 | }, 72 | }, 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /src/styles/Button.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import React from 'react'; 3 | 4 | import { Link } from 'react-router-dom'; 5 | 6 | import styled from '@emotion/styled'; 7 | import { css } from '@emotion/react'; 8 | 9 | import mq from './responsive'; 10 | import palette from './palette'; 11 | 12 | const fontSizeButton = mq({ 13 | fontSize: ['.8rem', '.9rem', '1rem'], 14 | }); 15 | 16 | const ButtonWrapper = ({ warn, success }) => css` 17 | font-family: 'Jua', sans-serif; 18 | font-weight: inherit; 19 | cursor: pointer; 20 | ${fontSizeButton}; 21 | color: ${palette.gray[7]}; 22 | background: white; 23 | transition: all .1s ease-in-out; 24 | padding: 0.5rem 1rem; 25 | border-radius: 4px; 26 | border: 2px solid ${palette.gray[6]}; 27 | 28 | &:hover { 29 | color: white; 30 | background: ${palette.gray[7]}; 31 | border: 2px solid ${palette.gray[7]}; 32 | } 33 | 34 | ${warn && css` 35 | color: white; 36 | background: ${palette.warn[1]}; 37 | padding: 0.3rem 1rem; 38 | border: 2px solid ${palette.warn[1]}; 39 | 40 | &:hover { 41 | background: white; 42 | color: ${palette.warn[1]}; 43 | border: 2px solid ${palette.warn[1]}; 44 | } 45 | `} 46 | 47 | ${success && css` 48 | color: white; 49 | background: ${palette.teal[5]}; 50 | border: 2px solid ${palette.teal[5]}; 51 | 52 | &:hover { 53 | background: white; 54 | color: ${palette.teal[5]}; 55 | border: 2px solid ${palette.teal[5]}; 56 | } 57 | `} 58 | `; 59 | 60 | const StyledButton = styled.button` 61 | ${ButtonWrapper} 62 | `; 63 | const StyledLink = styled(Link)` 64 | ${ButtonWrapper} 65 | `; 66 | 67 | const Button = (props) => { 68 | const { to, success, warn } = props; 69 | 70 | return ( 71 | <> 72 | {to ? ( 73 | 78 | ) : ( 79 | 80 | )} 81 | 82 | ); 83 | }; 84 | 85 | export default Button; 86 | -------------------------------------------------------------------------------- /src/hooks/useNotFound.test.js: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector } from 'react-redux'; 2 | 3 | import { renderHook, act } from '@testing-library/react-hooks'; 4 | 5 | import useNotFound from './useNotFound'; 6 | 7 | describe('useNotFound', () => { 8 | const dispatch = jest.fn(); 9 | 10 | beforeEach(() => { 11 | dispatch.mockClear(); 12 | 13 | useDispatch.mockImplementation(() => dispatch); 14 | 15 | useSelector.mockImplementation((state) => state({ 16 | commonReducer: { 17 | errorType: given.errorType, 18 | }, 19 | })); 20 | }); 21 | 22 | const renderNotFound = () => renderHook(() => useNotFound()); 23 | 24 | describe('isNotFound', () => { 25 | context('Have NOT_FOUND error type', () => { 26 | it('should return true', () => { 27 | given('errorType', () => ('NOT_FOUND')); 28 | 29 | const { result } = renderNotFound(); 30 | 31 | expect(result.current.isNotFound).toBeTruthy(); 32 | }); 33 | }); 34 | 35 | context("Haven't NOT_FOUND error type", () => { 36 | it('should return false', () => { 37 | given('errorType', () => ('')); 38 | 39 | const { result } = renderNotFound(); 40 | 41 | expect(result.current.isNotFound).toBeFalsy(); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('calls reset', () => { 47 | it('should be listens dispatch action', () => { 48 | given('errorType', () => ('')); 49 | 50 | const { result } = renderNotFound(); 51 | 52 | act(() => { 53 | result.current.reset(); 54 | }); 55 | 56 | expect(dispatch).toBeCalledWith({ 57 | type: 'common/resetError', 58 | }); 59 | }); 60 | }); 61 | 62 | describe('calls showNotFound', () => { 63 | it('should be listens dispatch action', () => { 64 | given('errorType', () => ('')); 65 | 66 | const { result } = renderNotFound(); 67 | 68 | act(() => { 69 | result.current.showNotFound(); 70 | }); 71 | 72 | expect(dispatch).toBeCalledWith({ 73 | type: 'common/setNotFound', 74 | }); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/components/introduce/AverageReview.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import AverageReview from './AverageReview'; 6 | import InjectMockProviders from '../common/test/InjectMockProviders'; 7 | 8 | describe('AverageReview', () => { 9 | const renderAverageReview = ({ reviews = [], width = 700 }) => render(( 10 | 11 | 14 | 15 | )); 16 | 17 | context('When Desktop screen', () => { 18 | context('When the average rating is integer', () => { 19 | const reviews = [ 20 | { rating: 3 }, 21 | { rating: 3 }, 22 | ]; 23 | it('Renders average reviews contents', () => { 24 | const { container } = renderAverageReview({ reviews }); 25 | 26 | expect(container).toHaveTextContent(`스터디를 참여한 ${reviews.length}명의 회원 평균평점`); 27 | expect(container).toHaveTextContent(6.0); 28 | expect(container.innerHTML).toContain('width: 40px;'); 29 | }); 30 | }); 31 | 32 | context("When the average rating isn't integer", () => { 33 | const reviews = [ 34 | { rating: 3 }, 35 | { rating: 4 }, 36 | { rating: 4 }, 37 | ]; 38 | it('Renders average reviews contents', () => { 39 | const { container } = renderAverageReview({ reviews }); 40 | 41 | expect(container).toHaveTextContent(`스터디를 참여한 ${reviews.length}명의 회원 평균평점`); 42 | expect(container).toHaveTextContent(7.4); 43 | }); 44 | }); 45 | }); 46 | 47 | context('When Mobile screen', () => { 48 | const reviews = [ 49 | { rating: 3 }, 50 | { rating: 3 }, 51 | ]; 52 | it('Renders average reviews contents', () => { 53 | const { container } = renderAverageReview({ reviews, width: 400 }); 54 | 55 | expect(container).toHaveTextContent(`스터디를 참여한 ${reviews.length}명의 회원 평균평점`); 56 | expect(container).toHaveTextContent(6.0); 57 | expect(container.innerHTML).toContain('width: 32px;'); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/assets/icons/get.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/containers/write/WriteFormContainer.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { fireEvent, render } from '@testing-library/react'; 6 | 7 | import WriteFormContainer from './WriteFormContainer'; 8 | import MockTheme from '../../components/common/test/MockTheme'; 9 | 10 | describe('WriteFormContainer', () => { 11 | const dispatch = jest.fn(); 12 | 13 | beforeEach(() => { 14 | dispatch.mockClear(); 15 | 16 | useDispatch.mockImplementation(() => dispatch); 17 | 18 | useSelector.mockImplementation((state) => state({ 19 | groupReducer: { 20 | writeField: { 21 | title: '', 22 | contents: '', 23 | moderatorId: '', 24 | applyEndDate: '', 25 | participants: [], 26 | personnel: 0, 27 | }, 28 | }, 29 | })); 30 | }); 31 | 32 | const renderWriteFormContainer = () => render(( 33 | 34 | 35 | 36 | )); 37 | 38 | describe('render Write Form Container contents text', () => { 39 | it('renders write input text', () => { 40 | const { getByPlaceholderText, getByLabelText } = renderWriteFormContainer(); 41 | 42 | expect(getByPlaceholderText('제목을 입력하세요')).not.toBeNull(); 43 | expect(getByLabelText('모집 마감 날짜')).not.toBeNull(); 44 | expect(getByLabelText('참여 인원 수')).not.toBeNull(); 45 | }); 46 | }); 47 | 48 | describe('dispatch actions call', () => { 49 | const { value, name } = { 50 | name: 'title', 51 | value: '안녕하세요!', 52 | }; 53 | it('listens actions change event', () => { 54 | const { getByPlaceholderText } = renderWriteFormContainer(); 55 | 56 | const inputTitle = getByPlaceholderText('제목을 입력하세요'); 57 | 58 | expect(inputTitle).toHaveValue(''); 59 | 60 | fireEvent.change(inputTitle, { 61 | target: { value, name }, 62 | }); 63 | 64 | expect(dispatch).toBeCalledWith({ 65 | payload: { 66 | name, 67 | value, 68 | }, 69 | type: 'group/changeWriteField', 70 | }); 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/containers/write/WriteEditorContainer.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { fireEvent, render } from '@testing-library/react'; 6 | 7 | import WriteEditorContainer from './WriteEditorContainer'; 8 | import MockTheme from '../../components/common/test/MockTheme'; 9 | 10 | describe('WriteEditorContainer', () => { 11 | const dispatch = jest.fn(); 12 | 13 | beforeEach(() => { 14 | dispatch.mockClear(); 15 | 16 | useDispatch.mockImplementation(() => dispatch); 17 | 18 | useSelector.mockImplementation((state) => state({ 19 | groupReducer: { 20 | writeField: { 21 | contents: given.contents, 22 | }, 23 | }, 24 | })); 25 | }); 26 | 27 | const renderWriteEditorContainer = () => render(( 28 | 29 | 30 | 31 | )); 32 | 33 | context('with contents', () => { 34 | given('contents', () => ('

test

')); 35 | 36 | describe('render Write Editor Container contents text', () => { 37 | it('renders initial contents', () => { 38 | const { container } = renderWriteEditorContainer(); 39 | 40 | expect(container).toHaveTextContent(('test')); 41 | }); 42 | }); 43 | 44 | describe('dispatch actions call', () => { 45 | it('listens actions changeWriteField event', () => { 46 | const { getByLabelText, container } = renderWriteEditorContainer(); 47 | 48 | const contents = getByLabelText('contents').querySelector('div'); 49 | 50 | fireEvent.keyPress(contents, { 51 | target: { innerHTML: '안녕하세요!' }, 52 | }); 53 | 54 | expect(container).toHaveTextContent('안녕하세요!'); 55 | }); 56 | }); 57 | }); 58 | 59 | context('without contents', () => { 60 | given('contents', () => ('')); 61 | 62 | describe('render Write Editor Container contents text', () => { 63 | it('renders editor placeholder text', () => { 64 | const { container } = renderWriteEditorContainer(); 65 | 66 | expect(container).toHaveTextContent(('내용을 작성해주세요.')); 67 | }); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/components/main/StudyGroup.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render } from '@testing-library/react'; 4 | 5 | import { MemoryRouter } from 'react-router-dom'; 6 | 7 | import { tomorrow } from '../../util/utils'; 8 | 9 | import StudyGroup from './StudyGroup'; 10 | import MockTheme from '../common/test/MockTheme'; 11 | 12 | const isCheckOverTen = (calendar) => (calendar < 9 ? '0' : ''); 13 | 14 | describe('StudyGroup', () => { 15 | const renderStudyGroup = ({ group }) => render(( 16 | 17 | 18 | 21 | 22 | 23 | )); 24 | 25 | describe('renders study group text contents', () => { 26 | const tomorrowDate = new Date(tomorrow); 27 | const month = tomorrowDate.getMonth(); 28 | const date = tomorrowDate.getDate(); 29 | 30 | const dateFormat = `${tomorrowDate.getFullYear()}년 ${isCheckOverTen(month)}${month + 1}월 ${isCheckOverTen(date)}${date}일`; 31 | 32 | const group = { 33 | id: 1, 34 | title: '스터디를 소개합니다.2', 35 | moderatorId: 'user2', 36 | applyEndDate: tomorrowDate, 37 | participants: [ 38 | 'user2', 39 | ], 40 | personnel: 2, 41 | tags: [ 42 | 'JavaScript', 43 | 'React', 44 | 'Algorithm', 45 | ], 46 | }; 47 | it('renders title, moderatorId, tags', () => { 48 | const { container } = renderStudyGroup({ group }); 49 | 50 | expect(container).toHaveTextContent('스터디를 소개합니다.2'); 51 | expect(container).toHaveTextContent('user2'); 52 | group.tags.forEach((tag) => { 53 | expect(container).toHaveTextContent(`#${tag}`); 54 | }); 55 | expect(container).toHaveTextContent(dateFormat); 56 | }); 57 | 58 | it('renders changed applyEndDate format', () => { 59 | const { container } = renderStudyGroup({ group }); 60 | 61 | expect(container).toHaveTextContent(dateFormat); 62 | }); 63 | 64 | it('renders study status is Recruiting', () => { 65 | const { container } = renderStudyGroup({ group }); 66 | 67 | expect(container).toHaveTextContent('하루 후 모집 마감'); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /tests/auth/login_test.js: -------------------------------------------------------------------------------- 1 | Feature('사용자가 스터디 개설 및 스터디 참여를 하기 위해 로그인을 할 수 있다.'); 2 | 3 | const step = codeceptjs.container.plugins('commentStep'); 4 | 5 | const Given = (given) => step`${given}`; 6 | const When = (when) => step`${when}`; 7 | const Then = (then) => step`${then}`; 8 | 9 | Scenario('올바르게 로그인에 성공한 경우', ({ I }) => { 10 | Given('로그인 페이지에서'); 11 | I.amOnPage('/login'); 12 | 13 | When('이메일, 비밀번호를 입력 후 로그인 버튼을 클릭하면'); 14 | I.fillField('input[name=userEmail]', 'test@test.com'); 15 | I.fillField('input[name=password]', secret('123123')); 16 | I.click('button[type=submit]'); 17 | 18 | Then('메인 페이지로 이동한다.'); 19 | I.seeCurrentUrlEquals('/'); 20 | }); 21 | 22 | Scenario('이메일 또는 비밀번호 필드에 입력을 하지 않은 경우', ({ I }) => { 23 | Given('로그인 페이지에서'); 24 | I.amOnPage('/login'); 25 | 26 | When('필드에 입력을 하지 않고 로그인 버튼을 클릭하면'); 27 | I.fillField('input[name=userEmail]', ''); 28 | I.fillField('input[name=password]', ''); 29 | I.click('button[type=submit]'); 30 | 31 | Then('"입력이 안된 사항이 있습니다." 메시지가 보인다.'); 32 | I.see('입력이 안된 사항이 있습니다.'); 33 | }); 34 | 35 | Scenario('이메일 형식으로 입력하지 않은 경우', ({ I }) => { 36 | Given('로그인 페이지에서'); 37 | I.amOnPage('/login'); 38 | 39 | When('이메일 형식이 아닌 형태로 입력 후 로그인 버튼을 클릭하면'); 40 | I.fillField('input[name=userEmail]', 'test'); 41 | I.fillField('input[name=password]', secret('123123')); 42 | I.click('button[type=submit]'); 43 | 44 | Then('"이메일 형식으로 입력하세요." 메시지가 보인다.'); 45 | I.see('이메일 형식으로 입력하세요.'); 46 | }); 47 | 48 | Scenario('비밀번호가 일치하지 않은 경우', ({ I }) => { 49 | Given('로그인 페이지에서'); 50 | I.amOnPage('/login'); 51 | 52 | When('일치하지 않는 비밀번호를 입력 후 로그인 버튼을 클릭하면'); 53 | I.fillField('input[name=userEmail]', 'test@test.com'); 54 | I.fillField('input[name=password]', '11'); 55 | I.click('button[type=submit]'); 56 | 57 | Then('"비밀번호가 일치하지 않습니다." 메시지가 보인다.'); 58 | I.see('비밀번호가 일치하지 않습니다.'); 59 | }); 60 | 61 | Scenario('존재하지 않는 이메일인 경우 경우', ({ I }) => { 62 | Given('로그인 페이지에서'); 63 | I.amOnPage('/login'); 64 | 65 | When('존재하지 않는 이메일을 입력 후 로그인 버튼을 클릭하면'); 66 | I.fillField('input[name=userEmail]', 'test@test.net'); 67 | I.fillField('input[name=password]', secret('123123')); 68 | I.click('button[type=submit]'); 69 | 70 | Then('"비밀번호가 일치하지 않습니다." 메시지가 보인다.'); 71 | I.see('가입된 사용자가 아닙니다.'); 72 | }); 73 | -------------------------------------------------------------------------------- /src/components/common/DateTimeChange.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import 'moment/locale/ko'; 4 | import moment from 'moment'; 5 | import Moment from 'react-moment'; 6 | 7 | import styled from '@emotion/styled'; 8 | 9 | import { APPLY_STATUS } from '../../util/constants/constants'; 10 | import { authorizedUsersNumber, changeDateToTime, isCheckedTimeStatus } from '../../util/utils'; 11 | 12 | import DateTimeStatus from '../../styles/DateTimeStatus'; 13 | 14 | const { DEAD_LINE } = APPLY_STATUS; 15 | 16 | moment.locale('ko'); 17 | 18 | const MomentWrapper = styled(Moment)` 19 | font-family: 'Nanum Gothic', sans-serif; 20 | `; 21 | 22 | const DateTimeChange = ({ group, page, time }) => { 23 | const { participants, personnel, applyEndDate } = group; 24 | 25 | const applyEndTime = changeDateToTime(applyEndDate); 26 | 27 | const valid = { 28 | time, applyEndTime, participants, personnel, 29 | }; 30 | 31 | const mainTimeStatus = () => { 32 | if (isCheckedTimeStatus(valid)) { 33 | return ( 34 | {DEAD_LINE} 35 | ); 36 | } 37 | 38 | return ( 39 | 40 | {applyEndTime} 41 |   42 | {DEAD_LINE} 43 | 44 | ); 45 | }; 46 | 47 | const introduceTimeStatus = () => { 48 | if (isCheckedTimeStatus(valid)) { 49 | return ( 50 | 51 | {DEAD_LINE} 52 | 53 | ); 54 | } 55 | 56 | return ( 57 | 58 | {applyEndTime} 59 |   60 | {DEAD_LINE} 61 | 62 | ); 63 | }; 64 | 65 | return ( 66 | <> 67 | {page === 'introduce' 68 | ? introduceTimeStatus() 69 | : ( 70 | <> 71 | 72 | {`신청 현황: ${authorizedUsersNumber(participants)} / ${personnel}`} 73 | 74 | {mainTimeStatus()} 75 | 76 | )} 77 | 78 | ); 79 | }; 80 | 81 | export default React.memo(DateTimeChange); 82 | -------------------------------------------------------------------------------- /src/pages/IntroducePage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { render } from '@testing-library/react'; 6 | 7 | import IntroducePage from './IntroducePage'; 8 | import InjectMockProviders from '../components/common/test/InjectMockProviders'; 9 | 10 | describe('IntroducePage', () => { 11 | const dispatch = jest.fn(); 12 | 13 | beforeEach(() => { 14 | dispatch.mockClear(); 15 | 16 | useDispatch.mockImplementation(() => dispatch); 17 | 18 | useSelector.mockImplementation((state) => state({ 19 | groupReducer: { 20 | group: { 21 | id: 1, 22 | moderatorId: 'user1', 23 | title: '스터디를 소개합니다. 1', 24 | personnel: 7, 25 | contents: '우리는 이것저것 합니다.1', 26 | participants: [], 27 | tags: [ 28 | 'JavaScript', 29 | 'React', 30 | 'Algorithm', 31 | ], 32 | }, 33 | applyFields: { 34 | reason: '', 35 | wantToGet: '', 36 | }, 37 | studyReviewFields: { 38 | rating: 3, 39 | review: '', 40 | }, 41 | }, 42 | authReducer: {}, 43 | commonReducer: false, 44 | })); 45 | }); 46 | 47 | context('with params props', () => { 48 | it('renders title and theme toggle button', () => { 49 | const params = { id: '1' }; 50 | 51 | const { container, getByTestId } = render(( 52 | 53 | 54 | 55 | )); 56 | 57 | expect(dispatch).toBeCalledTimes(1); 58 | 59 | expect(container).toHaveTextContent('스터디를 소개합니다. 1'); 60 | 61 | expect(getByTestId('theme-toggle')).not.toBeNull(); 62 | }); 63 | }); 64 | 65 | context('without params props', () => { 66 | it('renders title and theme toggle button', () => { 67 | const { container, getByTestId } = render(( 68 | 69 | 70 | 71 | )); 72 | 73 | expect(dispatch).toBeCalledTimes(1); 74 | 75 | expect(container).toHaveTextContent('스터디를 소개합니다. 1'); 76 | 77 | expect(getByTestId('theme-toggle')).not.toBeNull(); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/containers/auth/RegisterFormContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from 'react'; 2 | 3 | import { useUnmount } from 'react-use'; 4 | import { useForm } from 'react-hook-form'; 5 | import { useHistory } from 'react-router-dom'; 6 | import { useSelector, useDispatch } from 'react-redux'; 7 | 8 | import { clearAuth, requestRegister } from '../../reducers/authSlice'; 9 | 10 | import { getAuth, isNullFields } from '../../util/utils'; 11 | import { ERROR_MESSAGE, FIREBASE_AUTH_ERROR_MESSAGE } from '../../util/constants/messages'; 12 | 13 | import AuthForm from '../../components/auth/AuthForm'; 14 | 15 | const { NO_INPUT, NOT_MATCH_PASSWORD, FAILURE_REGISTER } = ERROR_MESSAGE; 16 | 17 | const RegisterFormContainer = () => { 18 | const [error, setError] = useState(null); 19 | 20 | const dispatch = useDispatch(); 21 | const history = useHistory(); 22 | 23 | const auth = useSelector(getAuth('auth')); 24 | const user = useSelector(getAuth('user')); 25 | const authError = useSelector(getAuth('authError')); 26 | 27 | const { register, handleSubmit, setValue } = useForm(); 28 | 29 | const onSubmit = useCallback((formData) => { 30 | const { userEmail, password, passwordConfirm } = formData; 31 | 32 | if (isNullFields(formData)) { 33 | setError(NO_INPUT); 34 | return; 35 | } 36 | 37 | if (password !== passwordConfirm) { 38 | setValue('password', ''); 39 | setValue('passwordConfirm', ''); 40 | setError(NOT_MATCH_PASSWORD); 41 | return; 42 | } 43 | 44 | dispatch(requestRegister({ userEmail, password })); 45 | }, [dispatch]); 46 | 47 | useEffect(() => { 48 | if (user) { 49 | history.push('/'); 50 | } 51 | }, [user]); 52 | 53 | useEffect(() => { 54 | if (auth) { 55 | history.push('/login'); 56 | } 57 | 58 | if (authError) { 59 | setError( 60 | FIREBASE_AUTH_ERROR_MESSAGE[authError] 61 | || FAILURE_REGISTER, 62 | ); 63 | return; 64 | } 65 | 66 | setError(null); 67 | }, [auth, authError]); 68 | 69 | useUnmount(() => { 70 | dispatch(clearAuth()); 71 | }); 72 | 73 | return ( 74 | 80 | ); 81 | }; 82 | 83 | export default RegisterFormContainer; 84 | -------------------------------------------------------------------------------- /src/containers/introduce/IntroduceHeaderContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from 'react'; 2 | 3 | import { useInterval } from 'react-use'; 4 | import { useDispatch, useSelector } from 'react-redux'; 5 | 6 | import { useMediaQuery } from 'react-responsive'; 7 | 8 | import { getAuth, getGroup } from '../../util/utils'; 9 | import { deleteParticipant, updateConfirmParticipant, updateParticipant } from '../../reducers/groupSlice'; 10 | 11 | import IntroduceHeader from '../../components/introduce/IntroduceHeader'; 12 | import ApplicantViewButton from '../../components/introduce/ApplicantViewButton'; 13 | import ModeratorViewButton from '../../components/introduce/ModeratorViewButton'; 14 | import ResponsiveGroupContentLoader from '../../components/loader/ResponsiveGroupContentLoader'; 15 | 16 | const IntroduceHeaderContainer = () => { 17 | const [realTime, setRealTime] = useState(Date.now()); 18 | 19 | const dispatch = useDispatch(); 20 | 21 | const isDesktop = useMediaQuery({ 22 | minWidth: 451, 23 | }); 24 | 25 | const isMobile = useMediaQuery({ 26 | maxWidth: 450, 27 | }); 28 | 29 | const user = useSelector(getAuth('user')); 30 | const group = useSelector(getGroup('group')); 31 | 32 | useInterval(() => setRealTime(Date.now()), 1000); 33 | 34 | const onApplyStudy = useCallback((formData) => { 35 | dispatch(updateParticipant(formData)); 36 | }, [dispatch]); 37 | 38 | const onApplyCancel = useCallback(() => { 39 | dispatch(deleteParticipant()); 40 | }, [dispatch]); 41 | 42 | const onUpdateConfirmParticipant = useCallback((id) => { 43 | dispatch(updateConfirmParticipant(id)); 44 | }, [dispatch]); 45 | 46 | if (!group) { 47 | return ( 48 | 52 | ); 53 | } 54 | 55 | return ( 56 | 57 | 64 | 70 | 71 | ); 72 | }; 73 | 74 | export default React.memo(IntroduceHeaderContainer); 75 | -------------------------------------------------------------------------------- /src/components/auth/AuthForm.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { MemoryRouter } from 'react-router-dom'; 4 | 5 | import { render } from '@testing-library/react'; 6 | 7 | import AuthForm from './AuthForm'; 8 | import MockTheme from '../common/test/MockTheme'; 9 | 10 | describe('AuthForm', () => { 11 | const handleSubmit = jest.fn(); 12 | const mockRegister = jest.fn(); 13 | 14 | beforeEach(() => { 15 | handleSubmit.mockClear(); 16 | mockRegister.mockClear(); 17 | }); 18 | 19 | const renderAuthForm = ({ type, error = '' }) => render(( 20 | 21 | 22 | 28 | 29 | 30 | )); 31 | 32 | context('Has error status', () => { 33 | const state = { 34 | type: 'login', 35 | error: '에러!', 36 | }; 37 | 38 | it('renders error message', () => { 39 | const { container } = renderAuthForm(state); 40 | 41 | expect(container).toHaveTextContent('에러!'); 42 | }); 43 | }); 44 | 45 | context("hasn't error status", () => { 46 | context('when type is login', () => { 47 | const login = { 48 | type: 'login', 49 | }; 50 | 51 | it('renders login form text', () => { 52 | const { container, getByPlaceholderText } = renderAuthForm(login); 53 | 54 | expect(container).toHaveTextContent('로그인'); 55 | expect(getByPlaceholderText('이메일')).not.toBeNull(); 56 | expect(getByPlaceholderText('비밀번호')).not.toBeNull(); 57 | }); 58 | 59 | it('renders register link', () => { 60 | const { getByTestId } = renderAuthForm(login); 61 | 62 | const link = getByTestId('sign-up-link'); 63 | 64 | expect(link).not.toBeNull(); 65 | }); 66 | }); 67 | 68 | context('when type is register', () => { 69 | const register = { 70 | type: 'register', 71 | }; 72 | 73 | it('renders register form text', () => { 74 | const { container, getByPlaceholderText } = renderAuthForm(register); 75 | 76 | expect(container).toHaveTextContent('회원가입'); 77 | expect(getByPlaceholderText('이메일')).not.toBeNull(); 78 | expect(getByPlaceholderText('비밀번호')).not.toBeNull(); 79 | expect(getByPlaceholderText('비밀번호 확인')).not.toBeNull(); 80 | }); 81 | }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/styles/ThemeToggleButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import Toggle from 'react-toggle'; 6 | 7 | import SunIcon from '../assets/icons/sun.svg'; 8 | import MoonIcon from '../assets/icons/moon.svg'; 9 | 10 | import mq from './responsive'; 11 | import palette from './palette'; 12 | 13 | import 'react-toggle/style.css'; 14 | 15 | const ToggleWrapper = styled(Toggle)` 16 | &.react-toggle .react-toggle-track { 17 | background-color: #ffa000; 18 | } 19 | 20 | &.react-toggle .react-toggle-track { 21 | ${mq({ 22 | width: ['55px', '60px'], 23 | height: ['27px', '30px'], 24 | })}; 25 | } 26 | 27 | &.react-toggle .react-toggle-thumb { 28 | ${mq({ 29 | width: ['27px', '30px'], 30 | height: ['27px', '30px'], 31 | })}; 32 | 33 | top: 0px; 34 | left: 0px; 35 | border: 2px solid #ffa000; 36 | } 37 | 38 | &.react-toggle--checked .react-toggle-thumb { 39 | ${mq({ 40 | left: ['28px', '31px'], 41 | })}; 42 | 43 | border: 2px solid ${palette.gray[6]}; 44 | } 45 | 46 | &.react-toggle--checked .react-toggle-track { 47 | background-color: ${palette.gray[6]}; 48 | } 49 | 50 | &.react-toggle .react-toggle-track-check { 51 | ${mq({ 52 | width: ['26px', '28px'], 53 | height: ['26px', '28px'], 54 | left: ['3px', '4px'], 55 | })}; 56 | } 57 | 58 | &.react-toggle .react-toggle-track-x { 59 | ${mq({ 60 | width: ['23px', '24px'], 61 | height: ['23px', '24px'], 62 | right: ['3px', '4px'], 63 | })}; 64 | } 65 | 66 | &.react-toggle:hover:not(.react-toggle--disabled) .react-toggle-track { 67 | background-color: #f57c00; 68 | } 69 | 70 | &.react-toggle--checked:hover:not(.react-toggle--disabled) .react-toggle-track { 71 | background-color: ${palette.gray[7]}; 72 | } 73 | 74 | &.react-toggle--focus .react-toggle-thumb { 75 | box-shadow: none; 76 | } 77 | 78 | &.react-toggle:active:not(.react-toggle--disabled) .react-toggle-thumb { 79 | box-shadow: none; 80 | } 81 | `; 82 | 83 | const ThemeToggleButton = ({ onChange, theme }) => ( 84 | , 92 | unchecked: , 93 | }} 94 | data-testid="theme-toggle" 95 | /> 96 | ); 97 | export default ThemeToggleButton; 98 | -------------------------------------------------------------------------------- /src/components/myInfo/modal/ConfirmPasswordModal.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import MockTheme from '../../common/test/MockTheme'; 6 | import ConfirmPasswordModal from './ConfirmPasswordModal'; 7 | 8 | describe('ConfirmPasswordModal', () => { 9 | const handleCancel = jest.fn(); 10 | const handleConfirm = jest.fn(); 11 | const handleChange = jest.fn(); 12 | 13 | const renderConfirmPasswordModal = ({ visible }) => render(( 14 | 15 | 23 | 24 | )); 25 | 26 | context('Is visible', () => { 27 | it('should be render password confirm input and title', () => { 28 | const { container, getByLabelText } = renderConfirmPasswordModal({ 29 | visible: true, 30 | }); 31 | 32 | expect(container).toHaveTextContent('비밀번호 확인'); 33 | expect(getByLabelText('password-confirm-input')).not.toBeNull(); 34 | }); 35 | 36 | it('should be listen call cancel event', () => { 37 | const { getByText } = renderConfirmPasswordModal({ 38 | visible: true, 39 | }); 40 | 41 | fireEvent.click(getByText('취소')); 42 | 43 | expect(handleCancel).toBeCalledTimes(1); 44 | }); 45 | 46 | it('should be listen call confirm event', () => { 47 | const { getByText } = renderConfirmPasswordModal({ 48 | visible: true, 49 | }); 50 | 51 | fireEvent.submit(getByText('확인')); 52 | 53 | expect(handleConfirm).toBeCalledTimes(1); 54 | }); 55 | 56 | it('should be listen call input change event', () => { 57 | const { getByLabelText } = renderConfirmPasswordModal({ 58 | visible: true, 59 | }); 60 | 61 | const input = getByLabelText('password-confirm-input'); 62 | 63 | fireEvent.change(input, { 64 | target: { value: 'password' }, 65 | }); 66 | 67 | expect(handleChange).toBeCalledTimes(1); 68 | }); 69 | }); 70 | 71 | context("Isn't visible", () => { 72 | it('should be render password confirm input', () => { 73 | const { container } = renderConfirmPasswordModal({ 74 | visible: false, 75 | }); 76 | 77 | expect(container).toBeEmptyDOMElement(); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/containers/base/ThemeToggleContainer.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import { useDispatch, useSelector } from 'react-redux'; 6 | 7 | import ThemeToggleContainer from './ThemeToggleContainer'; 8 | 9 | describe('ThemeToggleContainer', () => { 10 | const dispatch = jest.fn(); 11 | 12 | beforeEach(() => { 13 | dispatch.mockClear(); 14 | 15 | useDispatch.mockImplementation(() => dispatch); 16 | 17 | useSelector.mockImplementation((state) => state({ 18 | commonReducer: { 19 | theme: given.theme, 20 | }, 21 | })); 22 | }); 23 | 24 | const renderThemeToggleContainer = () => render(( 25 | 26 | )); 27 | 28 | context('When theme is Light', () => { 29 | given('theme', () => (false)); 30 | 31 | it('renders theme toggle button', () => { 32 | const { getByTestId } = renderThemeToggleContainer(); 33 | 34 | expect(getByTestId('theme-toggle')).not.toBeNull(); 35 | expect(getByTestId('theme-toggle')).toHaveAttribute('title', 'light'); 36 | }); 37 | 38 | describe('When click theme toggle button', () => { 39 | it('should be listens dispatch changeTheme event', () => { 40 | const { getByTestId } = renderThemeToggleContainer(); 41 | 42 | const button = getByTestId('theme-toggle'); 43 | 44 | expect(button).toHaveAttribute('title', 'light'); 45 | 46 | fireEvent.click(button); 47 | 48 | expect(dispatch).toBeCalledWith({ 49 | type: 'common/changeTheme', 50 | }); 51 | }); 52 | }); 53 | }); 54 | 55 | context('When theme is Dark', () => { 56 | given('theme', () => (true)); 57 | it('renders theme toggle button', () => { 58 | const { getByTestId } = renderThemeToggleContainer(); 59 | 60 | expect(getByTestId('theme-toggle')).not.toBeNull(); 61 | expect(getByTestId('theme-toggle')).toHaveAttribute('title', 'dark'); 62 | }); 63 | 64 | describe('When click theme toggle button', () => { 65 | it('should be listens dispatch changeTheme event', () => { 66 | const { getByTestId } = renderThemeToggleContainer(); 67 | 68 | const button = getByTestId('theme-toggle'); 69 | 70 | expect(button).toHaveAttribute('title', 'dark'); 71 | 72 | fireEvent.click(button); 73 | 74 | expect(dispatch).toBeCalledWith({ 75 | type: 'common/changeTheme', 76 | }); 77 | }); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/components/base/Header.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { render, fireEvent } from '@testing-library/react'; 4 | 5 | import Header from './Header'; 6 | import InjectMockProviders from '../common/test/InjectMockProviders'; 7 | 8 | describe('Header', () => { 9 | beforeEach(() => { 10 | jest.clearAllMocks(); 11 | }); 12 | 13 | const handleClick = jest.fn(); 14 | 15 | const renderHeader = ({ user, width = 700 }) => render(( 16 | 17 |
21 | 22 | )); 23 | 24 | context('with user', () => { 25 | const user = 'seungmin@naver.com'; 26 | 27 | context('When desktop screen', () => { 28 | it('renders Header text', () => { 29 | const { container } = renderHeader({ user }); 30 | 31 | expect(container).toHaveTextContent('로그아웃'); 32 | expect(container).toHaveTextContent(user); 33 | }); 34 | 35 | it('Click logout button listens call', () => { 36 | const { getByText } = renderHeader({ user }); 37 | 38 | fireEvent.click(getByText('로그아웃')); 39 | 40 | expect(handleClick).toBeCalledTimes(1); 41 | }); 42 | }); 43 | 44 | context('When mobile screen', () => { 45 | it('The dropdown disappears when clicking outside after clicking the user-icon', () => { 46 | const { getByText, getByTestId, container } = renderHeader({ user, width: 400 }); 47 | 48 | fireEvent.click(getByTestId('user-icon')); 49 | 50 | expect(container).toHaveTextContent('seungmin@naver.com'); 51 | 52 | fireEvent.mouseDown(getByText('ConStu')); 53 | 54 | expect(container).not.toHaveTextContent('seungmin@naver.com'); 55 | }); 56 | 57 | it('The dropdown does not disappear when mosedown the dropdown after clicking the user-icon.', () => { 58 | const { getByText, getByTestId, container } = renderHeader({ user, width: 400 }); 59 | 60 | fireEvent.click(getByTestId('user-icon')); 61 | 62 | expect(container).toHaveTextContent('seungmin@naver.com'); 63 | 64 | fireEvent.mouseDown(getByText('seungmin@naver.com')); 65 | 66 | expect(container).toHaveTextContent('seungmin@naver.com'); 67 | }); 68 | }); 69 | }); 70 | 71 | context('without user', () => { 72 | it('renders Header text', () => { 73 | const { container } = renderHeader({ user: null }); 74 | 75 | expect(container).toHaveTextContent('로그인'); 76 | expect(container).toHaveTextContent('회원가입'); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/components/common/ModalWindow.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | import { css } from '@emotion/react'; 5 | 6 | import mq from '../../styles/responsive'; 7 | 8 | import Button from '../../styles/Button'; 9 | 10 | const ModalWindowWrapper = styled.div` 11 | top: 0; 12 | left: 0; 13 | position: fixed; 14 | z-index: 101; 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | width: 100%; 19 | height: 100%; 20 | background: rgba(0, 0, 0, 0.25); 21 | 22 | ${(props) => props.visible && css` 23 | &.animation { 24 | animation-name: fade-in; 25 | animation-duration: 0.3s; 26 | animation-fill-mode: both; 27 | } 28 | 29 | @keyframes fade-in { 30 | 0% { 31 | opacity: 0; 32 | } 33 | 100% { 34 | opacity: 1; 35 | } 36 | } 37 | `}; 38 | `; 39 | 40 | const ModalBoxWrapper = styled.div` 41 | ${mq({ 42 | width: ['300px', '320px'], 43 | })}; 44 | 45 | display: flex; 46 | flex-direction: column; 47 | padding: 1.5rem; 48 | border-radius: 6px; 49 | box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.125); 50 | background: ${({ theme }) => theme.subBaseTone[0]}; 51 | 52 | h2 { 53 | ${mq({ fontSize: ['1.2rem', '1.4rem'] })}; 54 | 55 | margin-top: 0; 56 | margin-bottom: 2rem; 57 | } 58 | 59 | p { 60 | font-size: 1rem; 61 | font-family: 'Nanum-Gothic', sans-serif; 62 | margin-bottom: 2rem; 63 | } 64 | 65 | .buttons { 66 | display: flex; 67 | justify-content: flex-end; 68 | } 69 | `; 70 | 71 | const StyledButton = styled(Button)` 72 | padding: 0.4rem 1rem; 73 | 74 | &:last-of-type { 75 | margin-left: .7rem; 76 | } 77 | `; 78 | 79 | const ModalWindow = ({ 80 | visible, 81 | title, 82 | description, 83 | confirmText = '확인', 84 | cancelText = '취소', 85 | onConfirm, 86 | onCancel, 87 | }) => { 88 | if (!visible) { 89 | return null; 90 | } 91 | 92 | return ( 93 | 94 | 95 |

{title}

96 |

{description}

97 |
98 | {cancelText} 99 | {onConfirm && ( 100 | {confirmText} 101 | )} 102 |
103 |
104 |
105 | ); 106 | }; 107 | 108 | export default ModalWindow; 109 | -------------------------------------------------------------------------------- /tests/auth/register_test.js: -------------------------------------------------------------------------------- 1 | Feature('사용자가 스터디 개설 및 스터디 참여를 하기 위해 회원가입을 할 수 있다.'); 2 | 3 | const step = codeceptjs.container.plugins('commentStep'); 4 | 5 | const Given = (given) => step`${given}`; 6 | const When = (when) => step`${when}`; 7 | const Then = (then) => step`${then}`; 8 | 9 | Scenario('이메일 또는 비밀번호, 비밀번호 확인 필드에 입력을 하지 않은 경우', ({ I }) => { 10 | Given('회원가입 페이지에서'); 11 | I.amOnPage('/register'); 12 | 13 | When('필드에 입력을 하지 않고 회원가입 버튼을 클릭하면'); 14 | I.fillField('input[name=userEmail]', ''); 15 | I.fillField('input[name=password]', ''); 16 | I.fillField('input[name=passwordConfirm]', '123'); 17 | I.click('button[type=submit]'); 18 | 19 | Then('"입력이 안된 사항이 있습니다." 메시지가 보인다.'); 20 | I.see('입력이 안된 사항이 있습니다.'); 21 | }); 22 | 23 | Scenario('이메일 형식으로 입력하지 않은 경우', ({ I }) => { 24 | Given('회원가입 페이지에서'); 25 | I.amOnPage('/register'); 26 | 27 | When('이메일 형식이 아닌 형태로 입력 후 회원가입 버튼을 클릭하면'); 28 | I.fillField('input[name=userEmail]', 'test'); 29 | I.fillField('input[name=password]', secret('123123')); 30 | I.fillField('input[name=passwordConfirm]', secret('123123')); 31 | I.click('button[type=submit]'); 32 | 33 | Then('"이메일 형식으로 입력하세요." 메시지가 보인다.'); 34 | I.see('이메일 형식으로 입력하세요.'); 35 | }); 36 | 37 | Scenario('비밀번호가 6자리 이하일 경우', ({ I }) => { 38 | Given('회원가입 페이지에서'); 39 | I.amOnPage('/register'); 40 | 41 | When('6자리 이하인 비밀번호를 입력 후 회원가입 버튼을 클릭하면'); 42 | I.fillField('input[name=userEmail]', 'test@test.com'); 43 | I.fillField('input[name=password]', '1233'); 44 | I.fillField('input[name=passwordConfirm]', '1233'); 45 | I.click('button[type=submit]'); 46 | 47 | Then('"6자리 이상의 비밀번호를 입력하세요." 메시지가 보인다.'); 48 | I.see('6자리 이상의 비밀번호를 입력하세요.'); 49 | }); 50 | 51 | Scenario('비밀번호 필드와 비밀번호 확인 필드가 일치하지 않은 경우', ({ I }) => { 52 | Given('회원가입 페이지에서'); 53 | I.amOnPage('/register'); 54 | 55 | When('서로 일치하지 않는 비밀번호와 비밀번호 확인를 입력 후 회원가입 버튼을 클릭하면'); 56 | I.fillField('input[name=userEmail]', 'test@test.com'); 57 | I.fillField('input[name=password]', '1233'); 58 | I.fillField('input[name=passwordConfirm]', '12331'); 59 | I.click('button[type=submit]'); 60 | 61 | Then('"비밀번호가 일치하지 않습니다." 메시지가 보인다.'); 62 | I.see('비밀번호가 일치하지 않습니다.'); 63 | }); 64 | 65 | Scenario('이미 가입된 이메일인 경우 경우', ({ I }) => { 66 | Given('회원가입 페이지에서'); 67 | I.amOnPage('/register'); 68 | 69 | When('이미 가입된 이메일을 입력 후 회원가입 버튼을 클릭하면'); 70 | I.fillField('input[name=userEmail]', 'test@test.com'); 71 | I.fillField('input[name=password]', '123123'); 72 | I.fillField('input[name=passwordConfirm]', '123123'); 73 | I.click('button[type=submit]'); 74 | 75 | Then('"이미 가입된 사용자입니다." 메시지가 보인다.'); 76 | I.see('이미 가입된 사용자입니다.'); 77 | }); 78 | -------------------------------------------------------------------------------- /src/components/myInfo/MembershipWithdrawal.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback, useEffect } from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import Button from '../../styles/Button'; 6 | 7 | import ConfirmPasswordModal from './modal/ConfirmPasswordModal'; 8 | import AskMembershipWithdrawalModal from './modal/AskMembershipWithdrawalModal'; 9 | 10 | const MembershipWithdrawalWrapper = styled.div` 11 | 12 | `; 13 | 14 | const MembershipWithdrawal = ({ onMembershipWithdrawal, onVerificationPassword, auth }) => { 15 | const [error, setError] = useState(false); 16 | const [password, setPassword] = useState(''); 17 | const [askModal, setAskModal] = useState(false); 18 | const [verificationPasswordModal, setVerificationPasswordModal] = useState(false); 19 | 20 | const handleClickDeleteUser = () => setVerificationPasswordModal(true); 21 | 22 | const handleMembershipWithdrawalCancel = () => { 23 | setAskModal(false); 24 | setPassword(''); 25 | }; 26 | 27 | const handleChange = (e) => { 28 | setError(false); 29 | setPassword(e.target.value); 30 | }; 31 | 32 | const handleMembershipWithdrawalConfirm = () => { 33 | setAskModal(false); 34 | onMembershipWithdrawal(); 35 | }; 36 | 37 | const handleVerificationPasswordConfirm = useCallback((e) => { 38 | e.preventDefault(); 39 | 40 | if (!password.trim()) { 41 | setError(true); 42 | return; 43 | } 44 | 45 | onVerificationPassword(password); 46 | setPassword(''); 47 | }, [password]); 48 | 49 | const handleVerificationPasswordCancel = () => { 50 | setError(false); 51 | setVerificationPasswordModal(false); 52 | setPassword(''); 53 | }; 54 | 55 | useEffect(() => { 56 | if (auth === 'REAUTHENTICATE') { 57 | setVerificationPasswordModal(false); 58 | setAskModal(true); 59 | } 60 | }, [auth]); 61 | 62 | return ( 63 | 64 | 70 | 75 | 83 | 84 | ); 85 | }; 86 | 87 | export default MembershipWithdrawal; 88 | -------------------------------------------------------------------------------- /src/pages/MainPage.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | 5 | import { render } from '@testing-library/react'; 6 | 7 | import STUDY_GROUPS from '../../fixtures/study-groups'; 8 | 9 | import MainPage from './MainPage'; 10 | import InjectMockProviders from '../components/common/test/InjectMockProviders'; 11 | 12 | describe('MainPage', () => { 13 | const dispatch = jest.fn(); 14 | 15 | beforeEach(() => { 16 | dispatch.mockClear(); 17 | 18 | useDispatch.mockImplementation(() => dispatch); 19 | 20 | useSelector.mockImplementation((selector) => selector({ 21 | groupReducer: { 22 | groups: STUDY_GROUPS, 23 | }, 24 | authReducer: { 25 | user: given.user, 26 | }, 27 | commonReducer: { 28 | theme: false, 29 | }, 30 | })); 31 | }); 32 | 33 | const renderMainPage = () => render(( 34 | 35 | 36 | 37 | )); 38 | 39 | describe('renders Main Page text contents', () => { 40 | it('renders theme toggle button', () => { 41 | const { getByTestId } = renderMainPage(); 42 | 43 | expect(getByTestId('theme-toggle')).not.toBeNull(); 44 | }); 45 | 46 | it('renders Main Page Title', () => { 47 | const { container } = renderMainPage(); 48 | 49 | expect(container).toHaveTextContent('스터디를 직접 개설하거나 참여해보세요!'); 50 | }); 51 | 52 | it('renders Main Page study group tags', () => { 53 | const { container } = renderMainPage(); 54 | 55 | STUDY_GROUPS.forEach(({ tags }) => { 56 | tags.forEach((tag) => { 57 | expect(container).toHaveTextContent(tag); 58 | }); 59 | }); 60 | }); 61 | 62 | context('without user', () => { 63 | given('user', () => (null)); 64 | it("doesn't renders Main Page Link text", () => { 65 | const { container } = renderMainPage(); 66 | 67 | expect(container).not.toHaveTextContent('스터디 개설하기'); 68 | }); 69 | }); 70 | 71 | context('with user', () => { 72 | given('user', () => ('user')); 73 | it('renders Main Page Link text', () => { 74 | const { container } = renderMainPage(); 75 | 76 | expect(container).toHaveTextContent('스터디 개설하기'); 77 | }); 78 | }); 79 | }); 80 | 81 | it('calls dispatch with loadStudyGroups action', () => { 82 | const { container } = renderMainPage(); 83 | 84 | expect(dispatch).toBeCalled(); 85 | 86 | expect(container).toHaveTextContent('스터디를 소개합니다.1'); 87 | expect(container).toHaveTextContent('스터디를 소개합니다.2'); 88 | }); 89 | }); 90 | --------------------------------------------------------------------------------