├── 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 |
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 |
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 |
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 |

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 |
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 |

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 |

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 |

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 |
40 |
41 |
42 |
45 |
46 | >
47 | );
48 | }
49 |
50 | ThreadForm.propTypes = {
51 | onCreateThread: PropTypes.func.isRequired,
52 | };
53 |
--------------------------------------------------------------------------------
/src/components/Threads/Threads.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { useSelector } from 'react-redux';
4 | import { Thread } from '../Thread';
5 |
6 | export default function Threads({ threads }) {
7 | const { users } = useSelector((state) => state);
8 |
9 | return (
10 |
11 | Threads
12 | {threads ? (
13 |
14 | {threads.map((thread) => (
15 | user.id === thread.ownerId)}
23 | totalComments={thread.totalComments}
24 | upVotesBy={thread.upVotesBy}
25 | downVotesBy={thread.downVotesBy}
26 | type="threads"
27 | />
28 | ))}
29 |
30 | ) : (
31 | Thread not found
32 | )}
33 |
34 | );
35 | }
36 |
37 | const threadsShape = {
38 | id: PropTypes.string.isRequired,
39 | title: PropTypes.string.isRequired,
40 | body: PropTypes.string.isRequired,
41 | category: PropTypes.string.isRequired,
42 | createdAt: PropTypes.string.isRequired,
43 | ownerId: PropTypes.string.isRequired,
44 | totalComments: PropTypes.number.isRequired,
45 | upVotesBy: PropTypes.arrayOf(PropTypes.string).isRequired,
46 | downVotesBy: PropTypes.arrayOf(PropTypes.string).isRequired,
47 | };
48 |
49 | Threads.propTypes = {
50 | threads: PropTypes.arrayOf(PropTypes.shape(threadsShape)).isRequired,
51 | };
52 |
--------------------------------------------------------------------------------
/src/components/Thread/ThreadAdd.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { XMarkIcon, PlusIcon } from '@heroicons/react/24/outline';
3 | import { useDispatch } from 'react-redux';
4 | import { useNavigate } from 'react-router-dom';
5 | import { useToggle } from '../../hooks';
6 | import { asyncCreateThread } from '../../app/states/threads/action';
7 | import ThreadForm from './ThreadForm';
8 |
9 | export default function ThreadAdd() {
10 | const dispatch = useDispatch();
11 | const navigate = useNavigate();
12 | const [checked, setChecked, changeChecked] = useToggle(false);
13 |
14 | const onCreateThread = async ({ title, body, category }) => {
15 | await dispatch(asyncCreateThread({ title, body, category })).then(
16 | ({ status }) => {
17 | if (status === 'success') {
18 | changeChecked(false);
19 | navigate('/');
20 | }
21 | },
22 | );
23 | };
24 |
25 | return (
26 | <>
27 |
33 |
40 |
41 |
42 |
49 |
Create Thread
50 |
51 |
52 |
53 | >
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/src/components/Thread/ThreadForm.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, screen, waitFor } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 | import userEvent from '@testing-library/user-event';
5 | import ThreadForm from './ThreadForm';
6 |
7 | describe('ThreadForm component', () => {
8 | it('should call onCreateThread function when Add Thread button is clicked', async () => {
9 | const mockOnCreateThread = jest.fn();
10 |
11 | render();
12 |
13 | // Menunggu elemen-elemen form terrender sepenuhnya
14 | const titleInput = await screen.findByPlaceholderText('Title');
15 | expect(titleInput).toBeInTheDocument();
16 |
17 | // Memasukkan nilai ke input title
18 | await userEvent.type(titleInput, 'test title');
19 |
20 | // Memastikan elemen input dengan placeholder 'Category' ada
21 | const categoryInput = await screen.findByPlaceholderText('Category');
22 | expect(categoryInput).toBeInTheDocument();
23 |
24 | // Memasukkan nilai ke input category
25 | await userEvent.type(categoryInput, 'test category');
26 |
27 | // Memastikan elemen textarea dengan placeholder 'Body' ada
28 | const bodyInput = await screen.findByPlaceholderText('Body');
29 | expect(bodyInput).toBeInTheDocument();
30 |
31 | // Memasukkan nilai ke textarea body
32 | await userEvent.type(bodyInput, 'test body');
33 |
34 | // Menemukan dan mengklik tombol 'Add Thread'
35 | const addThreadButton = await screen.findByRole('button', {
36 | name: 'Add Thread',
37 | });
38 | await userEvent.click(addThreadButton);
39 |
40 | // Memastikan bahwa fungsi onCreateThread dipanggil dengan argumen yang sesuai
41 | await waitFor(() => {
42 | expect(mockOnCreateThread).toHaveBeenCalledWith({
43 | title: 'test title',
44 | category: 'test category',
45 | body: 'test body',
46 | });
47 | });
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/src/app/states/authUser/reducer.test.js:
--------------------------------------------------------------------------------
1 | import authUserReducer from './reducer';
2 |
3 | /**
4 | * test scenario for authUserReducer
5 | *
6 | * - authUserReducers function
7 | * - should return the initial state when given by unknown action
8 | * - should return the authUser when given by LOGIN action
9 | * - should return the authUser with the remove authUser when given by LOGOUT action
10 | *
11 | */
12 |
13 | describe('authUserReducer function', () => {
14 | it('should return the initial state when given by unknown action', () => {
15 | // arrange
16 | const initialState = null;
17 | const action = { type: 'UNKNOWN' };
18 |
19 | // action
20 | const nextState = authUserReducer(initialState, action);
21 |
22 | // assert
23 | expect(nextState).toEqual(initialState);
24 | });
25 |
26 | it('should return the authUser when given by LOGIN action', () => {
27 | // arrange
28 | const initialState = null;
29 | const action = {
30 | type: 'authUser/login',
31 | payload: {
32 | authUser: {
33 | id: 'john_doe',
34 | name: 'John Doe',
35 | email: 'john@example.com',
36 | avatar: 'https://generated-image-url.jpg',
37 | },
38 | },
39 | };
40 |
41 | // action
42 | const nextState = authUserReducer(initialState, action);
43 |
44 | // assert
45 | expect(nextState).toEqual(action.payload.authUser);
46 | });
47 |
48 | it('should return the authUser with the remove authUser when given by LOGOUT action', () => {
49 | // arrange
50 | const initialState = {
51 | id: 'john_doe',
52 | name: 'John Doe',
53 | email: 'john@example.com',
54 | avatar: 'https://generated-image-url.jpg',
55 | };
56 | const action = {
57 | type: 'authUser/logout',
58 | };
59 |
60 | // action
61 | const nextState = authUserReducer(initialState, action);
62 |
63 | // assert
64 | expect(nextState).toBeNull();
65 | });
66 | });
67 |
--------------------------------------------------------------------------------
/src/app/states/threads/reducer.js:
--------------------------------------------------------------------------------
1 | import { ActionType } from './action';
2 |
3 | function threadsReducer(threads = [], action = {}) {
4 | switch (action.type) {
5 | case ActionType.CREATE_THREAD:
6 | return [action.payload.thread, ...threads];
7 | case ActionType.GET_ALL_THREADS:
8 | return action.payload.threads;
9 | case ActionType.UP_VOTE_THREAD:
10 | return threads.map((thread) => {
11 | if (thread.id === action.payload.threadId) {
12 | return {
13 | ...thread,
14 | upVotesBy: thread.upVotesBy.includes(action.payload.userId)
15 | ? thread.upVotesBy.filter((id) => id !== action.payload.userId)
16 | : thread.upVotesBy.concat([action.payload.userId]),
17 | downVotesBy: thread.downVotesBy.filter((id) => id !== action.payload.userId),
18 | };
19 | }
20 | return thread;
21 | });
22 | case ActionType.DOWN_VOTE_THREAD:
23 | return threads.map((thread) => {
24 | if (thread.id === action.payload.threadId) {
25 | return {
26 | ...thread,
27 | upVotesBy: thread.upVotesBy.filter((id) => id !== action.payload.userId),
28 | downVotesBy: thread.downVotesBy.includes(action.payload.userId)
29 | ? thread.downVotesBy.filter((id) => id !== action.payload.userId)
30 | : thread.downVotesBy.concat([action.payload.userId]),
31 | };
32 | }
33 | return thread;
34 | });
35 | case ActionType.NEUTRAL_VOTE_THREAD:
36 | return threads.map((thread) => {
37 | if (thread.id === action.payload.threadId) {
38 | return {
39 | ...thread,
40 | upVotesBy: thread.upVotesBy.filter((id) => id !== action.payload.userId),
41 | downVotesBy: thread.downVotesBy.filter((id) => id !== action.payload.userId),
42 | };
43 | }
44 | return thread;
45 | });
46 | default:
47 | return threads;
48 | }
49 | }
50 |
51 | export default threadsReducer;
52 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { Navigate, Route, Routes, useNavigate } from 'react-router-dom';
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import LoadingBar from 'react-redux-loading-bar';
5 | import ThreadsPage from './pages/ThreadsPage';
6 | import LoginPage from './pages/LoginPage';
7 | import RegisterPage from './pages/RegisterPage';
8 | import { asyncIsPreload } from './app/states/isPreload/action';
9 | import ThreadPage from './pages/ThreadPage';
10 | import LeaderboardsPage from './pages/LeaderboardsPage';
11 | import { Footer, Navbar } from './components';
12 | import { asyncLogout } from './app/states/authUser/action';
13 |
14 | function App() {
15 | const dispatch = useDispatch();
16 | const { authUser, isPreload } = useSelector((state) => state);
17 | const navigate = useNavigate();
18 |
19 | const onLogout = () => {
20 | dispatch(asyncLogout()).then(({ status }) => {
21 | if (status === 'success') navigate('/login');
22 | });
23 | };
24 |
25 | useEffect(() => {
26 | dispatch(asyncIsPreload());
27 | }, []);
28 |
29 | if (isPreload) {
30 | return null;
31 | }
32 |
33 | return (
34 | <>
35 |
36 |
37 |
38 |
39 | {/* } /> */}
40 | } />
41 | } />
42 | } />
43 | : }
46 | />
47 | : }
50 | />
51 |
52 |
53 |
54 | >
55 | );
56 | }
57 |
58 | export default App;
59 |
--------------------------------------------------------------------------------
/src/components/Leaderboards/Leaderboards.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | export default function Leaderboards({ leaderboards }) {
5 | return (
6 |
7 |
8 |
Leaderboards
9 |
10 |
11 |
12 |
13 | | User |
14 | Score |
15 |
16 |
17 |
18 | {leaderboards.map((leaderboard) => (
19 |
20 |
21 |
22 |
23 |
24 | 
28 |
29 |
30 |
31 | {leaderboard.user.name}
32 |
33 | {leaderboard.user.email}
34 |
35 |
36 |
37 | |
38 | {leaderboard.score} |
39 |
40 | ))}
41 |
42 |
43 |
44 |
45 |
46 | );
47 | }
48 |
49 | const leaderboardsShape = {
50 | user: PropTypes.objectOf(PropTypes.string).isRequired,
51 | score: PropTypes.number.isRequired,
52 | };
53 |
54 | Leaderboards.propTypes = {
55 | leaderboards: PropTypes.arrayOf(PropTypes.shape(leaderboardsShape))
56 | .isRequired,
57 | };
58 |
--------------------------------------------------------------------------------
/src/components/Comments/CommentsForm.jsx:
--------------------------------------------------------------------------------
1 | import React, { forwardRef } from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Link, useLocation } from 'react-router-dom';
4 | import { useSelector } from 'react-redux';
5 | import { useAutoFocus, useInput } from '../../hooks';
6 | import { act } from '@testing-library/react';
7 |
8 | const CommentInput = forwardRef(({ onChange, value }, ref) => (
9 |
16 | ));
17 |
18 | CommentInput.displayName = 'CommentInput';
19 |
20 | export default function CommentsForm({ onCreateComment }) {
21 | const authUser = useSelector((state) => state.authUser);
22 | const { hash } = useLocation();
23 | const commentFocus = useAutoFocus(hash);
24 | const [content, setContent, changeContent] = useInput();
25 |
26 | const handleOnCreateComment = async () => {
27 | await onCreateComment({ content });
28 | await act(async () => {
29 | changeContent('');
30 | });
31 | };
32 |
33 | return (
34 | <>
35 | COMMENT
36 | {authUser ? (
37 | <>
38 |
43 |
44 |
51 |
52 | >
53 | ) : (
54 |
55 |
56 | Login
57 | {' '}
58 | to post a comment
59 |
60 | )}
61 | >
62 | );
63 | }
64 |
65 | CommentInput.propTypes = {
66 | onChange: PropTypes.func.isRequired,
67 | value: PropTypes.string.isRequired,
68 | };
69 |
70 | CommentsForm.propTypes = {
71 | onCreateComment: PropTypes.func.isRequired,
72 | };
73 |
--------------------------------------------------------------------------------
/src/app/states/leaderboards/action.test.js:
--------------------------------------------------------------------------------
1 | import { hideLoading, showLoading } from 'react-redux-loading-bar';
2 | import api from '../../../utils/api';
3 | import { asyncGetLeaderboards, getLeaderboardsActionCreator } from './action';
4 |
5 | /**
6 | * test scenario
7 | *
8 | * - asyncGetLeaderboards thunk
9 | * - should dispatch action correctly when data fetching success
10 | * - should dispatch action and call alert correctly when data fetching failed
11 | */
12 |
13 | const fakeLeaderBoardsResponse = [
14 | {
15 | user: {
16 | id: 'users-1',
17 | name: 'John Doe',
18 | email: 'john@example.com',
19 | avatar: 'https://generated-image-url.jpg',
20 | },
21 | score: 10,
22 | },
23 | ];
24 |
25 | const fakeErrorResponse = new Error('Ups, something went wrong');
26 |
27 | describe('asyncGetLeaderboards thunk', () => {
28 | beforeEach(() => {
29 | api._getLeaderboards = api.getLeaderboards;
30 | });
31 |
32 | afterEach(() => {
33 | api.getLeaderboards = api._getLeaderboards;
34 | delete api._getLeaderboards;
35 | });
36 |
37 | it('should dispatch action correctly when data fetching success', async () => {
38 | // arrange
39 | api.getLeaderboards = () => Promise.resolve(fakeLeaderBoardsResponse);
40 | const dispatch = jest.fn();
41 |
42 | // action
43 | await asyncGetLeaderboards()(dispatch);
44 |
45 | // assert
46 | expect(dispatch).toHaveBeenCalledWith(showLoading());
47 | expect(dispatch).toHaveBeenCalledWith(
48 | getLeaderboardsActionCreator(fakeLeaderBoardsResponse)
49 | );
50 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
51 | });
52 |
53 | it('should dispatch action and call alert correctly when data fetching failed', async () => {
54 | // arrange
55 | api.getLeaderboards = () => Promise.reject(fakeErrorResponse);
56 | const dispatch = jest.fn();
57 | window.alert = jest.fn();
58 |
59 | // action
60 | await asyncGetLeaderboards()(dispatch);
61 |
62 | // assert
63 | expect(dispatch).toHaveBeenCalledWith(showLoading());
64 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
65 | expect(window.alert).toHaveBeenCalledWith(fakeErrorResponse.message);
66 | });
67 | });
68 |
--------------------------------------------------------------------------------
/src/components/Login/LoginForm.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { act, render, screen } from '@testing-library/react';
3 | import userEvent from '@testing-library/user-event';
4 | import LoginForm from './LoginForm';
5 | import '@testing-library/jest-dom';
6 |
7 | /**
8 | * test scenario
9 | *
10 | * - LoginForm component
11 | * - should handle email typing correctly
12 | * - should handle password typing correctly
13 | * - should call login function when login button is clicked
14 | */
15 |
16 | describe('LoginForm component', () => {
17 | it('should handle email typing correctly', async () => {
18 | // Arrange
19 | await act(async () => render( {}} />));
20 | const emailInput = await screen.getByPlaceholderText('Email');
21 |
22 | // Action
23 | await act(async () => userEvent.type(emailInput, 'emailtest@gmail.com'));
24 |
25 | // Assert
26 | expect(emailInput).toHaveValue('emailtest@gmail.com');
27 | });
28 |
29 | it('should handle password typing correctly', async () => {
30 | // Arrange
31 | await act(async () => render( {}} />));
32 | const passwordInput = await screen.getByPlaceholderText('Password');
33 |
34 | // Action
35 | await act(async () => userEvent.type(passwordInput, 'passwordtest'));
36 |
37 | // Assert
38 | expect(passwordInput).toHaveValue('passwordtest');
39 | });
40 |
41 | it('should call login function when login button is clicked', async () => {
42 | // Arrange
43 | const mockLogin = jest.fn();
44 | await act(async () => render());
45 | const emailInput = await screen.getByPlaceholderText('Email');
46 | await act(async () => userEvent.type(emailInput, 'emailtest@gmail.com'));
47 | const passwordInput = await screen.getByPlaceholderText('Password');
48 | await act(async () => userEvent.type(passwordInput, 'passwordtest'));
49 | const loginButton = await screen.getByRole('button', { name: 'Login' });
50 |
51 | // Action
52 | await act(async () => userEvent.click(loginButton));
53 |
54 | // Assert
55 | expect(mockLogin).toBeCalledWith({
56 | email: 'emailtest@gmail.com',
57 | password: 'passwordtest',
58 | });
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/src/app/states/isPreload/action.test.js:
--------------------------------------------------------------------------------
1 | import { hideLoading, showLoading } from 'react-redux-loading-bar';
2 | import api from '../../../utils/api';
3 | import { asyncIsPreload, isPreloadActionCreator } from './action';
4 | import { loginActionCreator } from '../authUser/action';
5 |
6 | /**
7 | * test scenario
8 | *
9 | * - asyncIsPreload thunk
10 | * - should dispatch action correctly when isPreload success
11 | * - should dispatch action and call alert correctly when isPreload failed
12 | */
13 |
14 | const fakeAuthUserResponse = [
15 | {
16 | id: 'john_doe',
17 | name: 'John Doe',
18 | email: 'john@example.com',
19 | avatar: 'https://generated-image-url.jpg',
20 | },
21 | ];
22 |
23 | const fakeErrorResponse = new Error('Ups, something went wrong');
24 |
25 | describe('asyncIsPreload thunk', () => {
26 | beforeEach(() => {
27 | api._getOwnProfile = api.getOwnProfile;
28 | });
29 |
30 | afterEach(() => {
31 | api.getOwnProfile = api._getOwnProfile;
32 |
33 | delete api._getOwnProfile;
34 | });
35 |
36 | it('should dispatch action correctly when isPreload success', async () => {
37 | // arrange
38 | // stub implementation
39 | api.getOwnProfile = () => Promise.resolve(fakeAuthUserResponse);
40 | api.getAccessToken = () => 'customtoken';
41 | // mock dispatch
42 | const dispatch = jest.fn();
43 |
44 | // action
45 | await asyncIsPreload()(dispatch);
46 |
47 | // assert
48 | expect(dispatch).toHaveBeenCalledWith(showLoading());
49 | expect(dispatch).toHaveBeenCalledWith(
50 | loginActionCreator(fakeAuthUserResponse),
51 | );
52 | expect(dispatch).toHaveBeenCalledWith(isPreloadActionCreator(false));
53 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
54 | });
55 |
56 | it('should dispatch action and call alert correctly when isPreload failed', async () => {
57 | // arrange
58 | // stub implementation
59 | api.getOwnProfile = () => Promise.reject(fakeErrorResponse);
60 | // mock dispatch
61 | const dispatch = jest.fn();
62 | // mock alert
63 | window.alert = jest.fn();
64 |
65 | // action
66 | await asyncIsPreload()(dispatch);
67 |
68 | // assert
69 | expect(dispatch).toHaveBeenCalledWith(showLoading());
70 | expect(dispatch).toHaveBeenCalledWith(loginActionCreator(null));
71 | expect(dispatch).toHaveBeenCalledWith(isPreloadActionCreator(false));
72 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
73 | });
74 | });
75 |
--------------------------------------------------------------------------------
/src/app/states/threads/action.test.js:
--------------------------------------------------------------------------------
1 | import { hideLoading, showLoading } from 'react-redux-loading-bar';
2 | import api from '../../../utils/api';
3 | import { asyncCreateThread, createThreadActionCreator } from './action';
4 |
5 | /**
6 | * test scenario
7 | *
8 | * - asyncCreateThread thunk
9 | * - should dispatch action correctly when create thread success
10 | * - should dispatch action and call alert correctly when create thread failed
11 | *
12 | */
13 |
14 | const fakeCreateThreadResponse = {
15 | id: 'thread-1',
16 | title: 'Thread Pertama',
17 | body: 'Ini adalah thread pertama',
18 | category: 'General',
19 | createdAt: '2021-06-21T07:00:00.000Z',
20 | ownerId: 'users-1',
21 | upVotesBy: [],
22 | downVotesBy: [],
23 | totalComments: 0,
24 | };
25 |
26 | const fakeCreateThreadInput = {
27 | title: 'Thread Pertama',
28 | body: 'Ini adalah thread pertama',
29 | category: 'General',
30 | };
31 |
32 | const fakeErrorResponse = new Error('Ups, something went wrong');
33 |
34 | describe('asyncCreateThread thunk', () => {
35 | beforeEach(() => {
36 | api._createThread = api.createThread;
37 | });
38 |
39 | afterEach(() => {
40 | api.createThread = api._createThread;
41 |
42 | delete api._createThread;
43 | });
44 |
45 | it('should dispatch action correctly when create thread success', async () => {
46 | // arrange
47 | // stub implementation
48 | api.createThread = () => Promise.resolve(fakeCreateThreadResponse);
49 | // mock dispatch
50 | const dispatch = jest.fn();
51 |
52 | // action
53 | await asyncCreateThread(fakeCreateThreadInput)(dispatch);
54 |
55 | // assert
56 | expect(dispatch).toHaveBeenCalledWith(showLoading());
57 | expect(dispatch).toHaveBeenCalledWith(
58 | createThreadActionCreator(fakeCreateThreadResponse),
59 | );
60 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
61 | });
62 |
63 | it('should dispatch action and call alert correctly when create thread failed', async () => {
64 | // arrange
65 | // stub implementation
66 | api.createThread = () => Promise.reject(fakeErrorResponse);
67 | // mock dispatch
68 | const dispatch = jest.fn();
69 | // mock alert
70 | window.alert = jest.fn();
71 |
72 | // action
73 | await asyncCreateThread(fakeCreateThreadInput)(dispatch);
74 |
75 | // assert
76 | expect(dispatch).toHaveBeenCalledWith(showLoading());
77 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
78 | expect(window.alert).toHaveBeenCalledWith(fakeErrorResponse.message);
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/src/components/Comments/CommentsForm.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { screen, waitFor, act } from '@testing-library/react';
3 | import userEvent from '@testing-library/user-event';
4 | import { MemoryRouter } from 'react-router-dom';
5 | import CommentsForm from './CommentsForm';
6 | import '@testing-library/jest-dom';
7 | import renderWithProviders from '../../utils/renderWithProviders';
8 |
9 | /**
10 | * test scenario
11 | *
12 | * - CommentsForm component
13 | * - should handle comment typing correctly
14 | * - should call onCreateComment function when Post Comments button is clicked
15 | */
16 |
17 | const fakeAuthUser = {
18 | id: 'user-123',
19 | name: 'John Doe',
20 | email: 'john@example.com',
21 | avatar: 'https://generated-image-url.jpg',
22 | };
23 |
24 | describe('CommentsForm component', () => {
25 | it('should handle comment typing correctly', async () => {
26 | // Arrange
27 | await act(async () => {
28 | renderWithProviders(
29 |
30 | {}} />
31 | ,
32 | {
33 | preloadedState: {
34 | authUser: fakeAuthUser,
35 | },
36 | }
37 | );
38 | });
39 |
40 | const commentInput = await screen.findByPlaceholderText('Comment');
41 |
42 | // Action
43 | await act(async () => {
44 | await userEvent.type(commentInput, 'Hello World!');
45 | });
46 |
47 | // Assert
48 | expect(commentInput).toHaveValue('Hello World!');
49 | });
50 |
51 | it('should call onCreateComment function when Post Comments button is clicked', async () => {
52 | // Arrange
53 | const mockOnCreateComment = jest.fn();
54 |
55 | await act(async () => {
56 | renderWithProviders(
57 |
58 |
59 | ,
60 | {
61 | preloadedState: {
62 | authUser: fakeAuthUser,
63 | },
64 | }
65 | );
66 | });
67 |
68 | const commentInput = await screen.findByPlaceholderText('Comment');
69 |
70 | await act(async () => {
71 | await userEvent.type(commentInput, 'Hello World!');
72 | });
73 |
74 | const commentButton = await screen.findByRole('button', {
75 | name: 'Post Comment',
76 | });
77 |
78 | // Action
79 | await act(async () => {
80 | await userEvent.click(commentButton);
81 | });
82 |
83 | // Assert
84 | expect(mockOnCreateComment).toBeCalledWith({ content: 'Hello World!' });
85 | });
86 | });
87 |
--------------------------------------------------------------------------------
/src/components/Thread/ThreadAction.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import {
4 | ChatBubbleLeftRightIcon,
5 | HandThumbDownIcon as HandThumbDownIconOutline,
6 | HandThumbUpIcon as HandThumbUpIconOutline,
7 | } from '@heroicons/react/24/outline';
8 | import {
9 | HandThumbDownIcon as HandThumbDownIconFilled,
10 | HandThumbUpIcon as HandThumbUpIconFilled,
11 | } from '@heroicons/react/24/solid';
12 | import { useSelector } from 'react-redux';
13 | import { useNavigate } from 'react-router-dom';
14 |
15 | export default function ThreadAction({
16 | id,
17 | totalComments,
18 | upVotesBy,
19 | downVotesBy,
20 | onToggleVoteThread,
21 | }) {
22 | const { authUser } = useSelector((state) => state);
23 | const navigate = useNavigate();
24 |
25 | return (
26 |
27 |
28 | {upVotesBy.length}
29 |
40 |
41 |
42 | {downVotesBy.length}
43 |
54 |
55 |
56 | {totalComments}
57 |
64 |
65 |
66 | );
67 | }
68 |
69 | ThreadAction.propTypes = {
70 | id: PropTypes.string.isRequired,
71 | totalComments: PropTypes.number.isRequired,
72 | upVotesBy: PropTypes.arrayOf(PropTypes.string).isRequired,
73 | downVotesBy: PropTypes.arrayOf(PropTypes.string).isRequired,
74 | onToggleVoteThread: PropTypes.func.isRequired,
75 | };
76 |
--------------------------------------------------------------------------------
/src/app/states/authUser/action.test.js:
--------------------------------------------------------------------------------
1 | import { hideLoading, showLoading } from 'react-redux-loading-bar';
2 | import api from '../../../utils/api';
3 | import { asyncLogin, loginActionCreator } from './action';
4 |
5 | /**
6 | * test scenario
7 | *
8 | * - asyncLogin thunk
9 | * - should dispatch action correctly when login success
10 | * - should dispatch action and call alert correctly when login failed
11 | */
12 |
13 | const fakeLoginResponse = {
14 | token:
15 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImRpbWFzMiIsIm5hbWUiOiJEaW1hcyBTYXB1dHJhIiwicGhvdG8iOiJodHRwczovL3VpLWF2YXRhcnMuY29tL2FwaS8_bmFtZT1EaW1hcyBTYXB1dHJhJmJhY2tncm91bmQ9cmFuZG9tIiwiaXNfcGVybWFuZW50IjpmYWxzZSwiaWF0IjoxNjYzODQwNzY0fQ._HrzpinFYX_m9WfvM-lGCdVrnhnaGHhzt1e6eATE1Iw',
16 | };
17 |
18 | const fakeGetOwnProfileResponse = {
19 | id: 'john_doe',
20 | name: 'John Doe',
21 | email: 'john@example.com',
22 | avatar: 'https://generated-image-url.jpg',
23 | };
24 |
25 | const fakeLoginInput = {
26 | email: 'john@example.com',
27 | password: 'JohnDoe',
28 | };
29 |
30 | const fakeErrorResponse = new Error('Ups, something went wrong');
31 |
32 | describe('asyncLogin thunk', () => {
33 | beforeEach(() => {
34 | api._login = api.login;
35 | api._getOwnProfile = api.getOwnProfile;
36 | });
37 |
38 | afterEach(() => {
39 | api.login = api._login;
40 | api.getOwnProfile = api._getOwnProfile;
41 |
42 | delete api._login;
43 | delete api._getOwnProfile;
44 | });
45 |
46 | it('should dispatch action correctly when login success', async () => {
47 | // arrange
48 | // stub implementation
49 | api.login = () => Promise.resolve(fakeLoginResponse);
50 | api.getOwnProfile = () => Promise.resolve(fakeGetOwnProfileResponse);
51 | // mock dispatch
52 | const dispatch = jest.fn();
53 |
54 | // action
55 | await asyncLogin(fakeLoginInput)(dispatch);
56 |
57 | // assert
58 | expect(dispatch).toHaveBeenCalledWith(showLoading());
59 | expect(dispatch).toHaveBeenCalledWith(
60 | loginActionCreator(fakeGetOwnProfileResponse)
61 | );
62 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
63 | });
64 |
65 | it('should dispatch action and call alert correctly when login failed', async () => {
66 | // arrange
67 | // stub implementation
68 | api.login = () => Promise.reject(fakeErrorResponse);
69 | // mock dispatch
70 | const dispatch = jest.fn();
71 | // mock alert
72 | window.alert = jest.fn();
73 |
74 | // action
75 | await asyncLogin(fakeLoginInput)(dispatch);
76 |
77 | // assert
78 | expect(dispatch).toHaveBeenCalledWith(showLoading());
79 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
80 | expect(window.alert).toHaveBeenCalledWith(fakeErrorResponse.message);
81 | });
82 | });
83 |
--------------------------------------------------------------------------------
/cypress/e2e/login.cy.js:
--------------------------------------------------------------------------------
1 | /**
2 | * - Login spec
3 | * - should display login page correctly
4 | * - should display alert when email is empty
5 | * - should display alert when password is empty
6 | * - should display alert when email and password are wrong
7 | * - should display homepage when email and password are correct
8 | */
9 |
10 | describe('Login spec', () => {
11 | beforeEach(() => {
12 | cy.visit('http://localhost:3000/login');
13 | });
14 |
15 | it('should display login page correctly', () => {
16 | // verify the elements that must appear on the login page
17 | cy.get('input[placeholder="Email"]').should('be.visible');
18 | cy.get('input[placeholder="Password"]').should('be.visible');
19 | cy.get('button')
20 | .contains(/^Login$/)
21 | .should('be.visible');
22 | });
23 |
24 | it('should display alert when email is empty', () => {
25 | // click the login button without filling in your email
26 | cy.get('button')
27 | .contains(/^Login$/)
28 | .click();
29 |
30 | // verify window.alert to display messages from the API
31 | cy.on('window:alert', (str) => {
32 | expect(str).to.equal('"email" is not allowed to be empty');
33 | });
34 | });
35 |
36 | it('should display alert when password is empty', () => {
37 | // fill in the email
38 | cy.get('input[placeholder="Email"]').type('gilberthutapea@gmail.com');
39 |
40 | // Click the login button without entering a password
41 | cy.get('button')
42 | .contains(/^Login$/)
43 | .click();
44 |
45 | // verify window.alert to display messages from the API
46 | cy.on('window:alert', (str) => {
47 | expect(str).to.equal('"password" is not allowed to be empty');
48 | });
49 | });
50 |
51 | it('should display alert when email and password are wrong', () => {
52 | // fill in the email
53 | cy.get('input[placeholder="Email"]').type('gilberthutapea@gmail.com');
54 |
55 | // Enter the wrong password
56 | cy.get('input[placeholder="Password"]').type('wrong_password');
57 |
58 | // pressing the Login button
59 | cy.get('button')
60 | .contains(/^Login$/)
61 | .click();
62 |
63 | // verify window.alert to display messages from the API
64 | cy.on('window:alert', (str) => {
65 | expect(str).to.equal('email or password is wrong');
66 | });
67 | });
68 |
69 | it('should display homepage when username and password are correct', () => {
70 | // fill in the email
71 | cy.get('input[placeholder="Email"]').type('gilberthutapea@gmail.com');
72 |
73 | // fill in the password
74 | cy.get('input[placeholder="Password"]').type('gilbert123');
75 |
76 | // pressing the Login button
77 | cy.get('button')
78 | .contains(/^Login$/)
79 | .click();
80 |
81 | // verify that elements located on the homepage are displayed
82 | cy.get('label[for="create-thread-modal"]').should('be.visible');
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/src/components/Register/RegisterForm.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { act, render, screen } from '@testing-library/react';
3 | import userEvent from '@testing-library/user-event';
4 | import RegisterForm from './RegisterForm';
5 | import '@testing-library/jest-dom';
6 |
7 | /**
8 | * test scenario
9 | *
10 | * - RegisterForm component
11 | * - should handle name typing correctly
12 | * - should handle email typing correctly
13 | * - should handle password typing correctly
14 | * - should call register function when register button is clicked
15 | */
16 |
17 | describe('RegisterForm component', () => {
18 | it('should handle name typing correctly', async () => {
19 | // Arrange
20 | await act(async () => render( {}} />));
21 | const nameInput = await screen.getByPlaceholderText('Name');
22 |
23 | // Action
24 | await act(async () => userEvent.type(nameInput, 'emailtest'));
25 |
26 | // Assert
27 | expect(nameInput).toHaveValue('emailtest');
28 | });
29 |
30 | it('should handle email typing correctly', async () => {
31 | // Arrange
32 | await act(async () => render( {}} />));
33 | const emailInput = await screen.getByPlaceholderText('Email');
34 |
35 | // Action
36 | await act(async () => userEvent.type(emailInput, 'emailtest@gmail.com'));
37 |
38 | // Assert
39 | expect(emailInput).toHaveValue('emailtest@gmail.com');
40 | });
41 |
42 | it('should handle password typing correctly', async () => {
43 | // Arrange
44 | await act(async () => render( {}} />));
45 | const passwordInput = await screen.getByPlaceholderText('Password');
46 |
47 | // Action
48 | await act(async () => userEvent.type(passwordInput, 'passwordtest'));
49 |
50 | // Assert
51 | expect(passwordInput).toHaveValue('passwordtest');
52 | });
53 |
54 | it('should call register function when register button is clicked', async () => {
55 | // Arrange
56 | const mockRegister = jest.fn();
57 | await act(async () => render());
58 | const nameInput = await screen.getByPlaceholderText('Name');
59 | await act(async () => userEvent.type(nameInput, 'emailtest'));
60 | const emailInput = await screen.getByPlaceholderText('Email');
61 | await act(async () => userEvent.type(emailInput, 'emailtest@gmail.com'));
62 | const passwordInput = await screen.getByPlaceholderText('Password');
63 | await act(async () => userEvent.type(passwordInput, 'passwordtest'));
64 | const registerButton = await screen.getByRole('button', {
65 | name: 'Register',
66 | });
67 |
68 | // Action
69 | await act(async () => userEvent.click(registerButton));
70 |
71 | // Assert
72 | expect(mockRegister).toBeCalledWith({
73 | name: 'emailtest',
74 | email: 'emailtest@gmail.com',
75 | password: 'passwordtest',
76 | });
77 | });
78 | });
79 |
--------------------------------------------------------------------------------
/src/components/Thread/Thread.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Parse from 'html-react-parser';
4 | import { HashtagIcon } from '@heroicons/react/24/outline';
5 | import { useDispatch, useSelector } from 'react-redux';
6 | import { Link } from 'react-router-dom';
7 | import ThreadAction from './ThreadAction';
8 | import { asyncToggleVoteDetailThread } from '../../app/states/detailThread/action';
9 | import { asyncToggleVoteThread } from '../../app/states/threads/action';
10 | import { Owner } from '../Owner';
11 |
12 | export default function Thread({
13 | id,
14 | title,
15 | body,
16 | category,
17 | createdAt,
18 | owner,
19 | totalComments,
20 | upVotesBy,
21 | downVotesBy,
22 | type,
23 | }) {
24 | const dispatch = useDispatch();
25 | const { authUser } = useSelector((state) => state);
26 |
27 | const onToggleVoteThread = (voteType) => {
28 | if (authUser) {
29 | if (type === 'threads') {
30 | dispatch(
31 | asyncToggleVoteThread({ threadId: id, voteType, userId: authUser.id }),
32 | );
33 | } else {
34 | dispatch(
35 | asyncToggleVoteDetailThread({
36 | threadId: id,
37 | voteType,
38 | userId: authUser.id,
39 | }),
40 | );
41 | }
42 | } else {
43 | alert('Please login first');
44 | }
45 | };
46 |
47 | return (
48 |
49 |
50 |
51 |
52 | {title}
53 |
54 |
55 |
{Parse(body)}
56 |
57 |
58 | {category}
59 |
60 |
67 |
68 |
69 |
70 | );
71 | }
72 |
73 | Thread.defaultProps = {
74 | type: 'thread',
75 | };
76 |
77 | Thread.propTypes = {
78 | id: PropTypes.string.isRequired,
79 | title: PropTypes.string.isRequired,
80 | body: PropTypes.string.isRequired,
81 | category: PropTypes.string.isRequired,
82 | createdAt: PropTypes.string.isRequired,
83 | owner: PropTypes.shape({
84 | id: PropTypes.string.isRequired,
85 | name: PropTypes.string.isRequired,
86 | avatar: PropTypes.string.isRequired,
87 | }).isRequired,
88 | totalComments: PropTypes.number.isRequired,
89 | upVotesBy: PropTypes.arrayOf(PropTypes.string).isRequired,
90 | downVotesBy: PropTypes.arrayOf(PropTypes.string).isRequired,
91 | type: PropTypes.string,
92 | };
93 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "forum-bethup",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "vite build",
9 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10 | "test": "wait-on http://localhost:3000 && jest",
11 | "ci:test": "start-server-and-test dev http://localhost:3000 e2e",
12 | "e2e": "cypress run",
13 | "preview": "vite preview",
14 | "storybook": "storybook dev -p 6006",
15 | "build-storybook": "storybook build -o build/storybook"
16 | },
17 | "jest": {
18 | "testEnvironment": "jsdom",
19 | "transform": {
20 | "^.+\\.jsx?$": "babel-jest"
21 | }
22 | },
23 | "dependencies": {
24 | "@reduxjs/toolkit": "^2.2.3",
25 | "prop-types": "^15.8.1",
26 | "react": "^18.2.0",
27 | "react-dom": "^18.2.0",
28 | "react-redux": "^9.1.2",
29 | "react-redux-loading-bar": "^5.0.8",
30 | "react-router-dom": "^6.23.0",
31 | "wait-on": "^7.2.0"
32 | },
33 | "devDependencies": {
34 | "@babel/core": "^7.24.5",
35 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6",
36 | "@babel/plugin-proposal-optional-chaining": "^7.21.0",
37 | "@babel/preset-env": "^7.24.5",
38 | "@babel/preset-react": "^7.24.1",
39 | "@chromatic-com/storybook": "^1.4.0",
40 | "@heroicons/react": "^2.1.3",
41 | "@storybook/addon-essentials": "^8.1.1",
42 | "@storybook/addon-interactions": "^8.1.1",
43 | "@storybook/addon-links": "^8.1.1",
44 | "@storybook/addon-onboarding": "^8.1.1",
45 | "@storybook/blocks": "^8.1.1",
46 | "@storybook/react": "^8.1.1",
47 | "@storybook/react-vite": "^8.1.1",
48 | "@storybook/test": "^8.1.1",
49 | "@tailwindcss/line-clamp": "^0.4.4",
50 | "@testing-library/dom": "^10.1.0",
51 | "@testing-library/jest-dom": "^6.4.5",
52 | "@testing-library/react": "^15.0.7",
53 | "@types/react": "^18.2.66",
54 | "@types/react-dom": "^18.2.22",
55 | "@vitejs/plugin-react": "^4.2.1",
56 | "@vitejs/plugin-vue": "^5.0.4",
57 | "autoprefixer": "^10.4.19",
58 | "babel-jest": "^29.7.0",
59 | "cypress": "^13.9.0",
60 | "daisyui": "^4.10.3",
61 | "eslint": "^8.57.0",
62 | "eslint-config-prettier": "^9.1.0",
63 | "eslint-config-standard": "^17.1.0",
64 | "eslint-plugin-cypress": "^3.2.0",
65 | "eslint-plugin-import": "^2.29.1",
66 | "eslint-plugin-n": "^16.6.2",
67 | "eslint-plugin-promise": "^6.1.1",
68 | "eslint-plugin-react": "^7.34.1",
69 | "eslint-plugin-react-hooks": "^4.6.0",
70 | "eslint-plugin-react-refresh": "^0.4.6",
71 | "eslint-plugin-storybook": "^0.8.0",
72 | "html-react-parser": "^5.1.10",
73 | "jest": "^29.7.0",
74 | "jest-environment-jsdom": "^29.7.0",
75 | "postcss": "^8.4.38",
76 | "postcss-loader": "^8.1.1",
77 | "prettier": "^3.2.5",
78 | "start-server-and-test": "^2.0.3",
79 | "storybook": "^8.1.1",
80 | "tailwindcss": "^3.4.3",
81 | "vite": "^5.2.11",
82 | "vite-plugin-babel": "^1.2.0"
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/src/app/states/threads/action.js:
--------------------------------------------------------------------------------
1 | import { hideLoading, showLoading } from 'react-redux-loading-bar';
2 | import api from '../../../utils/api';
3 |
4 | const ActionType = {
5 | CREATE_THREAD: 'CREATE_THREAD',
6 | GET_ALL_THREADS: 'GET_ALL_THREADS',
7 | UP_VOTE_THREAD: 'UP_VOTE_THREAD',
8 | DOWN_VOTE_THREAD: 'DOWN_VOTE_THREAD',
9 | NEUTRAL_VOTE_THREAD: 'NEUTRAL_VOTE_THREAD',
10 | };
11 |
12 | function createThreadActionCreator(thread) {
13 | return {
14 | type: ActionType.CREATE_THREAD,
15 | payload: {
16 | thread,
17 | },
18 | };
19 | }
20 |
21 | function getAllThreadsActionCreator(threads) {
22 | return {
23 | type: ActionType.GET_ALL_THREADS,
24 | payload: {
25 | threads,
26 | },
27 | };
28 | }
29 |
30 | function upVoteThreadActionCreator({ threadId, userId }) {
31 | return {
32 | type: ActionType.UP_VOTE_THREAD,
33 | payload: {
34 | threadId,
35 | userId,
36 | },
37 | };
38 | }
39 |
40 | function downVoteThreadActionCreator({ threadId, userId }) {
41 | return {
42 | type: ActionType.DOWN_VOTE_THREAD,
43 | payload: {
44 | threadId,
45 | userId,
46 | },
47 | };
48 | }
49 |
50 | function neutralVoteThreadActionCreator({ threadId, userId }) {
51 | return {
52 | type: ActionType.NEUTRAL_VOTE_THREAD,
53 | payload: {
54 | threadId,
55 | userId,
56 | },
57 | };
58 | }
59 |
60 | function asyncCreateThread({ title, body, category }) {
61 | return async (dispatch) => {
62 | dispatch(showLoading());
63 | try {
64 | const thread = await api.createThread({ title, body, category });
65 | dispatch(createThreadActionCreator(thread));
66 | return { status: 'success' };
67 | } catch (error) {
68 | alert(error.message);
69 | return { status: 'error' };
70 | } finally {
71 | dispatch(hideLoading());
72 | }
73 | };
74 | }
75 |
76 | function asyncToggleVoteThread({ threadId, voteType, userId }) {
77 | return async (dispatch) => {
78 | dispatch(showLoading());
79 | switch (voteType) {
80 | case 1: {
81 | const responseUpVote = await api.upVoteThread(threadId);
82 | if (responseUpVote.status === 'success') { dispatch(upVoteThreadActionCreator({ threadId, userId })); }
83 | break;
84 | }
85 | case -1: {
86 | const responseDownVote = await api.downVoteThread(threadId);
87 | if (responseDownVote.status === 'success') { dispatch(downVoteThreadActionCreator({ threadId, userId })); }
88 | break;
89 | }
90 | default: {
91 | const responseNeutralVote = await api.neutralVoteThread(threadId);
92 | if (responseNeutralVote.status === 'success') { dispatch(neutralVoteThreadActionCreator({ threadId, userId })); }
93 | break;
94 | }
95 | }
96 | dispatch(hideLoading());
97 | };
98 | }
99 |
100 | export {
101 | ActionType,
102 | createThreadActionCreator,
103 | getAllThreadsActionCreator,
104 | asyncCreateThread,
105 | asyncToggleVoteThread,
106 | };
107 |
--------------------------------------------------------------------------------
/src/app/states/shared/action.test.js:
--------------------------------------------------------------------------------
1 | import { hideLoading, showLoading } from 'react-redux-loading-bar';
2 | import api from '../../../utils/api';
3 | import { asyncPopulateUsersAndThreads } from './action';
4 | import { getAllThreadsActionCreator } from '../threads/action';
5 | import { getAllUsersActionCreator } from '../users/action';
6 |
7 | /**
8 | * test scenario
9 | *
10 | * - asyncPopulateUsersAndThreads thunk
11 | * - should dispatch action correctly when data fetching success
12 | * - should dispatch action and call alert correctly when data fetching failed
13 | */
14 |
15 | const fakeUsersResponse = [
16 | {
17 | id: 'john_doe',
18 | name: 'John Doe',
19 | email: 'john@example.com',
20 | avatar: 'https://generated-image-url.jpg',
21 | },
22 | ];
23 |
24 | const fakeThreadsResponse = [
25 | {
26 | id: 'thread-1',
27 | title: 'Thread Pertama',
28 | body: 'Ini adalah thread pertama',
29 | category: 'General',
30 | createdAt: '2021-06-21T07:00:00.000Z',
31 | ownerId: 'users-1',
32 | upVotesBy: [],
33 | downVotesBy: [],
34 | totalComments: 0,
35 | },
36 | ];
37 |
38 | const fakeErrorResponse = new Error('Ups, something went wrong');
39 |
40 | describe('asyncPopulateUsersAndThreads thunk', () => {
41 | beforeEach(() => {
42 | api._getAllUsers = api.getAllUsers;
43 | api._getAllThreads = api.getAllThreads;
44 | });
45 |
46 | afterEach(() => {
47 | api.getAllUsers = api._getAllUsers;
48 | api.getAllThreads = api._getAllThreads;
49 |
50 | delete api._getAllUsers;
51 | delete api._getAllThreads;
52 | });
53 |
54 | it('should dispatch action correctly when data fetching success', async () => {
55 | // arrange
56 | // stub implementation
57 | api.getAllUsers = () => Promise.resolve(fakeUsersResponse);
58 | api.getAllThreads = () => Promise.resolve(fakeThreadsResponse);
59 | // mock dispatch
60 | const dispatch = jest.fn();
61 |
62 | // action
63 | await asyncPopulateUsersAndThreads()(dispatch);
64 |
65 | // assert
66 | expect(dispatch).toHaveBeenCalledWith(showLoading());
67 | expect(dispatch).toHaveBeenCalledWith(
68 | getAllUsersActionCreator(fakeUsersResponse),
69 | );
70 | expect(dispatch).toHaveBeenCalledWith(
71 | getAllThreadsActionCreator(fakeThreadsResponse),
72 | );
73 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
74 | });
75 |
76 | it('should dispatch action and call alert correctly when data fetching failed', async () => {
77 | // arrange
78 | // stub implementation
79 | api.getAllUsers = () => Promise.reject(fakeErrorResponse);
80 | api.getAllThreads = () => Promise.reject(fakeErrorResponse);
81 | // mock dispatch
82 | const dispatch = jest.fn();
83 | // mock alert
84 | window.alert = jest.fn();
85 |
86 | // action
87 | await asyncPopulateUsersAndThreads()(dispatch);
88 |
89 | // assert
90 | expect(dispatch).toHaveBeenCalledWith(showLoading());
91 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
92 | expect(window.alert).toHaveBeenCalledWith(fakeErrorResponse.message);
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/src/components/Comments/Comment.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import Parser from 'html-react-parser';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import {
6 | HandThumbDownIcon as HandThumbDownIconOutline,
7 | HandThumbUpIcon as HandThumbUpIconOutline,
8 | } from '@heroicons/react/24/outline';
9 | import {
10 | HandThumbDownIcon as HandThumbDownIconFilled,
11 | HandThumbUpIcon as HandThumbUpIconFilled,
12 | } from '@heroicons/react/24/solid';
13 | import { asyncToggleVoteCommentThread } from '../../app/states/detailThread/action';
14 | import { Owner } from '../Owner';
15 |
16 | export default function Comment({
17 | threadId,
18 | id,
19 | owner,
20 | content,
21 | upVotesBy,
22 | downVotesBy,
23 | createdAt,
24 | }) {
25 | const dispatch = useDispatch();
26 | const { authUser } = useSelector((state) => state);
27 |
28 | const onToggleVoteComment = ({ voteType, commentId }) => {
29 | if (authUser) {
30 | dispatch(
31 | asyncToggleVoteCommentThread({
32 | threadId,
33 | voteType,
34 | userId: authUser.id,
35 | commentId,
36 | }),
37 | );
38 | } else {
39 | alert('Please login first');
40 | }
41 | };
42 |
43 | return (
44 |
45 |
46 |
47 |
{Parser(content)}
48 |
49 |
50 | {upVotesBy.length}
51 |
65 |
66 |
67 | {downVotesBy.length}
68 |
82 |
83 |
84 |
85 |
86 | );
87 | }
88 |
89 | Comment.propTypes = {
90 | threadId: PropTypes.string.isRequired,
91 | id: PropTypes.string.isRequired,
92 | content: PropTypes.string.isRequired,
93 | createdAt: PropTypes.string.isRequired,
94 | owner: PropTypes.objectOf(PropTypes.string).isRequired,
95 | upVotesBy: PropTypes.arrayOf(PropTypes.string).isRequired,
96 | downVotesBy: PropTypes.arrayOf(PropTypes.string).isRequired,
97 | };
98 |
--------------------------------------------------------------------------------
/src/app/states/detailThread/reducer.js:
--------------------------------------------------------------------------------
1 | import { ActionType } from './action';
2 |
3 | export default function detailThreadReducer(detailThread = null, action = {}) {
4 | switch (action.type) {
5 | case ActionType.GET_DETAIL_THREAD:
6 | return action.payload.detailThread;
7 | case ActionType.CREATE_COMMENT:
8 | return {
9 | ...detailThread,
10 | comments: [action.payload.comment, ...detailThread.comments],
11 | };
12 | case ActionType.UP_VOTE_DETAIL_THREAD:
13 | return {
14 | ...detailThread,
15 | upVotesBy: detailThread.upVotesBy.includes(action.payload.userId)
16 | ? detailThread.upVotesBy.filter((id) => id !== action.payload.userId)
17 | : detailThread.upVotesBy.concat([action.payload.userId]),
18 | downVotesBy: detailThread.downVotesBy.filter((id) => id !== action.payload.userId),
19 | };
20 | case ActionType.DOWN_VOTE_DETAIL_THREAD:
21 | return {
22 | ...detailThread,
23 | upVotesBy: detailThread.upVotesBy.filter((id) => id !== action.payload.userId),
24 | downVotesBy: detailThread.downVotesBy.includes(action.payload.userId)
25 | ? detailThread.downVotesBy.filter((id) => id !== action.payload.userId)
26 | : detailThread.downVotesBy.concat([action.payload.userId]),
27 | };
28 | case ActionType.NEUTRAL_VOTE_DETAIL_THREAD:
29 | return {
30 | ...detailThread,
31 | upVotesBy: detailThread.upVotesBy.filter((id) => id !== action.payload.userId),
32 | downVotesBy: detailThread.downVotesBy.filter((id) => id !== action.payload.userId),
33 | };
34 | case ActionType.UP_VOTE_COMMENT_THREAD:
35 | return {
36 | ...detailThread,
37 | comments: detailThread.comments.map((comment) => {
38 | if (comment.id === action.payload.commentId) {
39 | return {
40 | ...comment,
41 | upVotesBy: comment.upVotesBy.includes(action.payload.userId)
42 | ? comment.upVotesBy.filter((id) => id !== action.payload.userId)
43 | : comment.upVotesBy.concat([action.payload.userId]),
44 | downVotesBy: comment.downVotesBy.filter((id) => id !== action.payload.userId),
45 | };
46 | }
47 | return comment;
48 | }),
49 | };
50 | case ActionType.DOWN_VOTE_COMMENT_THREAD:
51 | return {
52 | ...detailThread,
53 | comments: detailThread.comments.map((comment) => {
54 | if (comment.id === action.payload.commentId) {
55 | return {
56 | ...comment,
57 | upVotesBy: comment.upVotesBy.filter((id) => id !== action.payload.userId),
58 | downVotesBy: comment.downVotesBy.includes(action.payload.userId)
59 | ? comment.downVotesBy.filter((id) => id !== action.payload.userId)
60 | : comment.downVotesBy.concat([action.payload.userId]),
61 | };
62 | }
63 | return comment;
64 | }),
65 | };
66 | case ActionType.NEUTRAL_VOTE_COMMENT_THREAD:
67 | return {
68 | ...detailThread,
69 | comments: detailThread.comments.map((comment) => {
70 | if (comment.id === action.payload.commentId) {
71 | return {
72 | ...comment,
73 | upVotesBy: comment.upVotesBy.filter((id) => id !== action.payload.userId),
74 | downVotesBy: comment.downVotesBy.filter((id) => id !== action.payload.userId),
75 | };
76 | }
77 | return comment;
78 | }),
79 | };
80 | case ActionType.RESET_DETAIL_THREAD:
81 | return null;
82 | default:
83 | return detailThread;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/src/components/Navbar/Navbar.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Link } from 'react-router-dom';
4 | import { Bars3CenterLeftIcon } from '@heroicons/react/24/outline';
5 | import NavbarUser from './NavbarUser';
6 | import { isObjectEmpty } from '../../utils';
7 | import logoForumBetHup from '../../assets/images/logo/logo-forum-BetHup.png';
8 |
9 | export default function Navbar({ authUser, onLogout }) {
10 | return (
11 |
12 |
13 |
14 |
21 |
22 | {!isObjectEmpty(authUser) && (
23 | -
24 |
25 |
26 | )}
27 | -
28 | Home
29 |
30 | -
31 | Leaderboards
32 |
33 | {!isObjectEmpty(authUser) ? (
34 | -
35 |
38 |
39 | ) : (
40 | -
41 | Login
42 |
43 | )}
44 |
45 |
46 |
47 |
48 |

54 |
Forum BetHup
55 |
56 |
57 |
58 |
59 |
60 | -
61 | Home
62 |
63 | -
64 | Leaderboards
65 |
66 | {isObjectEmpty(authUser) && (
67 | -
68 | Login
69 |
70 | )}
71 |
72 | {!isObjectEmpty(authUser) && (
73 |
74 |
83 |
84 | -
85 |
86 |
87 | -
88 |
91 |
92 |
93 |
94 | )}
95 |
96 |
97 | );
98 | }
99 |
100 | Navbar.defaultProps = {
101 | authUser: null,
102 | };
103 |
104 | Navbar.propTypes = {
105 | authUser: PropTypes.shape(PropTypes.string.isRequired),
106 | onLogout: PropTypes.func.isRequired,
107 | };
108 |
--------------------------------------------------------------------------------
/cypress/e2e/register.cy.js:
--------------------------------------------------------------------------------
1 | /**
2 | * - Register spec
3 | * - should display register page correctly
4 | * - should display alert when name is empty
5 | * - should display alert when email is empty
6 | * - should display alert when password is empty
7 | * - should display alert when email is already taken
8 | * - should display login page when email is not already taken
9 | */
10 |
11 | const dateNow = Date.now();
12 |
13 | describe('Register spec', () => {
14 | beforeEach(() => {
15 | cy.visit('http://localhost:3000/register');
16 | });
17 |
18 | it('should display register page correctly', () => {
19 | // verify the elements that should appear on the register page
20 | cy.get('input[placeholder="Name"]').should('be.visible');
21 | cy.get('input[placeholder="Email"]').should('be.visible');
22 | cy.get('input[placeholder="Password"]').should('be.visible');
23 | cy.get('button')
24 | .contains(/^Register$/)
25 | .should('be.visible');
26 | });
27 |
28 | it('should display alert when name is empty', () => {
29 | // Click the register button without filling in the name
30 | cy.get('button')
31 | .contains(/^Register$/)
32 | .click();
33 |
34 | // verify window.alert to display messages from the API
35 | cy.on('window:alert', (str) => {
36 | expect(str).to.equal('"name" is not allowed to be empty');
37 | });
38 | });
39 |
40 | it('should display alert when email is empty', () => {
41 | // fill in name
42 | cy.get('input[placeholder="Name"]').type(`${dateNow}name`);
43 |
44 | // Click the register button without filling in your email
45 | cy.get('button')
46 | .contains(/^Register$/)
47 | .click();
48 |
49 | // verify window.alert to display messages from the API
50 | cy.on('window:alert', (str) => {
51 | expect(str).to.equal('"email" is not allowed to be empty');
52 | });
53 | });
54 |
55 | it('should display alert when password is empty', () => {
56 | // fill in name
57 | cy.get('input[placeholder="Name"]').type(`${dateNow}name`);
58 | // fill in the email
59 | cy.get('input[placeholder="Email"]').type(`${dateNow}@gmail.com`);
60 |
61 | // Click the register button without entering a password
62 | cy.get('button')
63 | .contains(/^Register$/)
64 | .click();
65 |
66 | // verify window.alert to display messages from the API
67 | cy.on('window:alert', (str) => {
68 | expect(str).to.equal('"password" is not allowed to be empty');
69 | });
70 | });
71 |
72 | it('should display alert when email is already taken', () => {
73 | // fill in name
74 | cy.get('input[placeholder="Name"]').type(`${dateNow}name`);
75 | // fill in the registered email
76 | cy.get('input[placeholder="Email"]').type('gilberthutapea@gmail.com');
77 | // fill in the password
78 | cy.get('input[placeholder="Password"]').type(`${dateNow}password`);
79 |
80 | // pressing the register button
81 | cy.get('button')
82 | .contains(/^Register$/)
83 | .click();
84 |
85 | // verify window.alert to display messages from the API
86 | cy.on('window:alert', (str) => {
87 | expect(str).to.equal('email is already taken');
88 | });
89 | });
90 |
91 | it('should display login page when email is not already taken', () => {
92 | // fill in name
93 | cy.get('input[placeholder="Name"]').type(`${dateNow}name`);
94 | // fill in the email
95 | cy.get('input[placeholder="Email"]').type(`${dateNow}@gmail.com`);
96 | // fill in the password
97 | cy.get('input[placeholder="Password"]').type(`${dateNow}password`);
98 |
99 | // pressing the register button
100 | cy.get('button')
101 | .contains(/^Register$/)
102 | .click();
103 |
104 | // verify that elements located on the homepage are displayed
105 | cy.get('button').contains('Login').should('be.visible');
106 | });
107 | });
108 |
--------------------------------------------------------------------------------
/src/app/states/detailThread/action.test.js:
--------------------------------------------------------------------------------
1 | import { hideLoading, showLoading } from 'react-redux-loading-bar';
2 | import api from '../../../utils/api';
3 | import {
4 | asyncGetDetailThread,
5 | getDetailThreadActionCreator,
6 | asyncCreateComment,
7 | createCommentActionCreator,
8 | } from './action';
9 |
10 | /**
11 | * test scenario
12 | *
13 | * - asyncGetDetailThread thunk
14 | * - should dispatch action correctly when data fetching success
15 | * - should dispatch action and call alert correctly when data fetching failed
16 | *
17 | * - asyncCreateComment thunk
18 | * - should dispatch action correctly when create comment success
19 | * - should dispatch action and call alert correctly when create comment failed
20 | */
21 |
22 | const fakeDetailThreadResponse = {
23 | id: 'thread-1',
24 | title: 'Thread Pertama',
25 | body: 'Ini adalah thread pertama',
26 | category: 'General',
27 | createdAt: '2021-06-21T07:00:00.000Z',
28 | owner: {
29 | id: 'users-1',
30 | name: 'John Doe',
31 | avatar: 'https://generated-image-url.jpg',
32 | },
33 | upVotesBy: [],
34 | downVotesBy: [],
35 | comments: [],
36 | };
37 |
38 | const fakeCommentResponse = {
39 | id: 'comment-1',
40 | content: 'Ini adalah komentar pertama',
41 | createdAt: '2021-06-21T07:00:00.000Z',
42 | upVotesBy: [],
43 | downVotesBy: [],
44 | owner: {
45 | id: 'users-1',
46 | name: 'John Doe',
47 | email: 'john@example.com',
48 | },
49 | };
50 |
51 | const fakeCreateCommentInput = {
52 | threadId: 'thread-1',
53 | content: 'Ini adalah komentar pertama',
54 | };
55 |
56 | const fakeErrorResponse = new Error('Ups, something went wrong');
57 |
58 | describe('asyncGetDetailThread thunk', () => {
59 | beforeEach(() => {
60 | api._getDetailThread = api.getDetailThread;
61 | });
62 |
63 | afterEach(() => {
64 | api.getDetailThread = api._getDetailThread;
65 |
66 | delete api._getDetailThread;
67 | });
68 |
69 | it('should dispatch action correctly when data fetching success', async () => {
70 | // arrange
71 | // stub implementation
72 | api.getDetailThread = () => Promise.resolve(fakeDetailThreadResponse);
73 | // mock dispatch
74 | const dispatch = jest.fn();
75 |
76 | // action
77 | await asyncGetDetailThread()(dispatch);
78 |
79 | // assert
80 | expect(dispatch).toHaveBeenCalledWith(showLoading());
81 | expect(dispatch).toHaveBeenCalledWith(
82 | getDetailThreadActionCreator(fakeDetailThreadResponse),
83 | );
84 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
85 | });
86 |
87 | it('should dispatch action and call alert correctly when register failed', async () => {
88 | // arrange
89 | // stub implementation
90 | api.getDetailThread = () => Promise.reject(fakeErrorResponse);
91 | // mock dispatch
92 | const dispatch = jest.fn();
93 | // mock alert
94 | window.alert = jest.fn();
95 |
96 | // action
97 | await asyncGetDetailThread()(dispatch);
98 |
99 | // assert
100 | expect(dispatch).toHaveBeenCalledWith(showLoading());
101 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
102 | expect(window.alert).toHaveBeenCalledWith(fakeErrorResponse.message);
103 | });
104 | });
105 |
106 | describe('asyncCreateComment thunk', () => {
107 | beforeEach(() => {
108 | api._createComment = api.createComment;
109 | });
110 |
111 | afterEach(() => {
112 | api.createComment = api._createComment;
113 |
114 | delete api._createComment;
115 | });
116 |
117 | it('should dispatch action correctly when create comment success', async () => {
118 | // arrange
119 | // stub implementation
120 | api.createComment = () => Promise.resolve(fakeCommentResponse);
121 | // mock dispatch
122 | const dispatch = jest.fn();
123 |
124 | // action
125 | await asyncCreateComment(fakeCreateCommentInput)(dispatch);
126 |
127 | // assert
128 | expect(dispatch).toHaveBeenCalledWith(showLoading());
129 | expect(dispatch).toHaveBeenCalledWith(
130 | createCommentActionCreator(fakeCommentResponse),
131 | );
132 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
133 | });
134 |
135 | it('should dispatch action and call alert correctly when create comment failed', async () => {
136 | // arrange
137 | // stub implementation
138 | api.createComment = () => Promise.reject(fakeErrorResponse);
139 | // mock dispatch
140 | const dispatch = jest.fn();
141 | // mock alert
142 | window.alert = jest.fn();
143 |
144 | // action
145 | await asyncCreateComment(fakeCreateCommentInput)(dispatch);
146 |
147 | // assert
148 | expect(dispatch).toHaveBeenCalledWith(showLoading());
149 | expect(dispatch).toHaveBeenCalledWith(hideLoading());
150 | expect(window.alert).toHaveBeenCalledWith(fakeErrorResponse.message);
151 | });
152 | });
153 |
--------------------------------------------------------------------------------
/src/app/states/detailThread/action.js:
--------------------------------------------------------------------------------
1 | import { hideLoading, showLoading } from 'react-redux-loading-bar';
2 | import api from '../../../utils/api';
3 |
4 | const ActionType = {
5 | GET_DETAIL_THREAD: 'GET_DETAIL_THREAD',
6 | RESET_DETAIL_THREAD: 'RESET_DETAIL_THREAD',
7 | CREATE_COMMENT: 'CREATE_COMMENT',
8 | UP_VOTE_DETAIL_THREAD: 'UP_VOTE_DETAIL_THREAD',
9 | DOWN_VOTE_DETAIL_THREAD: 'DOWN_VOTE_DETAIL_THREAD',
10 | NEUTRAL_VOTE_DETAIL_THREAD: 'NEUTRAL_VOTE_DETAIL_THREAD',
11 | UP_VOTE_COMMENT_THREAD: 'UP_VOTE_COMMENT_THREAD',
12 | DOWN_VOTE_COMMENT_THREAD: 'DOWN_VOTE_COMMENT_THREAD',
13 | NEUTRAL_VOTE_COMMENT_THREAD: 'NEUTRAL_VOTE_COMMENT_THREAD',
14 | };
15 |
16 | function getDetailThreadActionCreator(detailThread) {
17 | return {
18 | type: ActionType.GET_DETAIL_THREAD,
19 | payload: {
20 | detailThread,
21 | },
22 | };
23 | }
24 |
25 | function createCommentActionCreator(comment) {
26 | return {
27 | type: ActionType.CREATE_COMMENT,
28 | payload: {
29 | comment,
30 | },
31 | };
32 | }
33 |
34 | function upVoteDetailThreadActionCreator({ threadId, userId }) {
35 | return {
36 | type: ActionType.UP_VOTE_DETAIL_THREAD,
37 | payload: {
38 | threadId,
39 | userId,
40 | },
41 | };
42 | }
43 |
44 | function downVoteDetailThreadActionCreator({ threadId, userId }) {
45 | return {
46 | type: ActionType.DOWN_VOTE_DETAIL_THREAD,
47 | payload: {
48 | threadId,
49 | userId,
50 | },
51 | };
52 | }
53 |
54 | function neutralVoteDetailThreadActionCreator({ threadId, userId }) {
55 | return {
56 | type: ActionType.NEUTRAL_VOTE_DETAIL_THREAD,
57 | payload: {
58 | threadId,
59 | userId,
60 | },
61 | };
62 | }
63 |
64 | function upVoteCommentThreadActionCreator({ commentId, userId }) {
65 | return {
66 | type: ActionType.UP_VOTE_COMMENT_THREAD,
67 | payload: {
68 | commentId,
69 | userId,
70 | },
71 | };
72 | }
73 |
74 | function downVoteCommentThreadActionCreator({ commentId, userId }) {
75 | return {
76 | type: ActionType.DOWN_VOTE_COMMENT_THREAD,
77 | payload: {
78 | commentId,
79 | userId,
80 | },
81 | };
82 | }
83 |
84 | function neutralVoteCommentThreadActionCreator({ commentId, userId }) {
85 | return {
86 | type: ActionType.NEUTRAL_VOTE_COMMENT_THREAD,
87 | payload: {
88 | commentId,
89 | userId,
90 | },
91 | };
92 | }
93 |
94 | function resetDetailThreadActionCreator() {
95 | return {
96 | type: ActionType.RESET_DETAIL_THREAD,
97 | };
98 | }
99 |
100 | function asyncGetDetailThread(id) {
101 | return async (dispatch) => {
102 | dispatch(showLoading());
103 | try {
104 | const detailThread = await api.getDetailThread(id);
105 | dispatch(getDetailThreadActionCreator(detailThread));
106 | return { status: 'success' };
107 | } catch (error) {
108 | alert(error.message);
109 | return { status: 'error' };
110 | } finally {
111 | dispatch(hideLoading());
112 | }
113 | };
114 | }
115 |
116 | function asyncCreateComment({ threadId, content }) {
117 | return async (dispatch) => {
118 | dispatch(showLoading());
119 | try {
120 | const comment = await api.createComment({ threadId, content });
121 | dispatch(createCommentActionCreator(comment));
122 | return { status: 'success' };
123 | } catch (error) {
124 | alert(error.message);
125 | return { status: 'error' };
126 | } finally {
127 | dispatch(hideLoading());
128 | }
129 | };
130 | }
131 |
132 | function asyncToggleVoteDetailThread({ threadId, voteType, userId }) {
133 | return async (dispatch) => {
134 | dispatch(showLoading());
135 | switch (voteType) {
136 | case 1: {
137 | const responseUpVote = await api.upVoteThread(threadId);
138 | if (responseUpVote.status === 'success') { dispatch(upVoteDetailThreadActionCreator({ threadId, userId })); }
139 | break;
140 | }
141 | case -1: {
142 | const responseDownVote = await api.downVoteThread(threadId);
143 | if (responseDownVote.status === 'success') { dispatch(downVoteDetailThreadActionCreator({ threadId, userId })); }
144 | break;
145 | }
146 | default: {
147 | const responseNeutralVote = await api.downVoteThread(threadId);
148 | if (responseNeutralVote.status === 'success') { dispatch(neutralVoteDetailThreadActionCreator({ threadId, userId })); }
149 | break;
150 | }
151 | }
152 | dispatch(hideLoading());
153 | };
154 | }
155 |
156 | function asyncToggleVoteCommentThread({
157 | threadId, commentId, voteType, userId,
158 | }) {
159 | return async (dispatch) => {
160 | dispatch(showLoading());
161 | switch (voteType) {
162 | case 1: {
163 | const responseUpVote = await api.upVoteComment({ threadId, commentId });
164 | if (responseUpVote.status === 'success') { dispatch(upVoteCommentThreadActionCreator({ commentId, userId })); }
165 | break;
166 | }
167 | case -1: {
168 | const responseDownVote = await api.downVoteComment({ threadId, commentId });
169 | if (responseDownVote.status === 'success') { dispatch(downVoteCommentThreadActionCreator({ commentId, userId })); }
170 | break;
171 | }
172 | default: {
173 | const responseNeutralVote = await api.neutralVoteComment({ threadId, commentId });
174 | if (responseNeutralVote.status === 'success') { dispatch(neutralVoteCommentThreadActionCreator({ commentId, userId })); }
175 | break;
176 | }
177 | }
178 | dispatch(hideLoading());
179 | };
180 | }
181 |
182 | export {
183 | ActionType,
184 | getDetailThreadActionCreator,
185 | createCommentActionCreator,
186 | resetDetailThreadActionCreator,
187 | asyncGetDetailThread,
188 | asyncCreateComment,
189 | asyncToggleVoteDetailThread,
190 | asyncToggleVoteCommentThread,
191 | };
192 |
--------------------------------------------------------------------------------
/src/app/states/threads/reducer.test.js:
--------------------------------------------------------------------------------
1 | import threadsReducer from './reducer';
2 |
3 | /**
4 | * test scenario for threadsReducer
5 | *
6 | * - threadsReducers function
7 | * - should return the initial state when given by unknown action
8 | * - should return the threads when given by GET_ALL_THREADS action
9 | * - should return the threads with the new thread when given by CREATE_THREAD action
10 | * - should return the threads with the toggled upvote thread when given by UP_VOTE_THREAD action
11 | * - should return the threads with the toggled downvote thread
12 | * when given by DOWN_VOTE_THREAD action
13 | * - should return the threads with the toggled neutral vote thread
14 | * when given by NEUTRAL_VOTE_THREAD action
15 | *
16 | */
17 |
18 | describe('threadsReducer function', () => {
19 | it('should return the initial state when given by unknown action', () => {
20 | // arrange
21 | const initialState = [];
22 | const action = { type: 'UNKNOWN' };
23 |
24 | // action
25 | const nextState = threadsReducer(initialState, action);
26 |
27 | // assert
28 | expect(nextState).toEqual(initialState);
29 | });
30 |
31 | it('should return the threads when given by GET_ALL_THREADS action', () => {
32 | // arrange
33 | const initialState = [];
34 | const action = {
35 | type: 'GET_ALL_THREADS',
36 | payload: {
37 | threads: [
38 | {
39 | id: 'thread-1',
40 | title: 'Thread Pertama',
41 | body: 'Ini adalah thread pertama',
42 | category: 'General',
43 | createdAt: '2021-06-21T07:00:00.000Z',
44 | ownerId: 'users-1',
45 | upVotesBy: [],
46 | downVotesBy: [],
47 | totalComments: 0,
48 | },
49 | ],
50 | },
51 | };
52 |
53 | // action
54 | const nextState = threadsReducer(initialState, action);
55 |
56 | // assert
57 | expect(nextState).toEqual(action.payload.threads);
58 | });
59 |
60 | it('should return the threads with the new thread when given by CREATE_THREAD action', () => {
61 | // arrange
62 | const initialState = [
63 | {
64 | id: 'thread-1',
65 | title: 'Thread Pertama',
66 | body: 'Ini adalah thread pertama',
67 | category: 'General',
68 | createdAt: '2021-06-21T07:00:00.000Z',
69 | ownerId: 'users-1',
70 | upVotesBy: [],
71 | downVotesBy: [],
72 | totalComments: 0,
73 | },
74 | ];
75 |
76 | const action = {
77 | type: 'CREATE_THREAD',
78 | payload: {
79 | thread: {
80 | id: 'thread-2',
81 | title: 'Thread Kedua',
82 | body: 'Ini adalah thread kedua',
83 | category: 'General',
84 | createdAt: '2021-06-21T07:00:00.000Z',
85 | ownerId: 'users-2',
86 | upVotesBy: [],
87 | downVotesBy: [],
88 | totalComments: 0,
89 | },
90 | },
91 | };
92 |
93 | // action
94 | const nextState = threadsReducer(initialState, action);
95 |
96 | // assert
97 | expect(nextState).toEqual([action.payload.thread, ...initialState]);
98 | });
99 |
100 | it('should return the threads with the toggled upvote thread when given by UP_VOTE_THREAD action', () => {
101 | // arrange
102 | const initialState = [
103 | {
104 | id: 'thread-1',
105 | title: 'Thread Pertama',
106 | body: 'Ini adalah thread pertama',
107 | category: 'General',
108 | createdAt: '2021-06-21T07:00:00.000Z',
109 | ownerId: 'users-1',
110 | upVotesBy: [],
111 | downVotesBy: [],
112 | totalComments: 0,
113 | },
114 | ];
115 |
116 | const action = {
117 | type: 'UP_VOTE_THREAD',
118 | payload: {
119 | threadId: 'thread-1',
120 | userId: 'user-1',
121 | },
122 | };
123 |
124 | // action: upvote thread
125 | const nextState = threadsReducer(initialState, action);
126 |
127 | // assert
128 | expect(nextState).toEqual([
129 | {
130 | ...initialState[0],
131 | upVotesBy: [action.payload.userId],
132 | downVotesBy: [],
133 | },
134 | ]);
135 |
136 | // action: unupvote thread
137 | const nextState2 = threadsReducer(nextState, action);
138 |
139 | expect(nextState2).toEqual(initialState);
140 | });
141 |
142 | it('should return the threads with the toggled downvote thread when given by DOWN_VOTE_THREAD action', () => {
143 | // arrange
144 | const initialState = [
145 | {
146 | id: 'thread-1',
147 | title: 'Thread Pertama',
148 | body: 'Ini adalah thread pertama',
149 | category: 'General',
150 | createdAt: '2021-06-21T07:00:00.000Z',
151 | ownerId: 'users-1',
152 | upVotesBy: [],
153 | downVotesBy: [],
154 | totalComments: 0,
155 | },
156 | ];
157 |
158 | const action = {
159 | type: 'DOWN_VOTE_THREAD',
160 | payload: {
161 | threadId: 'thread-1',
162 | userId: 'user-1',
163 | },
164 | };
165 |
166 | // action: downvote thread
167 | const nextState = threadsReducer(initialState, action);
168 |
169 | // assert
170 | expect(nextState).toEqual([
171 | {
172 | ...initialState[0],
173 | upVotesBy: [],
174 | downVotesBy: [action.payload.userId],
175 | },
176 | ]);
177 |
178 | // action: undownvote thread
179 | const nextState2 = threadsReducer(nextState, action);
180 |
181 | expect(nextState2).toEqual(initialState);
182 | });
183 |
184 | it('should return the threads with the neutral vote thread when given by NEUTRAL_VOTE_THREAD action', () => {
185 | // arrange
186 | const initialState = [
187 | {
188 | id: 'thread-1',
189 | title: 'Thread Pertama',
190 | body: 'Ini adalah thread pertama',
191 | category: 'General',
192 | createdAt: '2021-06-21T07:00:00.000Z',
193 | ownerId: 'users-1',
194 | upVotesBy: [],
195 | downVotesBy: [],
196 | totalComments: 0,
197 | },
198 | ];
199 |
200 | const action = {
201 | type: 'NEUTRAL_VOTE_THREAD',
202 | payload: {
203 | threadId: 'thread-1',
204 | userId: 'user-1',
205 | },
206 | };
207 |
208 | // action
209 | const nextState = threadsReducer(initialState, action);
210 |
211 | // assert
212 | expect(nextState).toEqual([
213 | {
214 | ...initialState[0],
215 | upVotesBy: [],
216 | downVotesBy: [],
217 | },
218 | ]);
219 | });
220 | });
221 |
--------------------------------------------------------------------------------
/src/utils/api.js:
--------------------------------------------------------------------------------
1 | const api = (() => {
2 | const BASE_URL = 'https://forum-api.dicoding.dev/v1';
3 |
4 | function putAccessToken(token) {
5 | localStorage.setItem('accessToken', token);
6 | }
7 |
8 | function getAccessToken() {
9 | return localStorage.getItem('accessToken');
10 | }
11 |
12 | async function fetchWithAuth(url, options = {}) {
13 | return fetch(url, {
14 | ...options,
15 | headers: {
16 | ...options.headers,
17 | Authorization: `Bearer ${getAccessToken()}`,
18 | },
19 | });
20 | }
21 |
22 | async function registerUser({ name, email, password }) {
23 | const response = await fetch(`${BASE_URL}/register`, {
24 | method: 'POST',
25 | headers: {
26 | 'Content-Type': 'application/json',
27 | },
28 | body: JSON.stringify({
29 | name,
30 | email,
31 | password,
32 | }),
33 | });
34 | const responseJson = await response.json();
35 | const { status, message, data: { user } } = responseJson;
36 |
37 | if (status !== 'success') {
38 | throw new Error(message);
39 | }
40 |
41 | return user;
42 | }
43 |
44 | async function login({ email, password }) {
45 | const response = await fetch(`${BASE_URL}/login`, {
46 | method: 'POST',
47 | headers: {
48 | 'Content-Type': 'application/json',
49 | },
50 | body: JSON.stringify({
51 | email,
52 | password,
53 | }),
54 | });
55 |
56 | const responseJson = await response.json();
57 | const { status, message, data: { token } } = responseJson;
58 |
59 | if (status !== 'success') {
60 | throw new Error(message);
61 | }
62 |
63 | return token;
64 | }
65 |
66 | async function getOwnProfile() {
67 | const response = await fetchWithAuth(`${BASE_URL}/users/me`);
68 | const responseJson = await response.json();
69 | const { status, message, data: { user } } = responseJson;
70 |
71 | if (status !== 'success') {
72 | throw new Error(message);
73 | }
74 |
75 | return user;
76 | }
77 |
78 | async function getAllUsers() {
79 | const response = await fetch(`${BASE_URL}/users`);
80 | const responseJson = await response.json();
81 | const { status, message, data: { users } } = responseJson;
82 |
83 | if (status !== 'success') {
84 | throw new Error(message);
85 | }
86 |
87 | return users;
88 | }
89 |
90 | async function createThread({ title, body, category }) {
91 | const response = await fetchWithAuth(`${BASE_URL}/threads`, {
92 | method: 'POST',
93 | headers: {
94 | 'Content-Type': 'application/json',
95 | },
96 | body: JSON.stringify({
97 | title,
98 | body,
99 | category,
100 | }),
101 | });
102 | const responseJson = await response.json();
103 | const { status, message, data: { thread } } = responseJson;
104 |
105 | if (status !== 'success') {
106 | throw new Error(message);
107 | }
108 |
109 | return thread;
110 | }
111 |
112 | async function getAllThreads() {
113 | const response = await fetch(`${BASE_URL}/threads`);
114 | const responseJson = await response.json();
115 | const { status, message, data: { threads } } = responseJson;
116 |
117 | if (status !== 'success') {
118 | throw new Error(message);
119 | }
120 |
121 | return threads;
122 | }
123 |
124 | async function getDetailThread(id) {
125 | const response = await fetch(`${BASE_URL}/threads/${id}`);
126 | const responseJson = await response.json();
127 | const { status, message, data: { detailThread } } = responseJson;
128 |
129 | if (status !== 'success') {
130 | throw new Error(message);
131 | }
132 |
133 | return detailThread;
134 | }
135 |
136 | async function createComment({ threadId, content }) {
137 | const response = await fetchWithAuth(`${BASE_URL}/threads/${threadId}/comments`, {
138 | method: 'POST',
139 | headers: {
140 | 'Content-Type': 'application/json',
141 | },
142 | body: JSON.stringify({
143 | content,
144 | }),
145 | });
146 | const responseJson = await response.json();
147 | const { status, message, data: { comment } } = responseJson;
148 |
149 | if (status !== 'success') {
150 | throw new Error(message);
151 | }
152 |
153 | return comment;
154 | }
155 |
156 | async function upVoteThread(threadId) {
157 | const response = await fetchWithAuth(`${BASE_URL}/threads/${threadId}/up-vote`, {
158 | method: 'POST',
159 | });
160 | const responseJson = await response.json();
161 | if (responseJson.status !== 'success') {
162 | alert(responseJson.message);
163 | }
164 |
165 | return responseJson;
166 | }
167 |
168 | async function downVoteThread(threadId) {
169 | const response = await fetchWithAuth(`${BASE_URL}/threads/${threadId}/down-vote`, {
170 | method: 'POST',
171 | });
172 | const responseJson = await response.json();
173 | if (responseJson.status !== 'success') {
174 | alert(responseJson.message);
175 | }
176 |
177 | return responseJson;
178 | }
179 |
180 | async function neutralVoteThread(threadId) {
181 | const response = await fetchWithAuth(`${BASE_URL}/threads/${threadId}/neutral-vote`, {
182 | method: 'POST',
183 | });
184 | const responseJson = await response.json();
185 | if (responseJson.status !== 'success') {
186 | alert(responseJson.message);
187 | }
188 |
189 | return responseJson;
190 | }
191 |
192 | async function upVoteComment({ threadId, commentId }) {
193 | const response = await fetchWithAuth(`${BASE_URL}/threads/${threadId}/comments/${commentId}/up-vote`, {
194 | method: 'POST',
195 | });
196 | const responseJson = await response.json();
197 | if (responseJson.status !== 'success') {
198 | alert(responseJson.message);
199 | }
200 |
201 | return responseJson;
202 | }
203 |
204 | async function downVoteComment({ threadId, commentId }) {
205 | const response = await fetchWithAuth(`${BASE_URL}/threads/${threadId}/comments/${commentId}/down-vote`, {
206 | method: 'POST',
207 | });
208 | const responseJson = await response.json();
209 | if (responseJson.status !== 'success') {
210 | alert(responseJson.message);
211 | }
212 |
213 | return responseJson;
214 | }
215 |
216 | async function neutralVoteComment({ threadId, commentId }) {
217 | const response = await fetchWithAuth(`${BASE_URL}/threads/${threadId}/comments/${commentId}/neutral-vote`, {
218 | method: 'POST',
219 | });
220 | const responseJson = await response.json();
221 | if (responseJson.status !== 'success') {
222 | alert(responseJson.message);
223 | }
224 |
225 | return responseJson;
226 | }
227 |
228 | async function getLeaderboards() {
229 | const response = await fetch(`${BASE_URL}/leaderboards`);
230 | const responseJson = await response.json();
231 | const { status, message, data: { leaderboards } } = responseJson;
232 |
233 | if (status !== 'success') {
234 | throw new Error(message);
235 | }
236 |
237 | return leaderboards;
238 | }
239 |
240 | return {
241 | putAccessToken,
242 | getAccessToken,
243 | registerUser,
244 | login,
245 | getOwnProfile,
246 | getAllUsers,
247 | createThread,
248 | getAllThreads,
249 | getDetailThread,
250 | createComment,
251 | upVoteThread,
252 | downVoteThread,
253 | neutralVoteThread,
254 | upVoteComment,
255 | downVoteComment,
256 | neutralVoteComment,
257 | getLeaderboards,
258 | };
259 | })();
260 |
261 | export default api;
262 |
--------------------------------------------------------------------------------
/src/app/states/detailThread/reducer.test.js:
--------------------------------------------------------------------------------
1 | import detailThreadReducer from './reducer';
2 |
3 | /**
4 | * test scenario for detailThreadReducer
5 | *
6 | * - detailThreadReducers function
7 | * - should return the initial state when given by unknown action
8 | * - should return the detailThread when given by GET_DETAIL_THREAD action
9 | * - should return the detailThread with the new comment when given by CREATE_COMMENT action
10 | * - should return the detailThread with the toggled upvote
11 | * when given by UP_VOTE_DETAIL_THREAD action
12 | * - should return the detailThread with the toggled downvote
13 | * when given by DOWN_VOTE_DETAIL_THREAD action
14 | * - should return the detailThread with the toggled neutral vote
15 | * when given by NEUTRAL_VOTE_DETAIL_THREAD action
16 | * - should return the detailThread with the toggled upvote comment
17 | * when given by UP_VOTE_COMMENT_THREAD action
18 | * - should return the detailThread with the toggled downvote comment
19 | * when given by DOWN_VOTE_COMMENT_THREAD action
20 | * - should return the detailThread with the toggled neutral vote comment
21 | * when given by NEUTRAL_VOTE_COMMENT_THREAD action
22 | *
23 | */
24 |
25 | describe('detailThreadReducer function', () => {
26 | it('should return the initial state when given by unknown action', () => {
27 | // arrange
28 | const initialState = null;
29 | const action = { type: 'UNKNOWN' };
30 |
31 | // action
32 | const nextState = detailThreadReducer(initialState, action);
33 |
34 | // assert
35 | expect(nextState).toEqual(initialState);
36 | });
37 |
38 | it('should return the detailThread when given by GET_DETAIL_THREAD action', () => {
39 | // arrange
40 | const initialState = [];
41 | const action = {
42 | type: 'GET_DETAIL_THREAD',
43 | payload: {
44 | detailThread: {
45 | id: 'thread-1',
46 | title: 'Thread Pertama',
47 | body: 'Ini adalah thread pertama',
48 | category: 'General',
49 | createdAt: '2021-06-21T07:00:00.000Z',
50 | owner: {
51 | id: 'users-1',
52 | name: 'John Doe',
53 | avatar: 'https://generated-image-url.jpg',
54 | },
55 | upVotesBy: [],
56 | downVotesBy: [],
57 | comments: [],
58 | },
59 | },
60 | };
61 |
62 | // action
63 | const nextState = detailThreadReducer(initialState, action);
64 |
65 | // assert
66 | expect(nextState).toEqual(action.payload.detailThread);
67 | });
68 |
69 | it('should return the detailThread with the new comment when given by CREATE_COMMENT action', () => {
70 | // arrange
71 | const initialState = {
72 | id: 'thread-1',
73 | title: 'Thread Pertama',
74 | body: 'Ini adalah thread pertama',
75 | category: 'General',
76 | createdAt: '2021-06-21T07:00:00.000Z',
77 | owner: {
78 | id: 'users-1',
79 | name: 'John Doe',
80 | avatar: 'https://generated-image-url.jpg',
81 | },
82 | upVotesBy: [],
83 | downVotesBy: [],
84 | comments: [],
85 | };
86 |
87 | const action = {
88 | type: 'CREATE_COMMENT',
89 | payload: {
90 | comment: {
91 | id: 'comment-1',
92 | content: 'Ini adalah komentar pertama',
93 | createdAt: '2021-06-21T07:00:00.000Z',
94 | upVotesBy: [],
95 | downVotesBy: [],
96 | owner: {
97 | id: 'users-1',
98 | name: 'John Doe',
99 | email: 'john@example.com',
100 | },
101 | },
102 | },
103 | };
104 |
105 | // action
106 | const nextState = detailThreadReducer(initialState, action);
107 |
108 | // assert
109 | expect(nextState).toEqual({
110 | ...initialState, comments: [action.payload.comment],
111 | });
112 | });
113 |
114 | it('should return the detailThread with the toggled upvote when given by UP_VOTE_DETAIL_THREAD action', () => {
115 | // arrange
116 | const initialState = {
117 | id: 'thread-1',
118 | title: 'Thread Pertama',
119 | body: 'Ini adalah thread pertama',
120 | category: 'General',
121 | createdAt: '2021-06-21T07:00:00.000Z',
122 | owner: {
123 | id: 'users-1',
124 | name: 'John Doe',
125 | avatar: 'https://generated-image-url.jpg',
126 | },
127 | upVotesBy: [],
128 | downVotesBy: [],
129 | comments: [],
130 | };
131 |
132 | const action = {
133 | type: 'UP_VOTE_DETAIL_THREAD',
134 | payload: {
135 | userId: 'user-1',
136 | },
137 | };
138 |
139 | // action: upvote
140 | const nextState = detailThreadReducer(initialState, action);
141 |
142 | // assert
143 | expect(nextState).toEqual({
144 | ...initialState,
145 | upVotesBy: [action.payload.userId],
146 | downVotesBy: [],
147 | });
148 |
149 | // action: unupvote
150 | const nextState2 = detailThreadReducer(nextState, action);
151 |
152 | expect(nextState2).toEqual(initialState);
153 | });
154 |
155 | it('should return the detailThread with the toggled downvote when given by DOWN_VOTE_DETAIL_THREAD action', () => {
156 | // arrange
157 | const initialState = {
158 | id: 'thread-1',
159 | title: 'Thread Pertama',
160 | body: 'Ini adalah thread pertama',
161 | category: 'General',
162 | createdAt: '2021-06-21T07:00:00.000Z',
163 | owner: {
164 | id: 'users-1',
165 | name: 'John Doe',
166 | avatar: 'https://generated-image-url.jpg',
167 | },
168 | upVotesBy: [],
169 | downVotesBy: [],
170 | comments: [],
171 | };
172 |
173 | const action = {
174 | type: 'DOWN_VOTE_DETAIL_THREAD',
175 | payload: {
176 | userId: 'user-1',
177 | },
178 | };
179 |
180 | // action: downvote
181 | const nextState = detailThreadReducer(initialState, action);
182 |
183 | // assert
184 | expect(nextState).toEqual({
185 | ...initialState,
186 | upVotesBy: [],
187 | downVotesBy: [action.payload.userId],
188 | });
189 |
190 | // action: undownvote
191 | const nextState2 = detailThreadReducer(nextState, action);
192 |
193 | expect(nextState2).toEqual(initialState);
194 | });
195 |
196 | it('should return the detailThread with the neutral vote when given by NEUTRAL_VOTE_THREAD action', () => {
197 | // arrange
198 | const initialState = {
199 | id: 'thread-1',
200 | title: 'Thread Pertama',
201 | body: 'Ini adalah thread pertama',
202 | category: 'General',
203 | createdAt: '2021-06-21T07:00:00.000Z',
204 | owner: {
205 | id: 'users-1',
206 | name: 'John Doe',
207 | avatar: 'https://generated-image-url.jpg',
208 | },
209 | upVotesBy: [],
210 | downVotesBy: [],
211 | comments: [],
212 | };
213 |
214 | const action = {
215 | type: 'NEUTRAL_VOTE_DETAIL_THREAD',
216 | payload: {
217 | userId: 'user-1',
218 | },
219 | };
220 |
221 | // action
222 | const nextState = detailThreadReducer(initialState, action);
223 |
224 | // assert
225 | expect(nextState).toEqual({
226 | ...initialState,
227 | upVotesBy: [],
228 | downVotesBy: [],
229 | });
230 | });
231 |
232 | it('should return the detailThread with the toggled upvote comment when given by UP_VOTE_COMMENT_THREAD action', () => {
233 | // arrange
234 | const initialState = {
235 | id: 'thread-1',
236 | title: 'Thread Pertama',
237 | body: 'Ini adalah thread pertama',
238 | category: 'General',
239 | createdAt: '2021-06-21T07:00:00.000Z',
240 | owner: {
241 | id: 'users-1',
242 | name: 'John Doe',
243 | avatar: 'https://generated-image-url.jpg',
244 | },
245 | upVotesBy: [],
246 | downVotesBy: [],
247 | comments: [{
248 | id: 'comment-1',
249 | content: 'Ini adalah komentar pertama',
250 | createdAt: '2021-06-21T07:00:00.000Z',
251 | upVotesBy: [],
252 | downVotesBy: [],
253 | owner: {
254 | id: 'users-1',
255 | name: 'John Doe',
256 | email: 'john@example.com',
257 | },
258 | }],
259 | };
260 |
261 | const action = {
262 | type: 'UP_VOTE_COMMENT_THREAD',
263 | payload: {
264 | userId: 'user-1',
265 | commentId: 'comment-1',
266 | },
267 | };
268 |
269 | // action: upvote comment
270 | const nextState = detailThreadReducer(initialState, action);
271 |
272 | // assert
273 | expect(nextState).toEqual({
274 | ...initialState,
275 | comments: [{
276 | ...initialState.comments[0],
277 | upVotesBy: [action.payload.userId],
278 | downVotesBy: [],
279 | }],
280 | });
281 |
282 | // action: unupvote comment
283 | const nextState2 = detailThreadReducer(nextState, action);
284 |
285 | expect(nextState2).toEqual(initialState);
286 | });
287 |
288 | it('should return the detailThread with the toggled downvote comment when given by DOWN_VOTE_COMMENT_THREAD action', () => {
289 | // arrange
290 | const initialState = {
291 | id: 'thread-1',
292 | title: 'Thread Pertama',
293 | body: 'Ini adalah thread pertama',
294 | category: 'General',
295 | createdAt: '2021-06-21T07:00:00.000Z',
296 | owner: {
297 | id: 'users-1',
298 | name: 'John Doe',
299 | avatar: 'https://generated-image-url.jpg',
300 | },
301 | upVotesBy: [],
302 | downVotesBy: [],
303 | comments: [{
304 | id: 'comment-1',
305 | content: 'Ini adalah komentar pertama',
306 | createdAt: '2021-06-21T07:00:00.000Z',
307 | upVotesBy: [],
308 | downVotesBy: [],
309 | owner: {
310 | id: 'users-1',
311 | name: 'John Doe',
312 | email: 'john@example.com',
313 | },
314 | }],
315 | };
316 |
317 | const action = {
318 | type: 'DOWN_VOTE_COMMENT_THREAD',
319 | payload: {
320 | userId: 'user-1',
321 | commentId: 'comment-1',
322 | },
323 | };
324 |
325 | // action: downvote comment
326 | const nextState = detailThreadReducer(initialState, action);
327 |
328 | // assert
329 | expect(nextState).toEqual({
330 | ...initialState,
331 | comments: [{
332 | ...initialState.comments[0],
333 | upVotesBy: [],
334 | downVotesBy: [action.payload.userId],
335 | }],
336 | });
337 |
338 | // action: undownvote comment
339 | const nextState2 = detailThreadReducer(nextState, action);
340 |
341 | expect(nextState2).toEqual(initialState);
342 | });
343 |
344 | it('should return the detailThread with the toggled neutral vote comment when given by NEUTRAL_VOTE_COMMENT_THREAD action', () => {
345 | // arrange
346 | const initialState = {
347 | id: 'thread-1',
348 | title: 'Thread Pertama',
349 | body: 'Ini adalah thread pertama',
350 | category: 'General',
351 | createdAt: '2021-06-21T07:00:00.000Z',
352 | owner: {
353 | id: 'users-1',
354 | name: 'John Doe',
355 | avatar: 'https://generated-image-url.jpg',
356 | },
357 | upVotesBy: [],
358 | downVotesBy: [],
359 | comments: [{
360 | id: 'comment-1',
361 | content: 'Ini adalah komentar pertama',
362 | createdAt: '2021-06-21T07:00:00.000Z',
363 | upVotesBy: [],
364 | downVotesBy: [],
365 | owner: {
366 | id: 'users-1',
367 | name: 'John Doe',
368 | email: 'john@example.com',
369 | },
370 | }],
371 | };
372 |
373 | const action = {
374 | type: 'NEUTRAL_VOTE_COMMENT_THREAD',
375 | payload: {
376 | userId: 'user-1',
377 | commentId: 'comment-1',
378 | },
379 | };
380 |
381 | // action
382 | const nextState = detailThreadReducer(initialState, action);
383 |
384 | // assert
385 | expect(nextState).toEqual({
386 | ...initialState,
387 | comments: [{
388 | ...initialState.comments[0],
389 | upVotesBy: [],
390 | downVotesBy: [],
391 | }],
392 | });
393 | });
394 | });
395 |
--------------------------------------------------------------------------------