├── src ├── components │ ├── Login │ │ ├── index.js │ │ ├── LoginForm.jsx │ │ ├── Login.jsx │ │ └── LoginForm.test.js │ ├── Owner │ │ ├── index.js │ │ └── Owner.jsx │ ├── Footer │ │ ├── index.js │ │ └── Footer.jsx │ ├── Navbar │ │ ├── index.js │ │ ├── NavbarUser.jsx │ │ └── Navbar.jsx │ ├── Threads │ │ ├── index.js │ │ └── Threads.jsx │ ├── Comments │ │ ├── index.js │ │ ├── Comments.jsx │ │ ├── CommentsForm.jsx │ │ ├── CommentsForm.test.js │ │ └── Comment.jsx │ ├── Register │ │ ├── index.js │ │ ├── Register.jsx │ │ ├── RegisterForm.jsx │ │ └── RegisterForm.test.js │ ├── Categories │ │ ├── index.js │ │ └── Categories.jsx │ ├── Leaderboards │ │ ├── index.js │ │ └── Leaderboards.jsx │ ├── Thread │ │ ├── index.js │ │ ├── ThreadForm.jsx │ │ ├── ThreadAdd.jsx │ │ ├── ThreadForm.test.js │ │ ├── ThreadAction.jsx │ │ └── Thread.jsx │ └── index.js ├── utils │ ├── index.js │ ├── isObjectEmpty.js │ ├── postedAt.js │ ├── renderWithProviders.js │ └── api.js ├── assets │ └── images │ │ └── logo │ │ └── logo-forum-BetHup.png ├── hooks │ ├── index.js │ ├── useAutoFocus.js │ ├── useInput.js │ └── useToggle.js ├── app │ ├── states │ │ ├── isPreload │ │ │ ├── reducer.js │ │ │ ├── action.js │ │ │ ├── reducer.test.js │ │ │ └── action.test.js │ │ ├── authUser │ │ │ ├── reducer.js │ │ │ ├── action.js │ │ │ ├── reducer.test.js │ │ │ └── action.test.js │ │ ├── leaderboards │ │ │ ├── reducer.js │ │ │ ├── action.js │ │ │ ├── reducer.test.js │ │ │ └── action.test.js │ │ ├── users │ │ │ ├── reducer.js │ │ │ └── action.js │ │ ├── shared │ │ │ ├── action.js │ │ │ └── action.test.js │ │ ├── threads │ │ │ ├── reducer.js │ │ │ ├── action.test.js │ │ │ ├── action.js │ │ │ └── reducer.test.js │ │ └── detailThread │ │ │ ├── reducer.js │ │ │ ├── action.test.js │ │ │ ├── action.js │ │ │ └── reducer.test.js │ └── store.js ├── stories │ ├── Categories.stories.js │ ├── Owner.stories.js │ ├── Navbar.stories.js │ └── Leaderboards.stories.js ├── index.css ├── main.jsx ├── pages │ ├── LeaderboardsPage.jsx │ ├── LoginPage.jsx │ ├── RegisterPage.jsx │ ├── ThreadsPage.jsx │ └── ThreadPage.jsx └── App.jsx ├── public ├── favicon.ico ├── logo192.png ├── logo512.png └── manifest.json ├── screenshot ├── 1_ci_check_error.png ├── 2_ci_check_pass.png └── 3_branch_protection.png ├── postcss.config.cjs ├── .prettierrc ├── cypress ├── fixtures │ └── example.json ├── e2e │ ├── leaderboards.cy.js │ ├── login.cy.js │ └── register.cy.js └── support │ ├── e2e.js │ └── commands.js ├── .babelrc ├── vite.config.js ├── cypress.config.js ├── .gitignore ├── .storybook ├── preview.js └── main.js ├── README.md ├── .github └── workflows │ └── ci.yml ├── tailwind.config.js ├── index.html ├── .eslintrc.cjs └── package.json /src/components/Login/index.js: -------------------------------------------------------------------------------- 1 | export { default as Login } from './Login'; 2 | -------------------------------------------------------------------------------- /src/components/Owner/index.js: -------------------------------------------------------------------------------- 1 | export { default as Owner } from './Owner'; 2 | -------------------------------------------------------------------------------- /src/components/Footer/index.js: -------------------------------------------------------------------------------- 1 | export { default as Footer } from './Footer'; 2 | -------------------------------------------------------------------------------- /src/components/Navbar/index.js: -------------------------------------------------------------------------------- 1 | export { default as Navbar } from './Navbar'; 2 | -------------------------------------------------------------------------------- /src/components/Threads/index.js: -------------------------------------------------------------------------------- 1 | export { default as Threads } from './Threads'; 2 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as isObjectEmpty } from './isObjectEmpty'; 2 | -------------------------------------------------------------------------------- /src/components/Comments/index.js: -------------------------------------------------------------------------------- 1 | export { default as Comments } from './Comments'; 2 | -------------------------------------------------------------------------------- /src/components/Register/index.js: -------------------------------------------------------------------------------- 1 | export { default as Register } from './Register'; 2 | -------------------------------------------------------------------------------- /src/components/Categories/index.js: -------------------------------------------------------------------------------- 1 | export { default as Categories } from './Categories'; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berthutapea/forum-bethup/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berthutapea/forum-bethup/HEAD/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berthutapea/forum-bethup/HEAD/public/logo512.png -------------------------------------------------------------------------------- /src/components/Leaderboards/index.js: -------------------------------------------------------------------------------- 1 | export { default as Leaderboards } from './Leaderboards'; 2 | -------------------------------------------------------------------------------- /screenshot/1_ci_check_error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berthutapea/forum-bethup/HEAD/screenshot/1_ci_check_error.png -------------------------------------------------------------------------------- /screenshot/2_ci_check_pass.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berthutapea/forum-bethup/HEAD/screenshot/2_ci_check_pass.png -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /screenshot/3_branch_protection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berthutapea/forum-bethup/HEAD/screenshot/3_branch_protection.png -------------------------------------------------------------------------------- /src/components/Thread/index.js: -------------------------------------------------------------------------------- 1 | export { default as Thread } from './Thread'; 2 | export { default as ThreadAdd } from './ThreadAdd'; 3 | -------------------------------------------------------------------------------- /src/assets/images/logo/logo-forum-BetHup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/berthutapea/forum-bethup/HEAD/src/assets/images/logo/logo-forum-BetHup.png -------------------------------------------------------------------------------- /src/utils/isObjectEmpty.js: -------------------------------------------------------------------------------- 1 | export default function isObjectEmpty(object) { 2 | return object ? Object.keys(object).length === 0 : true; 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "printWidth": 80, 6 | "singleQuote": true 7 | } 8 | -------------------------------------------------------------------------------- /src/hooks/index.js: -------------------------------------------------------------------------------- 1 | export { default as useInput } from './useInput'; 2 | export { default as useToggle } from './useToggle'; 3 | export { default as useAutoFocus } from './useAutoFocus'; 4 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-optional-chaining", 5 | "@babel/plugin-proposal-nullish-coalescing-operator" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | server: { 8 | port: 3000, 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | // implement node event listeners here 7 | }, 8 | video: false, 9 | defaultCommandTimeout: 10000, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/states/isPreload/reducer.js: -------------------------------------------------------------------------------- 1 | import { ActionType } from './action'; 2 | 3 | export default function isPreloadReducer(isPreload = true, action = {}) { 4 | switch (action.type) { 5 | case ActionType.IS_PRELOAD: 6 | return action.payload.isPreload; 7 | default: 8 | return isPreload; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/hooks/useAutoFocus.js: -------------------------------------------------------------------------------- 1 | import { createRef, useEffect } from 'react'; 2 | 3 | export default function useAutoFocus(hash) { 4 | const inputRef = createRef(); 5 | 6 | useEffect(() => { 7 | if (inputRef.current && hash) { 8 | inputRef.current.focus(); 9 | } 10 | }, []); 11 | 12 | return inputRef; 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useInput.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | function useInput(defaultValue = '') { 4 | const [value, setValue] = useState(defaultValue); 5 | 6 | function handleValueChange({ target }) { 7 | setValue(target.value); 8 | } 9 | 10 | return [value, handleValueChange, setValue]; 11 | } 12 | 13 | export default useInput; 14 | -------------------------------------------------------------------------------- /src/hooks/useToggle.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | function useToggle(defaultValue = false) { 4 | const [value, setValue] = useState(defaultValue); 5 | 6 | function handleValueChange() { 7 | setValue((state) => !state); 8 | } 9 | 10 | return [value, handleValueChange, setValue]; 11 | } 12 | 13 | export default useToggle; 14 | -------------------------------------------------------------------------------- /src/stories/Categories.stories.js: -------------------------------------------------------------------------------- 1 | import { Categories } from '../components'; 2 | 3 | const meta = { 4 | title: 'Components/Categories', 5 | component: Categories, 6 | }; 7 | 8 | export default meta; 9 | 10 | export const Basic = { 11 | args: { 12 | categories: ['Category 1', 'Category 2', 'Category 3'], 13 | keyword: '', 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/states/authUser/reducer.js: -------------------------------------------------------------------------------- 1 | import { ActionType } from './action'; 2 | 3 | export default function authUserReducer(authUser = null, action = {}) { 4 | switch (action.type) { 5 | case ActionType.LOGIN: 6 | return action.payload.authUser; 7 | case ActionType.LOGOUT: 8 | return null; 9 | default: 10 | return authUser; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/app/states/leaderboards/reducer.js: -------------------------------------------------------------------------------- 1 | import { ActionType } from './action'; 2 | 3 | function leaderboardsReducer(leaderboards = [], action = {}) { 4 | switch (action.type) { 5 | case ActionType.GET_LEADERBOARDS: 6 | return action.payload.leaderboards; 7 | default: 8 | return leaderboards; 9 | } 10 | } 11 | 12 | export default leaderboardsReducer; 13 | -------------------------------------------------------------------------------- /src/stories/Owner.stories.js: -------------------------------------------------------------------------------- 1 | import { Owner } from '../components'; 2 | 3 | const meta = { 4 | title: 'Components/Owner', 5 | component: Owner, 6 | }; 7 | 8 | export default meta; 9 | 10 | export const Basic = { 11 | args: { 12 | name: 'John Doe', 13 | avatar: 'https://ui-avatars.com/api/?name=John+Doe&background=random', 14 | createdAt: '2022-06-21T07:00:00.000Z', 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | package-lock.json 10 | 11 | node_modules 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | *storybook.log -------------------------------------------------------------------------------- /src/app/states/users/reducer.js: -------------------------------------------------------------------------------- 1 | import { ActionType } from './action'; 2 | 3 | function usersReducer(users = [], action = {}) { 4 | switch (action.type) { 5 | case ActionType.REGISTER_USER: 6 | return [action.payload.user, ...users]; 7 | case ActionType.GET_ALL_USERS: 8 | return action.payload.users; 9 | default: 10 | return users; 11 | } 12 | } 13 | 14 | export default usersReducer; 15 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | /** @type { import('@storybook/react').Preview } */ 2 | import '!style-loader!css-loader!postcss-loader!tailwindcss/tailwind.css'; 3 | 4 | const preview = { 5 | parameters: { 6 | actions: { argTypesRegex: "^on[A-Z].*" }, 7 | controls: { 8 | matchers: { 9 | color: /(background|color)$/i, 10 | date: /Date$/, 11 | }, 12 | }, 13 | }, 14 | }; 15 | 16 | export default preview; 17 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export { Categories } from './Categories'; 2 | export { Comments } from './Comments'; 3 | export { Leaderboards } from './Leaderboards'; 4 | export { Login } from './Login'; 5 | export { Navbar } from './Navbar'; 6 | export { Footer } from './Footer'; 7 | export { Owner } from './Owner'; 8 | export { Register } from './Register'; 9 | export { Thread, ThreadAdd } from './Thread'; 10 | export { Threads } from './Threads'; 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | -------------------------------------------------------------------------------- /cypress/e2e/leaderboards.cy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * - Leaderboards spec 3 | * - should display leaderboards page correctly 4 | */ 5 | 6 | describe('Leaderboards spec', () => { 7 | beforeEach(() => { 8 | cy.visit('http://localhost:3000/leaderboards'); 9 | }); 10 | 11 | it('should display leaderboards page correctly', () => { 12 | // verify the elements that should appear on the leaderboards page 13 | cy.get('h1').should('contain', 'Leaderboards'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function Footer() { 4 | return ( 5 |
6 | 11 |
12 | ); 13 | } 14 | 15 | export default Footer; 16 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 8 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 16 | monospace; 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | automation-test-job: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - name: npm install and test 19 | run: | 20 | npm install 21 | npm run ci:test -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const defaultTheme = require('tailwindcss/defaultTheme'); 3 | const daisyUi = require('daisyui'); 4 | const lineClamp = require('@tailwindcss/line-clamp'); 5 | 6 | module.exports = { 7 | content: ['./src/**/*.{js,jsx,ts,tsx}'], 8 | theme: { 9 | screens: { 10 | xs: '475px', 11 | ...defaultTheme.screens, 12 | }, 13 | extend: {}, 14 | }, 15 | plugins: [daisyUi, lineClamp], 16 | daisyui: { 17 | themes: ['light'], 18 | info: '#007bff', 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import { Provider } from 'react-redux'; 4 | import { BrowserRouter } from 'react-router-dom'; 5 | import App from './App'; 6 | import { store } from './app/store'; 7 | import './index.css'; 8 | 9 | const root = ReactDOM.createRoot(document.getElementById('root')); 10 | 11 | root.render( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /src/pages/LeaderboardsPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { asyncGetLeaderboards } from '../app/states/leaderboards/action'; 4 | import { Leaderboards } from '../components'; 5 | 6 | export default function LeaderboardsPage() { 7 | const dispatch = useDispatch(); 8 | const { leaderboards } = useSelector((state) => state); 9 | 10 | useEffect(() => { 11 | dispatch(asyncGetLeaderboards()); 12 | }, []); 13 | 14 | return ( 15 |
16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Forum BetHup", 3 | "name": "Aplikasi Forum Diskusi (Forum BetHup)", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /src/pages/LoginPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import { Login } from '../components'; 5 | import { asyncLogin } from '../app/states/authUser/action'; 6 | 7 | export default function LoginPage() { 8 | const dispatch = useDispatch(); 9 | const navigate = useNavigate(); 10 | 11 | const onLogin = ({ email, password }) => { 12 | dispatch(asyncLogin({ email, password })).then(({ status }) => { 13 | if (status === 'success') navigate('/'); 14 | }); 15 | }; 16 | 17 | return ( 18 |
19 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Navbar/NavbarUser.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default function NavbarUser({ name, email, avatar }) { 5 | return ( 6 |
7 |
8 |
9 | {name} 10 |
11 |
12 |
13 | {name} 14 |

{email}

15 |
16 |
17 | 18 | ); 19 | } 20 | 21 | NavbarUser.propTypes = { 22 | name: PropTypes.string.isRequired, 23 | email: PropTypes.string.isRequired, 24 | avatar: PropTypes.string.isRequired, 25 | }; 26 | -------------------------------------------------------------------------------- /src/stories/Navbar.stories.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/react-in-jsx-scope */ 2 | import { MemoryRouter } from 'react-router-dom'; 3 | import { Navbar } from '../components'; 4 | 5 | const meta = { 6 | title: 'Components/Navbar', 7 | component: Navbar, 8 | decorators: [ 9 | (Story) => ( 10 | 11 | 12 | 13 | ), 14 | ], 15 | }; 16 | 17 | export default meta; 18 | 19 | export const WithoutAuthUser = {}; 20 | 21 | export const WithAuthUser = { 22 | args: { 23 | authUser: { 24 | id: 'john_doe', 25 | name: 'John Doe', 26 | email: 'john@example.com', 27 | avatar: 'https://ui-avatars.com/api/?name=John+Doe&background=random', 28 | }, 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /src/utils/postedAt.js: -------------------------------------------------------------------------------- 1 | function postedAt(date) { 2 | const now = new Date(); 3 | const posted = new Date(date); 4 | const diff = now - posted; 5 | const diffDays = Math.floor(diff / (1000 * 60 * 60 * 24)); 6 | const diffHours = Math.floor(diff / (1000 * 60 * 60)); 7 | const diffMinutes = Math.floor(diff / (1000 * 60)); 8 | const diffSeconds = Math.floor(diff / 1000); 9 | 10 | if (diffDays > 0) { 11 | return `${diffDays} days ago`; 12 | } if (diffHours > 0) { 13 | return `${diffHours} hours ago`; 14 | } if (diffMinutes > 0) { 15 | return `${diffMinutes} minutes ago`; 16 | } if (diffSeconds > 0) { 17 | return `${diffSeconds} seconds ago`; 18 | } 19 | return 'just now'; 20 | } 21 | 22 | export default postedAt; 23 | -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /src/pages/RegisterPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { useNavigate } from 'react-router-dom'; 4 | import { asyncRegisterUser } from '../app/states/users/action'; 5 | import { Register } from '../components'; 6 | 7 | export default function RegisterPage() { 8 | const dispatch = useDispatch(); 9 | const navigate = useNavigate(); 10 | 11 | const onRegister = async ({ name, email, password }) => { 12 | dispatch(asyncRegisterUser({ name, email, password })).then( 13 | ({ status }) => { 14 | if (status === 'success') navigate('/login'); 15 | }, 16 | ); 17 | }; 18 | 19 | return ( 20 |
21 | 22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/components/Owner/Owner.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import postedAt from '../../utils/postedAt'; 4 | 5 | export default function Owner({ name, avatar, createdAt }) { 6 | return ( 7 |
8 |
9 |
10 | {name} 11 |
12 |
13 |
14 | {name} 15 |

{postedAt(createdAt)}

16 |
17 |
18 | ); 19 | } 20 | 21 | Owner.propTypes = { 22 | avatar: PropTypes.string.isRequired, 23 | name: PropTypes.string.isRequired, 24 | createdAt: PropTypes.string.isRequired, 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/renderWithProviders.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { render } from '@testing-library/react'; 4 | import { Provider } from 'react-redux'; 5 | import { setupStore } from '../app/store'; 6 | 7 | export default function renderWithProviders( 8 | ui, 9 | { 10 | preloadedState = {}, 11 | // Automatically create a store instance if no store was passed in 12 | store = setupStore(preloadedState), 13 | ...renderOptions 14 | } = {}, 15 | ) { 16 | Wrapper.propTypes = { 17 | children: PropTypes.node.isRequired, 18 | }; 19 | 20 | function Wrapper({ children }) { 21 | return {children}; 22 | } 23 | return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }; 24 | } 25 | -------------------------------------------------------------------------------- /src/app/states/shared/action.js: -------------------------------------------------------------------------------- 1 | import { hideLoading, showLoading } from 'react-redux-loading-bar'; 2 | import api from '../../../utils/api'; 3 | import { getAllThreadsActionCreator } from '../threads/action'; 4 | import { getAllUsersActionCreator } from '../users/action'; 5 | 6 | function asyncPopulateUsersAndThreads() { 7 | return async (dispatch) => { 8 | dispatch(showLoading()); 9 | try { 10 | const users = await api.getAllUsers(); 11 | const threads = await api.getAllThreads(); 12 | dispatch(getAllThreadsActionCreator(threads)); 13 | dispatch(getAllUsersActionCreator(users)); 14 | } catch (error) { 15 | alert(error.message); 16 | } finally { 17 | dispatch(hideLoading()); 18 | } 19 | }; 20 | } 21 | 22 | export { asyncPopulateUsersAndThreads }; 23 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | /** @type { import('@storybook/react-webpack5').StorybookConfig } */ 2 | const config = { 3 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 4 | addons: [ 5 | '@storybook/addon-links', 6 | '@storybook/addon-essentials', 7 | '@storybook/preset-create-react-app', 8 | '@storybook/addon-interactions', 9 | { 10 | name: '@storybook/addon-styling', 11 | options: { 12 | // Check out https://github.com/storybookjs/addon-styling/blob/main/docs/api.md 13 | // For more details on this addon's options. 14 | postCss: true, 15 | }, 16 | }, 17 | ], 18 | framework: { 19 | name: '@storybook/react-webpack5', 20 | options: {}, 21 | }, 22 | docs: { 23 | autodocs: 'tag', 24 | }, 25 | staticDirs: ['../public'], 26 | }; 27 | export default config; 28 | -------------------------------------------------------------------------------- /src/stories/Leaderboards.stories.js: -------------------------------------------------------------------------------- 1 | import { Leaderboards } from '../components'; 2 | 3 | const meta = { 4 | title: 'Components/Leaderboards', 5 | component: Leaderboards, 6 | }; 7 | 8 | export default meta; 9 | 10 | export const Basic = { 11 | args: { 12 | leaderboards: [ 13 | { 14 | user: { 15 | id: 'users-1', 16 | name: 'John Doe', 17 | email: 'john@example.com', 18 | avatar: 'https://ui-avatars.com/api/?name=John+Doe&background=random', 19 | }, 20 | score: 10, 21 | }, 22 | { 23 | user: { 24 | id: 'users-2', 25 | name: 'Jane Doe', 26 | email: 'jane@example.com', 27 | avatar: 'https://ui-avatars.com/api/?name=Jone+Doe&background=random', 28 | }, 29 | score: 5, 30 | }, 31 | ], 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | Forum BetHup 18 | 19 | 20 |
21 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/app/states/leaderboards/action.js: -------------------------------------------------------------------------------- 1 | import { hideLoading, showLoading } from 'react-redux-loading-bar'; 2 | import api from '../../../utils/api'; 3 | 4 | const ActionType = { 5 | GET_LEADERBOARDS: 'GET_LEADERBOARDS', 6 | }; 7 | 8 | function getLeaderboardsActionCreator(leaderboards) { 9 | return { 10 | type: ActionType.GET_LEADERBOARDS, 11 | payload: { 12 | leaderboards, 13 | }, 14 | }; 15 | } 16 | 17 | function asyncGetLeaderboards() { 18 | return async (dispatch) => { 19 | dispatch(showLoading()); 20 | try { 21 | const leaderboards = await api.getLeaderboards(); 22 | dispatch(getLeaderboardsActionCreator(leaderboards)); 23 | return { status: 'success' }; 24 | } catch (error) { 25 | alert(error.message); 26 | return { status: 'error' }; 27 | } finally { 28 | dispatch(hideLoading()); 29 | } 30 | }; 31 | } 32 | 33 | export { 34 | ActionType, 35 | getLeaderboardsActionCreator, 36 | asyncGetLeaderboards, 37 | }; 38 | -------------------------------------------------------------------------------- /src/app/store.js: -------------------------------------------------------------------------------- 1 | import { combineReducers, configureStore } from '@reduxjs/toolkit'; 2 | import { loadingBarReducer } from 'react-redux-loading-bar'; 3 | import authUserReducer from './states/authUser/reducer'; 4 | import isPreloadReducer from './states/isPreload/reducer'; 5 | import threadsReducer from './states/threads/reducer'; 6 | import usersReducer from './states/users/reducer'; 7 | import detailThreadReducer from './states/detailThread/reducer'; 8 | import leaderboardsReducer from './states/leaderboards/reducer'; 9 | 10 | const rootReducer = combineReducers({ 11 | isPreload: isPreloadReducer, 12 | loadingBar: loadingBarReducer, 13 | authUser: authUserReducer, 14 | users: usersReducer, 15 | threads: threadsReducer, 16 | detailThread: detailThreadReducer, 17 | leaderboards: leaderboardsReducer, 18 | }); 19 | 20 | export const setupStore = (preloadedState) => configureStore({ 21 | reducer: rootReducer, 22 | preloadedState, 23 | }); 24 | 25 | export const store = setupStore(); 26 | -------------------------------------------------------------------------------- /src/components/Categories/Categories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default function Categories({ categories, keyword, onKeyword }) { 5 | return ( 6 | <> 7 |

Categories

8 |
9 | {categories.map((category) => ( 10 | 21 | ))} 22 |
23 | 24 | ); 25 | } 26 | 27 | Categories.propTypes = { 28 | categories: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, 29 | keyword: PropTypes.string.isRequired, 30 | onKeyword: PropTypes.func.isRequired, 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/Login/LoginForm.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { useInput } from '../../hooks'; 4 | 5 | export default function LoginForm({ onLogin }) { 6 | const [email, setEmail] = useInput(''); 7 | const [password, setPassword] = useInput(''); 8 | 9 | return ( 10 | <> 11 | 18 | 25 | 32 | 33 | ); 34 | } 35 | 36 | LoginForm.propTypes = { 37 | onLogin: PropTypes.func.isRequired, 38 | }; 39 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | jest: true, 6 | 'cypress/globals': true, 7 | }, 8 | extends: [ 9 | 'plugin:react/recommended', 10 | 'standard', 11 | 'prettier', 12 | 'plugin:storybook/recommended', 13 | ], 14 | overrides: [], 15 | parserOptions: { 16 | ecmaVersion: 'latest', 17 | sourceType: 'module', 18 | }, 19 | plugins: ['react', 'cypress', 'react-hooks'], 20 | rules: { 21 | 'react/react-in-jsx-scope': 'off', 22 | 'no-unused-vars': 'off', 23 | 'import/no-extraneous-dependencies': 'off', 24 | 'linebreak-style': 'off', 25 | 'no-alert': 'off', 26 | 'no-underscore-dangle': 'off', 27 | 'import/prefer-default-export': 'off', 28 | 'react/jsx-filename-extension': 'off', 29 | 'react-hooks/rules-of-hooks': 'error', 30 | 'react-hooks/exhaustive-deps': 'off', 31 | 'react/jsx-props-no-spreading': 'off', 32 | // 'import/no-extraneous-dependencies': 'off', 33 | }, 34 | settings: { 35 | react: { 36 | version: 'detect', 37 | }, 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /src/app/states/isPreload/action.js: -------------------------------------------------------------------------------- 1 | import { hideLoading, showLoading } from 'react-redux-loading-bar'; 2 | import api from '../../../utils/api'; 3 | import { loginActionCreator } from '../authUser/action'; 4 | 5 | const ActionType = { 6 | IS_PRELOAD: 'IS_PRELOAD', 7 | }; 8 | 9 | function isPreloadActionCreator(isPreload) { 10 | return { 11 | type: ActionType.IS_PRELOAD, 12 | payload: { 13 | isPreload, 14 | }, 15 | }; 16 | } 17 | 18 | function asyncIsPreload() { 19 | return async (dispatch) => { 20 | const token = api.getAccessToken(); 21 | dispatch(showLoading()); 22 | try { 23 | if (token) { 24 | const user = await api.getOwnProfile(); 25 | dispatch(loginActionCreator(user)); 26 | } else { 27 | throw new Error(); 28 | } 29 | } catch (error) { 30 | dispatch(loginActionCreator(null)); 31 | } finally { 32 | dispatch(isPreloadActionCreator(false)); 33 | dispatch(hideLoading()); 34 | } 35 | }; 36 | } 37 | 38 | export { 39 | ActionType, 40 | isPreloadActionCreator, 41 | asyncIsPreload, 42 | }; 43 | -------------------------------------------------------------------------------- /src/app/states/isPreload/reducer.test.js: -------------------------------------------------------------------------------- 1 | import isPreloadReducer from './reducer'; 2 | 3 | /** 4 | * test scenario for isPreloadReducer 5 | * 6 | * - isPreloadReducers function 7 | * - should return the initial state when given by unknown action 8 | * - should return the isPreload when given by IS_PRELOAD action 9 | * 10 | */ 11 | 12 | describe('isPreloadReducer function', () => { 13 | it('should return the initial state when given by unknown action', () => { 14 | // arrange 15 | const initialState = true; 16 | const action = { type: 'UNKNOWN' }; 17 | 18 | // action 19 | const nextState = isPreloadReducer(initialState, action); 20 | 21 | // assert 22 | expect(nextState).toEqual(initialState); 23 | }); 24 | 25 | it('should return the isPreload when given by IS_PRELOAD action', () => { 26 | // arrange 27 | const initialState = []; 28 | const action = { 29 | type: 'IS_PRELOAD', 30 | payload: { 31 | isPreload: false, 32 | }, 33 | }; 34 | 35 | // action 36 | const nextState = isPreloadReducer(initialState, action); 37 | 38 | // assert 39 | expect(nextState).toEqual(action.payload.isPreload); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/app/states/users/action.js: -------------------------------------------------------------------------------- 1 | import { hideLoading, showLoading } from 'react-redux-loading-bar'; 2 | import api from '../../../utils/api'; 3 | 4 | const ActionType = { 5 | REGISTER_USER: 'REGISTER_USER', 6 | GET_ALL_USERS: 'GET_ALL_USERS', 7 | }; 8 | 9 | function registerUserActionCreator(user) { 10 | return { 11 | type: ActionType.REGISTER_USER, 12 | payload: { 13 | user, 14 | }, 15 | }; 16 | } 17 | 18 | function getAllUsersActionCreator(users) { 19 | return { 20 | type: ActionType.GET_ALL_USERS, 21 | payload: { 22 | users, 23 | }, 24 | }; 25 | } 26 | 27 | function asyncRegisterUser({ name, email, password }) { 28 | return async (dispatch) => { 29 | dispatch(showLoading()); 30 | try { 31 | const user = await api.registerUser({ name, email, password }); 32 | dispatch(registerUserActionCreator(user)); 33 | return { status: 'success' }; 34 | } catch (error) { 35 | alert(error.message); 36 | return { status: 'error' }; 37 | } finally { 38 | dispatch(hideLoading()); 39 | } 40 | }; 41 | } 42 | 43 | export { 44 | ActionType, 45 | registerUserActionCreator, 46 | getAllUsersActionCreator, 47 | asyncRegisterUser, 48 | }; 49 | -------------------------------------------------------------------------------- /src/components/Login/Login.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | import LoginForm from './LoginForm'; 5 | import logoForumBetHup from '../../assets/images/logo/logo-forum-BetHup.png'; 6 | 7 | const Login = ({ onLogin }) => { 8 | return ( 9 |
10 |
11 | Logo Forum BetHup 17 |
18 |

Forum BetHup

19 |
20 |
21 | 22 |

23 | Dont have an account yet?{' '} 24 | 25 | Register here 26 | 27 |

28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | Login.propTypes = { 35 | onLogin: PropTypes.func.isRequired, 36 | }; 37 | 38 | export default Login; 39 | -------------------------------------------------------------------------------- /src/components/Register/Register.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link } from 'react-router-dom'; 4 | import RegisterForm from './RegisterForm'; 5 | import logoForumBetHup from '../../assets/images/logo/logo-forum-BetHup.png'; 6 | 7 | export default function Register({ onRegister }) { 8 | return ( 9 |
10 |
11 | Logo Forum BetHup 17 |
18 |

Forum BetHup

19 |
20 |
21 | 22 |

23 | Already have an account?{' '} 24 | 25 | Login here 26 | 27 |

28 |
29 |
30 |
31 | ); 32 | } 33 | 34 | Register.propTypes = { 35 | onRegister: PropTypes.func.isRequired, 36 | }; 37 | -------------------------------------------------------------------------------- /src/components/Register/RegisterForm.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { useInput } from '../../hooks'; 4 | 5 | export default function RegisterForm({ onRegister }) { 6 | const [name, setName] = useInput(''); 7 | const [email, setEmail] = useInput(''); 8 | const [password, setPassword] = useInput(''); 9 | 10 | return ( 11 | <> 12 | 19 | 26 | 33 | 40 | 41 | ); 42 | } 43 | 44 | RegisterForm.propTypes = { 45 | onRegister: PropTypes.func.isRequired, 46 | }; 47 | -------------------------------------------------------------------------------- /src/pages/ThreadsPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { asyncPopulateUsersAndThreads } from '../app/states/shared/action'; 4 | import { Categories, ThreadAdd, Threads } from '../components'; 5 | 6 | export default function ThreadsPage() { 7 | const dispatch = useDispatch(); 8 | const authUser = useSelector((state) => state.authUser); 9 | const threads = useSelector((state) => state.threads); 10 | const [keyword, setKeyword] = useState(''); 11 | 12 | const onKeyword = (category) => 13 | setKeyword((state) => (state === category ? '' : category)); 14 | 15 | useEffect(() => { 16 | dispatch(asyncPopulateUsersAndThreads()); 17 | }, []); 18 | 19 | const threadsList = threads.filter((thread) => 20 | thread.category.includes(keyword) 21 | ); 22 | const categories = threads 23 | .map((item) => item.category) 24 | .filter( 25 | (category, index, currentCategory) => 26 | currentCategory.indexOf(category) === index 27 | ); 28 | 29 | return ( 30 |
31 | 36 | 37 | {authUser && } 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/pages/ThreadPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import { useDispatch, useSelector } from 'react-redux'; 4 | import { asyncGetDetailThread, resetDetailThreadActionCreator } from '../app/states/detailThread/action'; 5 | import { Comments, Thread, ThreadAdd } from '../components'; 6 | 7 | export default function ThreadPage() { 8 | const { id } = useParams(); 9 | 10 | const { authUser, detailThread } = useSelector((state) => state); 11 | const dispatch = useDispatch(); 12 | 13 | useEffect(() => { 14 | if (id) dispatch(asyncGetDetailThread(id)); 15 | return () => { 16 | dispatch(resetDetailThreadActionCreator()); 17 | }; 18 | }, []); 19 | 20 | if (!detailThread || !id) return null; 21 | 22 | return ( 23 |
24 | 36 | 37 | {authUser && } 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Comments/Comments.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { useDispatch } from 'react-redux'; 4 | import CommentsForm from './CommentsForm'; 5 | import Comment from './Comment'; 6 | import { asyncCreateComment } from '../../app/states/detailThread/action'; 7 | 8 | export default function Comments({ threadId, comments }) { 9 | const dispatch = useDispatch(); 10 | 11 | const onCreateComment = async ({ content }) => { 12 | await dispatch(asyncCreateComment({ threadId, content })); 13 | }; 14 | 15 | return ( 16 |
17 |
18 | 19 | {comments.map((comment) => ( 20 | 21 | ))} 22 |
23 |
24 | ); 25 | } 26 | 27 | const commentsShape = { 28 | id: PropTypes.string.isRequired, 29 | content: PropTypes.string.isRequired, 30 | createdAt: PropTypes.string.isRequired, 31 | owner: PropTypes.objectOf(PropTypes.string).isRequired, 32 | upVotesBy: PropTypes.arrayOf(PropTypes.string).isRequired, 33 | downVotesBy: PropTypes.arrayOf(PropTypes.string).isRequired, 34 | }; 35 | 36 | Comments.propTypes = { 37 | threadId: PropTypes.string.isRequired, 38 | comments: PropTypes.arrayOf(PropTypes.shape(commentsShape)).isRequired, 39 | }; 40 | -------------------------------------------------------------------------------- /src/app/states/leaderboards/reducer.test.js: -------------------------------------------------------------------------------- 1 | import leaderboardsReducer from './reducer'; 2 | 3 | /** 4 | * test scenario for leaderboardsReducer 5 | * 6 | * - leaderboardsReducers function 7 | * - should return the initial state when given by unknown action 8 | * - should return the users when given by GET_LEADERBOARDS action 9 | * 10 | */ 11 | 12 | describe('leaderboardsReducer function', () => { 13 | it('should return the initial state when given by unknown action', () => { 14 | // arrange 15 | const initialState = []; 16 | const action = { type: 'UNKNOWN' }; 17 | 18 | // action 19 | const nextState = leaderboardsReducer(initialState, action); 20 | 21 | // assert 22 | expect(nextState).toEqual(initialState); 23 | }); 24 | 25 | it('should return the leaderboards when given by GET_LEADERBOARDS action', () => { 26 | // arrange 27 | const initialState = []; 28 | const action = { 29 | type: 'GET_LEADERBOARDS', 30 | payload: { 31 | leaderboards: [ 32 | { 33 | user: { 34 | id: 'users-1', 35 | name: 'John Doe', 36 | email: 'john@example.com', 37 | avatar: 'https://generated-image-url.jpg', 38 | }, 39 | score: 10, 40 | }, 41 | ], 42 | }, 43 | }; 44 | 45 | // action 46 | const nextState = leaderboardsReducer(initialState, action); 47 | 48 | // assert 49 | expect(nextState).toEqual(action.payload.leaderboards); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | Cypress.Commands.add('login', (email, password) => { 15 | // opens the login page 16 | cy.visit('http://localhost:3000/login'); 17 | 18 | // fill in the email 19 | cy.get('input[placeholder=Email]').type(email); 20 | // fill in the password 21 | cy.get('input[placeholder=Password]').type(password); 22 | 23 | // pressing the Login button 24 | cy.get('button') 25 | .contains(/^Login$/) 26 | .click(); 27 | 28 | // verify that elements on the homepage are displayed when the user logs in 29 | cy.get('label[for="create-thread-modal"]').should('be.visible'); 30 | }); 31 | // 32 | // 33 | // -- This is a child command -- 34 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 35 | // 36 | // 37 | // -- This is a dual command -- 38 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 39 | // 40 | // 41 | // -- This will overwrite an existing command -- 42 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 43 | -------------------------------------------------------------------------------- /src/app/states/authUser/action.js: -------------------------------------------------------------------------------- 1 | import { hideLoading, showLoading } from 'react-redux-loading-bar'; 2 | import api from '../../../utils/api'; 3 | 4 | const ActionType = { 5 | LOGIN: 'authUser/login', 6 | LOGOUT: 'authUser/logout', 7 | }; 8 | 9 | function loginActionCreator(authUser) { 10 | return { 11 | type: ActionType.LOGIN, 12 | payload: { 13 | authUser, 14 | }, 15 | }; 16 | } 17 | 18 | function logoutActionCreator() { 19 | return { 20 | type: ActionType.LOGOUT, 21 | }; 22 | } 23 | 24 | function asyncLogin({ email, password }) { 25 | return async (dispatch) => { 26 | dispatch(showLoading()); 27 | try { 28 | const token = await api.login({ email, password }); 29 | api.putAccessToken(token); 30 | const user = await api.getOwnProfile(); 31 | dispatch(loginActionCreator(user)); 32 | return { status: 'success' }; 33 | } catch (error) { 34 | alert(error.message); 35 | return { status: 'error' }; 36 | } finally { 37 | dispatch(hideLoading()); 38 | } 39 | }; 40 | } 41 | 42 | function asyncLogout() { 43 | return async (dispatch) => { 44 | dispatch(showLoading()); 45 | try { 46 | dispatch(logoutActionCreator()); 47 | api.putAccessToken(''); 48 | return { status: 'success' }; 49 | } catch (error) { 50 | alert(error.message); 51 | return { status: 'error' }; 52 | } finally { 53 | dispatch(hideLoading()); 54 | } 55 | }; 56 | } 57 | 58 | export { 59 | ActionType, 60 | loginActionCreator, 61 | logoutActionCreator, 62 | asyncLogin, 63 | asyncLogout, 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/Thread/ThreadForm.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { useInput } from '../../hooks'; 4 | 5 | export default function ThreadForm({ onCreateThread }) { 6 | const [title, setTitle, changeTitle] = useInput(''); 7 | const [category, setCategory, changeCategory] = useInput(''); 8 | const [body, setBody, changeBody] = useInput(''); 9 | 10 | const handleOnCreateThread = async () => { 11 | await onCreateThread({ title, body, category }); 12 | changeTitle(''); 13 | changeCategory(''); 14 | changeBody(''); 15 | }; 16 | 17 | return ( 18 | <> 19 |
20 | 27 | 34 |