Project in Action - Jobster
Find the App Useful? You can always buy me a coffee
npm run install && npm start
- visit url http://localhost:3000/
npx create-react-app myApp
npx create-react-app@latest myApp
- set editor/browser side by side
- copy/paste assets and readme from complete project
- in src remove
- App.css
- App.test.js
- logo.svg
- reportWebVitals.js
- setupTests.js
- fix App.js and index.js
- change title in public/index.html
- replace favicon.ico in public
- resource Generate Favicons
- CSS in JS (styled-components)
- saves times on the setup
- less lines of css
- speeds up the development
- normalize.css
- small CSS file that provides cross-browser consistency in the default styling of HTML elements.
- normalize docs
npm install normalize.css
- import 'normalize.css' in index.js
- SET BEFORE 'index.css'
- replace contents of index.css
- if any questions about normalize or specific styles
- Coding Addict - Default Starter Video
- Repo - Default Starter Repo
- zoom level 175%
- markdown preview extension
- get something on the screen
- react router and styled components right after
- create pages directory in the source
- for now Landing.js
- create component (snippets extension)
- setup basic return
<h4>Landing Page<h4>
- import logo.svg and main.svg
- import Landing in App.js and render
- Landing.js
const Landing = () => {
return (
<main>
<nav>
<img src={logo} alt='jobster logo' className='logo' />
</nav>
<div className='container page'>
{/* info */}
<div className='info'>
<h1>
job <span>tracking</span> app
</h1>
<p>some text</p>
<button className='btn btn-hero'>Login/Register</button>
</div>
<img src={main} alt='job hunt' className='img main-img' />
</div>
</main>
);
};
export default Landing;
- CSS in JS
- Styled Components
- have logic and styles in component
- no name collisions
- apply javascript logic
- Styled Components Docs
- Styled Components Course
npm install styled-components
import styled from 'styled-components';
const El = styled.el`
// styles go here
`;
-
element can be any html element (div,button,section, etc)
-
no name collisions, since unique class
-
vscode-styled-components extension
-
colors and bugs
-
style entire react component
const Wrapper = styled.el``;
const Component = () => {
return (
<Wrapper>
<h1> Component</h1>
</Wrapper>
);
};
- assets/wrappers
- only responsible for styling
- logo built in Figma
- Cool Images
- create components folder in source
- create Logo.js
- move import and image logic
- export as default
- utilize index.js
Logo.js
import logo from '../assets/images/logo.svg';
const Logo = () => {
return <img src={logo} alt='jobify' className='logo' />;
};
export default Logo;
- create Error, Register, Dashboard pages
- basic return
- create index.js
- import all the pages
- export one by one
- basically the same, as in components
- import App.js
-
Please Reference React Router 6 Section
npm install react-router-dom@6
- import three components from router
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Error, Landing, Register, Dashboard } from './pages';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path='/' element={<Dashboard />} />
<Route path='landing' element={<Landing />} />
<Route path='register' element={<Register />} />
<Route path='*' element={<Error />} />
</Routes>
</BrowserRouter>
);
}
- go to Landing.js
import { Link } from 'react-router-dom';
return (
<Link to='/register' className='btn btn-hero'>
Login / Register
</Link>
);
import { Link } from 'react-router-dom';
import img from '../assets/images/not-found.svg';
import Wrapper from '../assets/wrappers/ErrorPage';
return (
<Wrapper className='full-page'>
<div>
<img src={img} alt='not found' />
<h3>text</h3>
<p>text</p>
<Link to='/'>back home</Link>
</div>
</Wrapper>
);
- use while developing
- only sparingly while recording
- better picture
- messes with flow
- just my preference
- still use them, just not all the time
import { useState, useEffect } from 'react';
import { Logo } from '../components';
import Wrapper from '../assets/wrappers/RegisterPage';
// redux toolkit and useNavigate later
const initialState = {
name: '',
email: '',
password: '',
isMember: true,
};
// if possible prefer local state
// global state
function Register() {
const [values, setValues] = useState(initialState);
// redux toolkit and useNavigate later
const handleChange = (e) => {
console.log(e.target);
};
const onSubmit = (e) => {
e.preventDefault();
console.log(e.target);
};
return (
<Wrapper className='full-page'>
<form className='form' onSubmit={onSubmit}>
<Logo />
<h3>Login</h3>
{/* name field */}
<div className='form-row'>
<label htmlFor='name' className='form-label'>
name
</label>
<input
type='text'
value={values.name}
name='name'
onChange={handleChange}
className='form-input'
/>
</div>
<button type='submit' className='btn btn-block'>
submit
</button>
</form>
</Wrapper>
);
}
- index.js
import React from 'react';
import { createRoot } from 'react-dom/client';
import 'normalize.css';
import './index.css';
import App from './App';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(<App tab='home' />);
- create FormRow.js in components
- setup import/export
- setup one for email and password
- hint "type,name,value"
const FormRow = ({ type, name, value, handleChange, labelText }) => {
return (
<div className='form-row'>
<label htmlFor={name} className='form-label'>
{labelText || name}
</label>
<input
type={type}
value={value}
name={name}
onChange={handleChange}
className='form-input'
/>
</div>
);
};
export default FormRow;
const toggleMember = () => {
setValues({ ...values, isMember: !values.isMember });
};
return (
<Wrapper>
{/* control h3 */}
<h3>{values.isMember ? 'Login' : 'Register'}</h3>
{/* toggle name */}
{!values.isMember && (
<FormRow
type='text'
name='name'
value={values.name}
handleChange={handleChange}
/>
)}
{/* right after submit btn */}
{/* toggle button */}
<p>
{values.isMember ? 'Not a member yet?' : 'Already a member?'}
<button type='button' onClick={toggleMember} className='member-btn'>
{values.isMember ? 'Register' : 'Login'}
</button>
</p>
</Wrapper>
);
Register.js
const handleChange = (e) => {
const name = e.target.name;
const value = e.target.value;
console.log(`${name}:${value}`);
setValues({ ...values, [name]: value });
};
const onSubmit = (e) => {
e.preventDefault();
const { name, email, password, isMember } = values;
if (!email || !password || (!isMember && !name)) {
consol.log('Please Fill Out All Fields');
return;
}
};
npm install --save react-toastify
App.js
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
return </Routes>
<ToastContainer />
<BrowserRouter>
Register.js
import { toast } from 'react-toastify';
if (!email || !password || (!isMember && !name)) {
toast.error('Please Fill Out All Fields');
return;
}
- modifications
position
index.css
.Toastify__toast {
text-transform: capitalize;
}
- features/user/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { toast } from 'react-toastify';
const initialState = {
isLoading: false,
user: null,
};
const userSlice = createSlice({
name: 'user',
initialState,
});
export default userSlice.reducer;
- create store.js
import { configureStore } from '@reduxjs/toolkit';
import userSlice from './features/user/userSlice';
export const store = configureStore({
reducer: {
user: userSlice,
},
});
- index.js
import { store } from './store';
import { Provider } from 'react-redux';
root.render(
<Provider store={store}>
<App tab='home' />
</Provider>
);
npm install @reduxjs/toolkit react-redux
- userSlice.js
export const registerUser = createAsyncThunk(
'user/registerUser',
async (user, thunkAPI) => {
console.log(`Register User : ${user}`)
);
export const loginUser = createAsyncThunk(
'user/loginUser',
async (user, thunkAPI) => {
console.log(`Login User : ${user}`)
});
- Register.js
import { useSelector, useDispatch } from 'react-redux';
import { loginUser, registerUser } from '../features/user/userSlice';
const Register = () => {
const dispatch = useDispatch();
const { isLoading, user } = useSelector((store) => store.user);
const onSubmit = (e) => {
e.preventDefault();
const { name, email, password, isMember } = values;
if (!email || !password || (!isMember && !name)) {
toast.error('Please Fill Out All Fields');
return;
}
if (isMember) {
dispatch(loginUser({ email: email, password: password }));
return;
}
dispatch(registerUser({ name, email, password }));
};
- GET - get resources from the server
- POST - submit resource to the server
- PUT/PATCH - modify resource on the server
- DELETE - delete resource form the server
// GET
axios.get(url, options);
// POST
axios.post(url, resource, options);
// PATCH
axios.patch(url, resource, options);
// DELETE
axios.delete(url, options);
npm install axios
-
Root URL
-
NODE COURSE
-
https://jobify-prod.herokuapp.com/api/v1/toolkit/auth/register
-
POST /auth/register
-
{name:'john',email:'john@gmail.com',password:'secret'}
-
sends back the user object with token
- POST /auth/testingRegister
- {name:'john',email:'john@gmail.com',password:'secret'}
- sends back the user object with token
- POST /auth/login
- {email:'john@gmail.com',password:'secret'}
- sends back the user object with token
- PATCH /auth/updateUser
- { email:'john@gmail.com', name:'john', lastName:'smith', location:'my location' }
- sends back the user object with token
- utils/axios.js
import axios from 'axios';
const customFetch = axios.create({
baseURL: 'https://jobify-prod.herokuapp.com/api/v1/toolkit',
});
export default customFetch;
userSlice.js
import customFetch from '../../utils/axios';
export const registerUser = createAsyncThunk(
'user/registerUser',
async (user, thunkAPI) => {
try {
const resp = await customFetch.post('/auth/testingRegister', user);
console.log(resp);
} catch (error) {
console.log(error.response);
}
}
);
userSlice.js
export const registerUser = createAsyncThunk(
'user/registerUser',
async (user, thunkAPI) => {
try {
const resp = await customFetch.post('/auth/register', user);
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
}
)
extraReducers: {
[registerUser.pending]: (state) => {
state.isLoading = true;
},
[registerUser.fulfilled]: (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
toast.success(`Hello There ${user.name}`);
},
[registerUser.rejected]: (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
}
}
extraReducers: (builder) => {
builder
.addCase(registerUser.pending, (state) => {
state.isLoading = true;
})
.addCase(registerUser.fulfilled, (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
addUserToLocalStorage(user);
toast.success(`Hello There ${user.name}`);
})
.addCase(registerUser.rejected, (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
})
.addCase(loginUser.pending, (state) => {
state.isLoading = true;
})
.addCase(loginUser.fulfilled, (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
addUserToLocalStorage(user);
toast.success(`Welcome Back ${user.name}`);
})
.addCase(loginUser.rejected, (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
})
.addCase(updateUser.pending, (state) => {
state.isLoading = true;
})
.addCase(updateUser.fulfilled, (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
addUserToLocalStorage(user);
toast.success(`User Updated!`);
})
.addCase(updateUser.rejected, (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
})
.addCase(clearStore.rejected, () => {
toast.error('There was an error..');
});
},
userSlice.js
export const loginUser = createAsyncThunk(
'user/loginUser',
async (user, thunkAPI) => {
try {
const resp = await customFetch.post('/auth/login', user);
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
}
extraReducers: {
[loginUser.pending]: (state) => {
state.isLoading = true;
},
[loginUser.fulfilled]: (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
toast.success(`Welcome Back ${user.name}`);
},
[loginUser.rejected]: (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
}
}
);
- utils/localStorage.js
export const addUserToLocalStorage = (user) => {
localStorage.setItem('user', JSON.stringify(user));
};
export const removeUserFromLocalStorage = () => {
localStorage.removeItem('user');
};
export const getUserFromLocalStorage = () => {
const result = localStorage.getItem('user');
const user = result ? JSON.parse(result) : null;
return user;
};
- invoke getUserFromLocalStorage when app loads (set it equal to user)
const initialState = {
isLoading: false,
user: getUserFromLocalStorage(),
};
[registerUser.fulfilled]: (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
addUserToLocalStorage(user);
toast.success(`Hello There ${user.name}`);
},
[loginUser.fulfilled]: (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
addUserToLocalStorage(user);
toast.success(`Welcome Back ${user.name}`);
},
Register.js
import { useNavigate } from 'react-router-dom';
const Register = () => {
const navigate = useNavigate();
useEffect(() => {
if (user) {
setTimeout(() => {
navigate('/');
}, 3000);
}
}, [user, navigate]);
};
- remove Dashboard.js
- create Dashboard Folder
- create Stats, Profile, AddJob, AllJobs, SharedLayout,
- create index.js and setup import/export
App.js
import {
AllJobs,
Profile,
SharedLayout,
Stats,
AddJob,
} from './pages/dashboard';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path='/' element={<SharedLayout />}>
<Route index element={<Stats />} />
<Route path='all-jobs' element={<AllJobs />} />
<Route path='add-job' element={<AddJob />} />
<Route path='profile' element={<Profile />} />
</Route>
<Route path='register' element={<Register />} />
<Route path='landing' element={<Landing />} />
<Route path='*' element={<Error />} />
</Routes>
<ToastContainer position='top-center' />
</BrowserRouter>
);
}
- create Navbar, SmallSidebar, BigSidebar in components
- import Wrappers from assets/wrappers
- simple return
- import/export
SharedLayout.js;
import { Outlet } from 'react-router-dom';
import { Navbar, SmallSidebar, BigSidebar } from '../../components';
import Wrapper from '../../assets/wrappers/SharedLayout';
const SharedLayout = () => {
return (
<>
<Wrapper>
<main className='dashboard'>
<SmallSidebar />
<BigSidebar />
<div>
<Navbar />
<div className='dashboard-page'>
<Outlet />
</div>
</div>
</main>
</Wrapper>
</>
);
};
export default SharedLayout;
- import Wrappers in BigSidebar,SmallSidebar and Navbar
npm install react-icons
Navbar.js
import Wrapper from '../assets/wrappers/Navbar'
import {FaHome} from 'react-icons/fa'
const Navbar = () => {
return (
<Wrapper>
<h4>navbar</h4>
<FaHome>
</Wrapper>
)
}
export default Navbar
Navbar.js;
import Wrapper from '../assets/wrappers/Navbar';
import { FaAlignLeft, FaUserCircle, FaCaretDown } from 'react-icons/fa';
import Logo from './Logo';
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
const Navbar = () => {
const { user } = useSelector((store) => store.user);
const dispatch = useDispatch();
return (
<Wrapper>
<div className='nav-center'>
<button
type='button'
className='toggle-btn'
onClick={() => console.log('toggle sidebar')}
>
<FaAlignLeft />
</button>
<div>
<Logo />
<h3 className='logo-text'>dashboard</h3>
</div>
<div className='btn-container'>
<button
type='button'
className='btn'
onClick={() => console.log('toggle logout dropdown')}
>
<FaUserCircle />
{user?.name}
<FaCaretDown />
</button>
<div className='dropdown show-dropdown'>
<button
type='button'
className='dropdown-btn'
onClick={() => {
console.log('logout user');
}}
>
logout
</button>
</div>
</div>
</div>
</Wrapper>
);
};
export default Navbar;
userSlice.js
const initialState = {
isLoading: false,
isSidebarOpen: false,
user: getUserFromLocalStorage(),
};
reducers: {
toggleSidebar: (state) => {
state.isSidebarOpen = !state.isSidebarOpen;
},
},
export const { toggleSidebar } = userSlice.actions;
Navbar.js
import { toggleSidebar } from '../features/user/userSlice';
const toggle = () => {
dispatch(toggleSidebar());
};
<button type='button' className='toggle-btn' onClick={toggle}>
<FaAlignLeft />
</button>;
Navbar.js
const [showLogout, setShowLogout] = useState(false)
<div className='btn-container'>
<button className='btn' onClick={() => setShowLogout(!showLogout)}>
<FaUserCircle />
{user.name}
<FaCaretDown />
</button>
<div className={showLogout ? 'dropdown show-dropdown' : 'dropdown'}>
<button onClick={() => console.log('logout user')} className='dropdown-btn'>
logout
</button>
</div>
</div>
userSlice.js
reducers: {
logoutUser: (state) => {
state.user = null;
state.isSidebarOpen = false;
removeUserFromLocalStorage();
},
toggleSidebar: (state) => {
state.isSidebarOpen = !state.isSidebarOpen;
},
},
export const { logoutUser, toggleSidebar } = userSlice.actions;
Navbar.js
import { toggleSidebar, logoutUser } from '../features/user/userSlice';
<div className={showLogout ? 'dropdown show-dropdown' : 'dropdown'}>
<button
type='button'
className='dropdown-btn'
onClick={() => {
dispatch(logoutUser());
}}
>
logout
</button>
</div>;
- pages/ProtectedRoute.js
import { Navigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
const ProtectedRoute = ({ children }) => {
const { user } = useSelector((store) => store.user);
if (!user) {
return <Navigate to='/landing' />;
}
return children;
};
export default ProtectedRoute;
App.js
<Route
path='/'
element={
<ProtectedRoute>
<SharedLayout />
</ProtectedRoute>
}
>
...
</Route>
SmallSidebar.js;
import Wrapper from '../assets/wrappers/SmallSidebar';
import { FaTimes } from 'react-icons/fa';
import { NavLink } from 'react-router-dom';
import Logo from './Logo';
import { useSelector, useDispatch } from 'react-redux';
export const SmallSidebar = () => {
return (
<Wrapper>
<div className='sidebar-container show-sidebar'>
<div className='content'>
<button className='close-btn' onClick={() => console.log('toggle')}>
<FaTimes />
</button>
<header>
<Logo />
</header>
<div className='nav-links'>nav links</div>
</div>
</div>
</Wrapper>
);
};
export default SmallSidebar;
SmallSidebar.js;
import { toggleSidebar } from '../features/user/userSlice';
const { isSidebarOpen } = useSelector((store) => store.user);
const dispatch = useDispatch();
const toggle = () => {
dispatch(toggleSidebar());
};
return (
<div className={isSidebarOpen ? 'sidebar-container show-sidebar' : 'sidebar-container'}>
<div className='content'>
<button type='button' className='close-btn' onClick={toggle}>
<FaTimes />
</button>
);
- create utils/links.js
import { IoBarChartSharp } from 'react-icons/io5';
import { MdQueryStats } from 'react-icons/md';
import { FaWpforms } from 'react-icons/fa';
import { ImProfile } from 'react-icons/im';
const links = [
{
id: 1,
text: 'stats',
path: '/',
icon: <IoBarChartSharp />,
},
{
id: 2,
text: 'all jobs',
path: 'all-jobs',
icon: <MdQueryStats />,
},
{
id: 3,
text: 'add job',
path: 'add-job',
icon: <FaWpforms />,
},
{
id: 4,
text: 'profile',
path: 'profile',
icon: <ImProfile />,
},
];
export default links;
SmallSidebar.js
import { NavLink } from 'react-router-dom';
return (
<div className='nav-links'>
{links.map((link) => {
const { text, path, id, icon } = link;
return (
<NavLink
to={path}
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
key={id}
onClick={toggle}
>
<span className='icon'>{icon}</span>
{text}
</NavLink>
);
})}
</div>
);
<NavLink
to={path}
key={id}
onClick={toggleSidebar}
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'}
end
>
- create components/NavLinks.js
- styles still set from Wrapper
- also can setup in links.js, preference
import { NavLink } from 'react-router-dom';
import links from '../utils/links';
const NavLinks = ({ toggleSidebar }) => {
return (
<div className='nav-links'>
{links.map((link) => {
const { text, path, id, icon } = link;
return (
<NavLink
to={path}
key={id}
onClick={toggleSidebar}
className={({ isActive }) =>
isActive ? 'nav-link active' : 'nav-link'
}
>
<span className='icon'>{icon}</span>
{text}
</NavLink>
);
})}
</div>
);
};
export default NavLinks;
SmallSidebar.js;
import NavLinks from './NavLinks';
return <NavLinks toggleSidebar={toggle} />;
import NavLinks from './NavLinks';
import Logo from '../components/Logo';
import Wrapper from '../assets/wrappers/BigSidebar';
import { useSelector } from 'react-redux';
const BigSidebar = () => {
const { isSidebarOpen } = useSelector((store) => store.user);
return (
<Wrapper>
<div
className={
isSidebarOpen
? 'sidebar-container '
: 'sidebar-container show-sidebar'
}
>
<div className='content'>
<header>
<Logo />
</header>
<NavLinks />
</div>
</div>
</Wrapper>
);
};
export default BigSidebar;
Profile.js
import { useState } from 'react';
import { FormRow } from '../../components';
import Wrapper from '../../assets/wrappers/DashboardFormPage';
import { useDispatch, useSelector } from 'react-redux';
import { toast } from 'react-toastify';
const Profile = () => {
const { isLoading, user } = useSelector((store) => store.user);
const dispatch = useDispatch();
const [userData,setUserData] = useState({
name:user?.name ||''
email:user?.email ||''
lastName:user?.lastName ||''
location:user?.location ||''
})
const handleSubmit = (e) => {
e.preventDefault();
const { name, email, lastName, location } = userData;
if (!name || !email || !lastName || !location) {
toast.error('Please Fill Out All Fields');
return;
}
};
const handleChange = (e) =>{
const name = e.target.name
const value = e.target.value
setUserData({...userData,[name]:value})
}
return (
<Wrapper>
<form className='form' onSubmit={handleSubmit}>
<h3>profile</h3>
<div className='form-center'>
<FormRow
type='text'
name='name'
value={userData.name}
handleChange={handleChange}
/>
<FormRow
type='text'
labelText='last name'
name='lastName'
value={userData.lastName}
handleChange={handleChange}
/>
<FormRow
type='email'
name='email'
value={userData.email}
handleChange={handleChange}
/>
<FormRow
type='text'
name='location'
value={userData.location}
handleChange={handleChange}
/>
<button className='btn btn-block' type='submit' disabled={isLoading}>
{isLoading ? 'Please Wait...' : 'save changes'}
</button>
</div>
</form>
</Wrapper>
);
};
export default Profile;
- PATCH /auth/updateUser
- { email:'john@gmail.com', name:'john', lastName:'smith', location:'my location' }
- authorization header : 'Bearer token'
- sends back the user object with token
userSlice.js
export const updateUser = createAsyncThunk(
'user/updateUser',
async (user, thunkAPI) => {
try {
const resp = await customFetch.patch('/auth/updateUser', user, {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
});
return resp.data;
} catch (error) {
console.log(error.response);
return thunkAPI.rejectWithValue(error.response.data.msg);
}
}
);
// extra reducers
[updateUser.pending]: (state) => {
state.isLoading = true;
},
[updateUser.fulfilled]: (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
addUserToLocalStorage(user);
toast.success('User Updated');
},
[updateUser.rejected]: (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
},
Profile.js
import { updateUser } from '../../features/user/userSlice';
const handleSubmit = (e) => {
e.preventDefault();
const { name, email, lastName, location } = userData;
if (!name || !email || !lastName || !location) {
toast.error('Please Fill Out All Fields');
return;
}
dispatch(updateUser({ name, email, lastName, location }));
};
userSlice.js
export const updateUser = createAsyncThunk(
'user/updateUser',
async (user, thunkAPI) => {
try {
const resp = await customFetch.patch('/auth/updateUser', user, {
headers: {
// authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
authorization: `Bearer `,
},
});
return resp.data;
} catch (error) {
// console.log(error.response);
if (error.response.status === 401) {
thunkAPI.dispatch(logoutUser());
return thunkAPI.rejectWithValue('Unauthorized! Logging Out...');
}
return thunkAPI.rejectWithValue(error.response.data.msg);
}
}
);
// logoutUser
logoutUser: (state) => {
state.user = null;
state.isSidebarOpen = false;
toast.success('Logout Successful!');
removeUserFromLocalStorage();
},
- features/user/userThunk.js
import customFetch from '../../utils/axios';
import { logoutUser } from './userSlice';
export const registerUserThunk = async (url, user, thunkAPI) => {
try {
const resp = await customFetch.post(url, user);
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
};
export const loginUserThunk = async (url, user, thunkAPI) => {
try {
const resp = await customFetch.post(url, user);
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
};
export const updateUserThunk = async (url, user, thunkAPI) => {
try {
const resp = await customFetch.patch(url, user, {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
});
return resp.data;
} catch (error) {
// console.log(error.response);
if (error.response.status === 401) {
thunkAPI.dispatch(logoutUser());
return thunkAPI.rejectWithValue('Unauthorized! Logging Out...');
}
return thunkAPI.rejectWithValue(error.response.data.msg);
}
};
userSlice.js
import {
loginUserThunk,
registerUserThunk,
updateUserThunk,
} from './userThunk';
export const registerUser = createAsyncThunk(
'user/registerUser',
async (user, thunkAPI) => {
return registerUserThunk('/auth/register', user, thunkAPI);
}
);
export const loginUser = createAsyncThunk(
'user/loginUser',
async (user, thunkAPI) => {
return loginUserThunk('/auth/login', user, thunkAPI);
}
);
export const updateUser = createAsyncThunk(
'user/updateUser',
async (user, thunkAPI) => {
return updateUserThunk('/auth/updateUser', user, thunkAPI);
}
);
- features/job/jobSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { toast } from 'react-toastify';
import customFetch from '../../utils/axios';
import { getUserFromLocalStorage } from '../../utils/localStorage';
const initialState = {
isLoading: false,
position: '',
company: '',
jobLocation: '',
jobTypeOptions: ['full-time', 'part-time', 'remote', 'internship'],
jobType: 'full-time',
statusOptions: ['interview', 'declined', 'pending'],
status: 'pending',
isEditing: false,
editJobId: '',
};
const jobSlice = createSlice({
name: 'job',
initialState,
});
export default jobSlice.reducer;
store.js
import jobSlice from './features/job/jobSlice';
export const store = configureStore({
reducer: {
user: userSlice,
job: jobSlice,
},
});
AddJob.js
import { FormRow } from '../../components';
import Wrapper from '../../assets/wrappers/DashboardFormPage';
import { useSelector, useDispatch } from 'react-redux';
import { toast } from 'react-toastify';
const AddJob = () => {
const {
isLoading,
position,
company,
jobLocation,
jobType,
jobTypeOptions,
status,
statusOptions,
isEditing,
editJobId,
} = useSelector((store) => store.job);
const dispatch = useDispatch();
const handleSubmit = (e) => {
e.preventDefault();
if (!position || !company || !jobLocation) {
toast.error('Please Fill Out All Fields');
return;
}
};
const handleJobInput = (e) => {
const name = e.target.name;
const value = e.target.value;
};
return (
<Wrapper>
<form className='form'>
<h3>{isEditing ? 'edit job' : 'add job'}</h3>
<div className='form-center'>
{/* position */}
<FormRow
type='text'
name='position'
value={position}
handleChange={handleJobInput}
/>
{/* company */}
<FormRow
type='text'
name='company'
value={company}
handleChange={handleJobInput}
/>
{/* location */}
<FormRow
type='text'
labelText='job location'
name='jobLocation'
value={jobLocation}
handleChange={handleJobInput}
/>
{/* job status */}
{/* job type */}
{/* btn container */}
<div className='btn-container'>
<button
type='button'
className='btn btn-block clear-btn'
onClick={() => console.log('clear values')}
>
clear
</button>
<button
type='submit'
className='btn btn-block submit-btn'
onClick={handleSubmit}
disabled={isLoading}
>
submit
</button>
</div>
</div>
</form>
</Wrapper>
);
};
export default AddJob;
// job status
return (
<div className='form-row'>
<label htmlFor='status' className='form-label'>
status
</label>
<select
name='status'
value={status}
onChange={handleJobInput}
className='form-select'
>
{statusOptions.map((itemValue, index) => {
return (
<option key={index} value={itemValue}>
{itemValue}
</option>
);
})}
</select>
</div>
);
- FormRowSelect.js
const FormRowSelect = ({ labelText, name, value, handleChange, list }) => {
return (
<div className='form-row'>
<label htmlFor={name} className='form-label'>
{labelText || name}
</label>
<select
name={name}
value={value}
id={name}
onChange={handleChange}
className='form-select'
>
{list.map((itemValue, index) => {
return (
<option key={index} value={itemValue}>
{itemValue}
</option>
);
})}
</select>
</div>
);
};
export default FormRowSelect;
AddJob.js
/* job status */
<FormRowSelect
name='status'
value={status}
handleChange={handleJobInput}
list={statusOptions}
/>
<FormRowSelect
name='jobType'
labelText='job type'
value={jobType}
handleChange={handleJobInput}
list={jobTypeOptions}
/>
jobSlice.js
// reducers
handleChange: (state, { payload: { name, value } }) => {
state[name] = value;
},
export const { handleChange } = jobSlice.actions;
AddJob.js
import { handleChange } from '../../features/job/jobSlice';
const handleJobInput = (e) => {
const name = e.target.name;
const value = e.target.value;
dispatch(handleChange({ name, value }));
};
// reducers
clearValues: () => {
return {
...initialState
};
return initialState
},
export const { handleChange, clearValues } = jobSlice.actions;
AddJob.js
import { clearValues, handleChange } from '../../features/job/jobSlice';
return (
<button
type='button'
className='btn btn-block clear-btn'
onClick={() => dispatch(clearValues())}
>
clear
</button>
);
- POST /jobs
- { position:'position', company:'company', jobLocation:'location', jobType:'full-time', status:'pending' }
- authorization header : 'Bearer token'
- sends back the job object
export const createJob = createAsyncThunk(
'job/createJob',
async (job, thunkAPI) => {
try {
const resp = await customFetch.post('/jobs', job, {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
});
thunkAPI.dispatch(clearValues());
return resp.data;
} catch (error) {
// basic setup
return thunkAPI.rejectWithValue(error.response.data.msg);
// logout user
if (error.response.status === 401) {
thunkAPI.dispatch(logoutUser());
return thunkAPI.rejectWithValue('Unauthorized! Logging Out...');
}
return thunkAPI.rejectWithValue(error.response.data.msg);
}
}
);
// extra reducers
extraReducers: {
[createJob.pending]: (state) => {
state.isLoading = true;
},
[createJob.fulfilled]: (state, action) => {
state.isLoading = false;
toast.success('Job Created');
},
[createJob.rejected]: (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
},
}
AddJob.js
import {
clearValues,
handleChange,
createJob,
} from '../../features/job/jobSlice';
const handleSubmit = (e) => {
e.preventDefault();
if (!position || !company || !jobLocation) {
toast.error('Please Fill Out All Fields');
return;
}
dispatch(createJob({ position, company, jobLocation, jobType, status }));
};
AddJob.js
const { user } = useSelector((store) => store.user);
useEffect(() => {
// eventually will check for isEditing
if (!isEditing) {
dispatch(handleChange({ name: 'jobLocation', value: user.location }));
}
}, []);
jobSlice.js
// reducers
clearValues: () => {
return {
...initialState,
jobLocation: getUserFromLocalStorage()?.location || '',
};
},
userSlice.js
logoutUser: (state,{payload}) => {
state.user = null;
state.isSidebarOpen = false;
removeUserFromLocalStorage();
if(payload){
toast.success(payload)
}
},
Navbar.js
<button
type='button'
className='dropdown-btn'
onClick={() => dispatch(logoutUser('Logging out...'))}
>
logout
</button>
- features/allJobs/allJobsSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { toast } from 'react-toastify';
import customFetch from '../../utils/axios';
const initialFiltersState = {
search: '',
searchStatus: 'all',
searchType: 'all',
sort: 'latest',
sortOptions: ['latest', 'oldest', 'a-z', 'z-a'],
};
const initialState = {
isLoading: false,
jobs: [],
totalJobs: 0,
numOfPages: 1,
page: 1,
stats: {},
monthlyApplications: [],
...initialFiltersState,
};
const allJobsSlice = createSlice({
name: 'allJobs',
initialState,
});
export default allJobsSlice.reducer;
store.js
import { configureStore } from '@reduxjs/toolkit';
import userSlice from './features/user/userSlice';
import jobSlice from './features/job/jobSlice';
import allJobsSlice from './features/allJobs/allJobsSlice';
export const store = configureStore({
reducer: {
user: userSlice,
job: jobSlice,
allJobs: allJobsSlice,
},
});
- create
- components/SearchContainer.js
- components/JobsContainer.js
- components/Job.js
- import/export
AllJobs.js
import { JobsContainer, SearchContainer } from '../../components';
const AllJobs = () => {
return (
<>
<SearchContainer />
<JobsContainer />
</>
);
};
export default AllJobs;
import { useEffect } from 'react';
import Job from './Job';
import Wrapper from '../assets/wrappers/JobsContainer';
import { useSelector, useDispatch } from 'react-redux';
const JobsContainer = () => {
const { jobs, isLoading } = useSelector((store) => store.allJobs);
const dispatch = useDispatch();
if (isLoading) {
return (
<Wrapper>
<h2>Loading...</h2>
</Wrapper>
);
}
if (jobs.length === 0) {
return (
<Wrapper>
<h2>No jobs to display...</h2>
</Wrapper>
);
}
return (
<Wrapper>
<h5>jobs info</h5>
<div className='jobs'>
{jobs.map((job) => {
return <Job key={job._id} {...job} />;
})}
</div>
</Wrapper>
);
};
export default JobsContainer;
Loading.js
const Loading = ({ center }) => {
return <div className={center ? 'loading loading-center' : 'loading'}></div>;
};
export default Loading;
JobsContainer.js
import Loading from './Loading';
if (isLoading) {
return <Loading center />;
}
- GET /jobs
- authorization header : 'Bearer token'
- returns {jobs:[],totalJobs:number, numOfPages:number }
allJobsSlice.js
export const getAllJobs = createAsyncThunk(
'allJobs/getJobs',
async (_, thunkAPI) => {
let url = `/jobs`;
try {
const resp = await customFetch.get(url, {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
});
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
}
);
// extra reducers
extraReducers: {
[getAllJobs.pending]: (state) => {
state.isLoading = true;
},
[getAllJobs.fulfilled]: (state, { payload }) => {
state.isLoading = false;
state.jobs = payload.jobs;
},
[getAllJobs.rejected]: (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
},
}
JobsContainer.js
import { getAllJobs } from '../features/allJobs/allJobsSlice';
useEffect(() => {
dispatch(getAllJobs());
}, []);
Job.js
import { FaLocationArrow, FaBriefcase, FaCalendarAlt } from 'react-icons/fa';
import { Link } from 'react-router-dom';
import Wrapper from '../assets/wrappers/Job';
import { useDispatch } from 'react-redux';
const Job = ({
_id,
position,
company,
jobLocation,
jobType,
createdAt,
status,
}) => {
const dispatch = useDispatch();
return (
<Wrapper>
<header>
<div className='main-icon'>{company.charAt(0)}</div>
<div className='info'>
<h5>{position}</h5>
<p>{company}</p>
</div>
</header>
<div className='content'>
<div className='content-center'>
<h4>more content</h4>
<div className={`status ${status}`}>{status}</div>
</div>
<footer>
<div className='actions'>
<Link
to='/add-job'
className='btn edit-btn'
onClick={() => {
console.log('edit job');
}}
>
Edit
</Link>
<button
type='button'
className='btn delete-btn'
onClick={() => {
console.log('delete job');
}}
>
Delete
</button>
</div>
</footer>
</div>
</Wrapper>
);
};
export default Job;
- components/JobInfo.js
import Wrapper from '../assets/wrappers/JobInfo';
const JobInfo = ({ icon, text }) => {
return (
<Wrapper>
<span className='icon'>{icon}</span>
<span className='text'>{text}</span>
</Wrapper>
);
};
export default JobInfo;
Job.js
const date = createdAt
<div className='content-center'>
<JobInfo icon={<FaLocationArrow />} text={jobLocation} />
<JobInfo icon={<FaCalendarAlt />} text={date} />
<JobInfo icon={<FaBriefcase />} text={jobType} />
<div className={`status ${status}`}>{status}</div>
</div>
npm install moment
Job.js
const date = moment(createdAt).format('MMM Do, YYYY');
allJobsSlice.js
reducers: {
showLoading: (state) => {
state.isLoading = true;
},
hideLoading: (state) => {
state.isLoading = false;
},
}
export const {
showLoading,
hideLoading,
} = allJobsSlice.actions;
- DELETE /jobs/jobId
- authorization header : 'Bearer token'
jobSlice.js
import { showLoading, hideLoading, getAllJobs } from '../allJobs/allJobsSlice';
export const deleteJob = createAsyncThunk(
'job/deleteJob',
async (jobId, thunkAPI) => {
thunkAPI.dispatch(showLoading());
try {
const resp = await customFetch.delete(`/jobs/${jobId}`, {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
});
thunkAPI.dispatch(getAllJobs());
return resp.data;
} catch (error) {
thunkAPI.dispatch(hideLoading());
return thunkAPI.rejectWithValue(error.response.data.msg);
}
}
);
Job.js
<button
type='button'
className='btn delete-btn'
onClick={() => {
dispatch(deleteJob(_id));
}}
>
Delete
</button>
jobSlice.js
reducers:{
setEditJob: (state, { payload }) => {
return { ...state, isEditing: true, ...payload };
},
}
export const { handleChange, clearValues, setEditJob } = jobSlice.actions;
Job.js
import { setEditJob, deleteJob } from '../features/job/jobSlice';
<Link
to='/add-job'
className='btn edit-btn'
onClick={() => {
dispatch(
setEditJob({
editJobId: _id,
position,
company,
jobLocation,
jobType,
status,
})
);
}}
>
Edit
</Link>;
AddJob.js
useEffect(() => {
if (!isEditing) {
dispatch(handleChange({ name: 'jobLocation', value: user.location }));
}
}, []);
- PATCH /jobs/jobId
- { position:'position', company:'company', jobLocation:'location', jobType:'full-time', status:'pending' }
- authorization header : 'Bearer token'
- sends back the updated job object
jobSlice.js
export const editJob = createAsyncThunk(
'job/editJob',
async ({ jobId, job }, thunkAPI) => {
try {
const resp = await customFetch.patch(`/jobs/${jobId}`, job, {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
});
thunkAPI.dispatch(clearValues());
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
}
);
extraReducers:{
[editJob.pending]: (state) => {
state.isLoading = true;
},
[editJob.fulfilled]: (state) => {
state.isLoading = false;
toast.success('Job Modified...');
},
[editJob.rejected]: (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
},
}
AddJob.js
import {
clearValues,
handleChange,
createJob,
editJob,
} from '../../features/job/jobSlice';
if (isEditing) {
dispatch(
editJob({
jobId: editJobId,
job: {
position,
company,
jobLocation,
jobType,
status,
},
})
);
return;
}
- features/job/jobThunk.js
import customFetch from '../../utils/axios';
import { showLoading, hideLoading, getAllJobs } from '../allJobs/allJobsSlice';
import { clearValues } from './jobSlice';
export const createJobThunk = async (job, thunkAPI) => {
try {
const resp = await customFetch.post('/jobs', job, {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
});
thunkAPI.dispatch(clearValues());
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
};
export const deleteJobThunk = async (jobId, thunkAPI) => {
thunkAPI.dispatch(showLoading());
try {
const resp = await customFetch.delete(`/jobs/${jobId}`, {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
});
thunkAPI.dispatch(getAllJobs());
return resp.data;
} catch (error) {
thunkAPI.dispatch(hideLoading());
return thunkAPI.rejectWithValue(error.response.data.msg);
}
};
export const editJobThunk = async ({ jobId, job }, thunkAPI) => {
try {
const resp = await customFetch.patch(`/jobs/${jobId}`, job, {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
});
thunkAPI.dispatch(clearValues());
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
};
jobSlice.js
import { createJobThunk, deleteJobThunk, editJobThunk } from './jobThunk';
export const createJob = createAsyncThunk('job/createJob', createJobThunk);
export const deleteJob = createAsyncThunk('job/deleteJob', deleteJobThunk);
export const editJob = createAsyncThunk('job/editJob', editJobThunk);
jobThunk.js
const authHeader = (thunkAPI) => {
return {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
};
};
export const createJobThunk = async (job, thunkAPI) => {
try {
const resp = await customFetch.post('/jobs', job, authHeader(thunkAPI));
thunkAPI.dispatch(clearValues());
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
};
- create utils/authHeader.js
const authHeader = (thunkAPI) => {
return {
headers: {
authorization: `Bearer ${thunkAPI.getState().user.user.token}`,
},
};
};
export default authHeader;
jobThunk.js
import authHeader from '../../utils/authHeader';
- utils/axios.js
import axios from 'axios';
import { getUserFromLocalStorage } from './localStorage';
const customFetch = axios.create({
baseURL: 'https://jobify-prod.herokuapp.com/api/v1/toolkit',
});
customFetch.interceptors.request.use(
(config) => {
const user = getUserFromLocalStorage();
if (user) {
config.headers['Authorization'] = `Bearer ${user.token}`;
// in the latest version "common" returns undefined
// config.headers.common['Authorization'] = `Bearer ${user.token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
export default customFetch;
- remove auth header
- email : testUser@test.com
- password : secret
- read only!
- dummy data
Register.js
<button
type='button'
className='btn btn-block btn-hipster'
disabled={isLoading}
onClick={() => {
dispatch(loginUser({ email: 'testUser@test.com', password: 'secret' }));
}}
>
{isLoading ? 'loading...' : 'demo'}
</button>
-
GET /jobs/stats
-
authorization header : 'Bearer token'
-
returns { defaultStats:{pending:24,interview:27,declined:24}, monthlyApplications:[{date:"Nov 2021",count:5},{date:"Dec 2021",count:4} ] }
-
last six months
allJobsSlice.js
export const showStats = createAsyncThunk(
'allJobs/showStats',
async (_, thunkAPI) => {
try {
const resp = await customFetch.get('/jobs/stats');
console.log(resp.data);
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
}
);
// extraReducers
[showStats.pending]: (state) => {
state.isLoading = true;
},
[showStats.fulfilled]: (state, { payload }) => {
state.isLoading = false;
state.stats = payload.defaultStats;
state.monthlyApplications = payload.monthlyApplications;
},
[showStats.rejected]: (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
},
- create
- components/StatsContainer.js
- components/ChartsContainer.js
- import/export
Stats.js
import { useEffect } from 'react';
import { StatsContainer, Loading, ChartsContainer } from '../../components';
import { useDispatch, useSelector } from 'react-redux';
import { showStats } from '../../features/allJobs/allJobsSlice';
const Stats = () => {
const { isLoading, monthlyApplications } = useSelector(
(store) => store.allJobs
);
const dispatch = useDispatch();
useEffect(() => {
dispatch(showStats());
// eslint-disable-next-line
}, []);
if (isLoading) {
return <Loading center />;
}
return (
<>
<StatsContainer />
{monthlyApplications.length > 0 && <ChartsContainer />}
</>
);
};
export default Stats;
- create components/StatItem.js
StatsContainer.js
import StatItem from './StatItem';
import { FaSuitcaseRolling, FaCalendarCheck, FaBug } from 'react-icons/fa';
import Wrapper from '../assets/wrappers/StatsContainer';
import { useSelector } from 'react-redux';
const StatsContainer = () => {
const { stats } = useSelector((store) => store.allJobs);
const defaultStats = [
{
title: 'pending applications',
count: stats.pending || 0,
icon: <FaSuitcaseRolling />,
color: '#e9b949',
bcg: '#fcefc7',
},
{
title: 'interviews scheduled',
count: stats.interview || 0,
icon: <FaCalendarCheck />,
color: '#647acb',
bcg: '#e0e8f9',
},
{
title: 'jobs declined',
count: stats.declined || 0,
icon: <FaBug />,
color: '#d66a6a',
bcg: '#ffeeee',
},
];
return (
<Wrapper>
{defaultStats.map((item, index) => {
return <StatItem key={index} {...item} />;
})}
</Wrapper>
);
};
export default StatsContainer;
StatItem.js
import Wrapper from '../assets/wrappers/StatItem';
const StatItem = ({ count, title, icon, color, bcg }) => {
return (
<Wrapper color={color} bcg={bcg}>
<header>
<span className='count'>{count}</span>
<span className='icon'>{icon}</span>
</header>
<h5 className='title'>{title}</h5>
</Wrapper>
);
};
export default StatItem;
- create
- components/AreaChart.js
- components/BarChart.js
ChartsContainer.js
import React, { useState } from 'react';
import BarChart from './BarChart';
import AreaChart from './AreaChart';
import Wrapper from '../assets/wrappers/ChartsContainer';
import { useSelector } from 'react-redux';
const ChartsContainer = () => {
const [barChart, setBarChart] = useState(true);
const { monthlyApplications: data } = useSelector((store) => store.allJobs);
return (
<Wrapper>
<h4>Monthly Applications</h4>
<button type='button' onClick={() => setBarChart(!barChart)}>
{barChart ? 'Area Chart' : 'Bar Chart'}
</button>
{barChart ? <BarChart data={data} /> : <AreaChart data={data} />}
</Wrapper>
);
};
export default ChartsContainer;
npm install recharts
- For now does not work with React 18
npm install react@17 react-dom@17
npm install recharts
npm install react@18 react-dom@18
AreaChart.js
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from 'recharts';
const AreaChartComponent = ({ data }) => {
return (
<ResponsiveContainer width='100%' height={300}>
<AreaChart data={data} margin={{ top: 50 }}>
<CartesianGrid strokeDasharray='3 3' />
<XAxis dataKey='date' />
<YAxis allowDecimals={false} />
<Tooltip />
<Area type='monotone' dataKey='count' stroke='#1e3a8a' fill='#3b82f6' />
</AreaChart>
</ResponsiveContainer>
);
};
export default AreaChartComponent;
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
const BarChartComponent = ({ data }) => {
return (
<ResponsiveContainer width='100%' height={300}>
<BarChart data={data} margin={{ top: 50 }}>
<CartesianGrid strokeDasharray='3 3 ' />
<XAxis dataKey='date' />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey='count' fill='#3b82f6' barSize={75} />
</BarChart>
</ResponsiveContainer>
);
};
export default BarChartComponent;
SearchContainer.js
import { FormRow, FormRowSelect } from '.';
import Wrapper from '../assets/wrappers/SearchContainer';
import { useSelector, useDispatch } from 'react-redux';
const SearchContainer = () => {
const { isLoading, search, searchStatus, searchType, sort, sortOptions } =
useSelector((store) => store.allJobs);
const { jobTypeOptions, statusOptions } = useSelector((store) => store.job);
const dispatch = useDispatch();
const handleSearch = (e) => {};
const handleSubmit = (e) => {
e.preventDefault();
};
return (
<Wrapper>
<form className='form'>
<h4>search form</h4>
<div className='form-center'>
{/* search position */}
<FormRow
type='text'
name='search'
value={search}
handleChange={handleSearch}
/>
{/* search by status */}
<FormRowSelect
labelText='status'
name='searchStatus'
value={searchStatus}
handleChange={handleSearch}
list={['all', ...statusOptions]}
/>
{/* search by type */}
<FormRowSelect
labelText='type'
name='searchType'
value={searchType}
handleChange={handleSearch}
list={['all', ...jobTypeOptions]}
/>
{/* sort */}
<FormRowSelect
name='sort'
value={sort}
handleChange={handleSearch}
list={sortOptions}
/>
<button
className='btn btn-block btn-danger'
disabled={isLoading}
onClick={handleSubmit}
>
clear filters
</button>
</div>
</form>
</Wrapper>
);
};
export default SearchContainer;
allJobsSlice.js
reducers:{
handleChange: (state, { payload: { name, value } }) => {
// state.page = 1;
state[name] = value;
},
clearFilters: (state) => {
return { ...state, ...initialFiltersState };
},
}
export const { showLoading, hideLoading, handleChange, clearFilters } =
allJobsSlice.actions;
SearchContainer.js
import { handleChange, clearFilters } from '../features/allJobs/allJobsSlice';
const handleSearch = (e) => {
if (isLoading) return;
dispatch(handleChange({ name: e.target.name, value: e.target.value }));
};
const handleSubmit = (e) => {
e.preventDefault();
dispatch(clearFilters());
};
-
server returns 10 jobs per page
-
create
-
components/PageBtnContainer.js
allJobsSlice.js
extraReducers:{
[getAllJobs.fulfilled]: (state, { payload }) => {
state.isLoading = false;
state.jobs = payload.jobs;
state.numOfPages = payload.numOfPages;
state.totalJobs = payload.totalJobs;
},
}
JobsContainer
const { jobs, isLoading, page, totalJobs, numOfPages } = useSelector(
(store) => store.allJobs
);
return (
<Wrapper>
<h5>
{totalJobs} job{jobs.length > 1 && 's'} found
</h5>
<div className='jobs'>
{jobs.map((job) => {
return <Job key={job._id} {...job} />;
})}
</div>
{numOfPages > 1 && <PageBtnContainer />}
</Wrapper>
);
import { HiChevronDoubleLeft, HiChevronDoubleRight } from 'react-icons/hi';
import Wrapper from '../assets/wrappers/PageBtnContainer';
import { useSelector, useDispatch } from 'react-redux';
const PageBtnContainer = () => {
const { numOfPages, page } = useSelector((store) => store.allJobs);
const dispatch = useDispatch();
const pages = Array.from({ length: numOfPages }, (_, index) => {
return index + 1;
});
const nextPage = () => {};
const prevPage = () => {};
return (
<Wrapper>
<button className='prev-btn' onClick={prevPage}>
<HiChevronDoubleLeft />
prev
</button>
<div className='btn-container'>
{pages.map((pageNumber) => {
return (
<button
type='button'
className={pageNumber === page ? 'pageBtn active' : 'pageBtn'}
key={pageNumber}
onClick={() => console.log('change page')}
>
{pageNumber}
</button>
);
})}
</div>
<button className='next-btn' onClick={nextPage}>
next
<HiChevronDoubleRight />
</button>
</Wrapper>
);
};
export default PageBtnContainer;
allJobsSlice.js
reducers:{
changePage: (state, { payload }) => {
state.page = payload;
},
}
export const {
showLoading,
hideLoading,
handleChange,
clearFilters,
changePage,
} = allJobsSlice.actions;
PageBtnContainer.js
import { changePage } from '../features/allJobs/allJobsSlice';
const nextPage = () => {
let newPage = page + 1;
if (newPage > numOfPages) {
newPage = 1;
}
dispatch(changePage(newPage));
};
const prevPage = () => {
let newPage = page - 1;
if (newPage < 1) {
newPage = numOfPages;
}
dispatch(changePage(newPage));
};
return (
<div className='btn-container'>
{pages.map((pageNumber) => {
return (
<button
type='button'
className={pageNumber === page ? 'pageBtn active' : 'pageBtn'}
key={pageNumber}
onClick={() => dispatch(changePage(pageNumber))}
>
{pageNumber}
</button>
);
})}
</div>
);
allJobsSlice
export const getAllJobs = createAsyncThunk(
'allJobs/getJobs',
async (_, thunkAPI) => {
const { page, search, searchStatus, searchType, sort } =
thunkAPI.getState().allJobs;
let url = `/jobs?status=${searchStatus}&jobType=${searchType}&sort=${sort}&page=${page}`;
if (search) {
url = url + `&search=${search}`;
}
try {
const resp = await customFetch.get(url);
return resp.data;
}
}
)
JobsContainer.js
const {
jobs,
isLoading,
page,
totalJobs,
numOfPages,
search,
searchStatus,
searchType,
sort,
} = useSelector((store) => store.allJobs);
useEffect(() => {
dispatch(getAllJobs());
// eslint-disable-next-line
}, [page, search, searchStatus, searchType, sort]);
allJobsSlice.js
reducers:{
handleChange: (state, { payload: { name, value } }) => {
state.page = 1;
state[name] = value;
},
SearchContainer.js
const handleSearch = (e) => {
if (isLoading) return;
dispatch(handleChange({ name: e.target.name, value: e.target.value }));
};
- create
- features/allJobs/allJobsThunk.js
import customFetch from '../../utils/axios';
export const getAllJobsThunk = async (thunkAPI) => {
const { page, search, searchStatus, searchType, sort } =
thunkAPI.getState().allJobs;
let url = `/jobs?page=${page}&status=${searchStatus}&jobType=${searchType}&sort=${sort}`;
if (search) {
url = url + `&search=${search}`;
}
try {
const resp = await customFetch.get(url);
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
};
export const showStatsThunk = async (_, thunkAPI) => {
try {
const resp = await customFetch.get('/jobs/stats');
return resp.data;
} catch (error) {
return thunkAPI.rejectWithValue(error.response.data.msg);
}
};
allJobsSlice.js
import { showStatsThunk, getAllJobsThunk } from './allJobsThunk';
export const getAllJobs = createAsyncThunk('allJobs/getJobs', getAllJobsThunk);
export const showStats = createAsyncThunk('allJobs/showStats', showStatsThunk);
allJobsSlice.js
reducers:{
clearAllJobsState: () => initialState,
}
userThunk.js
import { logoutUser } from './userSlice';
import { clearAllJobsState } from '../allJobs/allJobsSlice';
import { clearValues } from '../job/jobSlice';
export const clearStoreThunk = async (message, thunkAPI) => {
try {
// logout user
thunkAPI.dispatch(logoutUser(message));
// clear jobs value
thunkAPI.dispatch(clearAllJobsState());
// clear job input values
thunkAPI.dispatch(clearValues());
return Promise.resolve();
} catch (error) {
// console.log(error);
return Promise.reject();
}
};
userSlice.js
import { clearStoreThunk } from './userThunk';
export const clearStore = createAsyncThunk('user/clearStore', clearStoreThunk);
extraReducers:{
[clearStore.rejected]: () => {
toast.error('There was an error');
},
}
Navbar.js
import { clearStore } from '../features/user/userSlice';
return (
<button
type='button'
className='dropdown-btn'
onClick={() => {
dispatch(clearStore('Logout Successful...'));
}}
>
logout
</button>
);
axios.js
import { clearStore } from '../features/user/userSlice';
export const checkForUnauthorizedResponse = (error, thunkAPI) => {
if (error.response.status === 401) {
thunkAPI.dispatch(clearStore());
return thunkAPI.rejectWithValue('Unauthorized! Logging Out...');
}
return thunkAPI.rejectWithValue(error.response.data.msg);
};
allJobsThunk.js
import customFetch, { checkForUnauthorizedResponse } from '../../utils/axios';
export const showStatsThunk = async (_, thunkAPI) => {
try {
const resp = await customFetch.get('/jobs/stats');
return resp.data;
} catch (error) {
return checkForUnauthorizedResponse(error, thunkAPI);
}
};
- refactor in all authenticated requests
allJobsSlice.js
extraReducers: (builder) => {
builder
.addCase(getAllJobs.pending, (state) => {
state.isLoading = true;
})
.addCase(getAllJobs.fulfilled, (state, { payload }) => {
state.isLoading = false;
state.jobs = payload.jobs;
state.numOfPages = payload.numOfPages;
state.totalJobs = payload.totalJobs;
})
.addCase(getAllJobs.rejected, (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
})
.addCase(showStats.pending, (state) => {
state.isLoading = true;
})
.addCase(showStats.fulfilled, (state, { payload }) => {
state.isLoading = false;
state.stats = payload.defaultStats;
state.monthlyApplications = payload.monthlyApplications;
})
.addCase(showStats.rejected, (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
});
},
jobSlice.js
extraReducers: (builder) => {
builder
.addCase(createJob.pending, (state) => {
state.isLoading = true;
})
.addCase(createJob.fulfilled, (state) => {
state.isLoading = false;
toast.success('Job Created');
})
.addCase(createJob.rejected, (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
})
.addCase(deleteJob.fulfilled, (state, { payload }) => {
toast.success(payload);
})
.addCase(deleteJob.rejected, (state, { payload }) => {
toast.error(payload);
})
.addCase(editJob.pending, (state) => {
state.isLoading = true;
})
.addCase(editJob.fulfilled, (state) => {
state.isLoading = false;
toast.success('Job Modified...');
})
.addCase(editJob.rejected, (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
});
},
userSlice.js
extraReducers: (builder) => {
builder
.addCase(registerUser.pending, (state) => {
state.isLoading = true;
})
.addCase(registerUser.fulfilled, (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
addUserToLocalStorage(user);
toast.success(`Hello There ${user.name}`);
})
.addCase(registerUser.rejected, (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
})
.addCase(loginUser.pending, (state) => {
state.isLoading = true;
})
.addCase(loginUser.fulfilled, (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
addUserToLocalStorage(user);
toast.success(`Welcome Back ${user.name}`);
})
.addCase(loginUser.rejected, (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
})
.addCase(updateUser.pending, (state) => {
state.isLoading = true;
})
.addCase(updateUser.fulfilled, (state, { payload }) => {
const { user } = payload;
state.isLoading = false;
state.user = user;
addUserToLocalStorage(user);
toast.success(`User Updated!`);
})
.addCase(updateUser.rejected, (state, { payload }) => {
state.isLoading = false;
toast.error(payload);
})
.addCase(clearStore.rejected, () => {
toast.error('There was an error..');
});
},
- remove isLoading from handleSearch
- import useState and useMemo from react
- setup localSearch state value
- replace search input functionality so it updates localSearch
import { useState, useMemo } from 'react';
const SearchContainer = () => {
const [localSearch, setLocalSearch] = useState('');
const handleSearch = (e) => {
dispatch(handleChange({ name: e.target.name, value: e.target.value }));
};
return (
<Wrapper>
<form className='form'>
<h4>search form</h4>
<div className='form-center'>
{/* search position */}
<FormRow
type='text'
name='search'
value={localSearch}
handleChange={(e) => setLocalSearch(e.target.value)}
/>
// ...rest of the code
</div>
</form>
</Wrapper>
);
};
export default SearchContainer;
import { useState, useMemo } from 'react';
const SearchContainer = () => {
const [localSearch, setLocalSearch] = useState('');
const handleSearch = (e) => {
dispatch(handleChange({ name: e.target.name, value: e.target.value }));
};
const debounce = () => {
let timeoutID;
return (e) => {
setLocalSearch(e.target.value);
clearTimeout(timeoutID);
timeoutID = setTimeout(() => {
dispatch(handleChange({ name: e.target.name, value: e.target.value }));
}, 1000);
};
};
const handleSubmit = (e) => {
e.preventDefault();
setLocalSearch('');
dispatch(clearFilters());
};
const optimizedDebounce = useMemo(() => debounce(), []);
return (
<Wrapper>
<form className='form'>
<h4>search form</h4>
<div className='form-center'>
{/* search position */}
<FormRow
type='text'
name='search'
value={localSearch}
handleChange={optimizedDebounce}
/>
// ...rest of the code
</div>
</form>
</Wrapper>
);
};
export default SearchContainer;