Commit 3aeaf362 authored by Kulpybaev Ilyas's avatar Kulpybaev Ilyas

Урок 94

parent c6a0358f
......@@ -18,7 +18,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.1.1",
"react-router-dom": "^6.14.2"
"react-router-dom": "^6.14.2",
"redux-persist": "^6.0.0"
},
"devDependencies": {
"@types/react": "^18.2.15",
......@@ -3010,6 +3011,14 @@
"@babel/runtime": "^7.9.2"
}
},
"node_modules/redux-persist": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz",
"integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==",
"peerDependencies": {
"redux": ">4.0.0"
}
},
"node_modules/redux-thunk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz",
......
......@@ -20,7 +20,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.1.1",
"react-router-dom": "^6.14.2"
"react-router-dom": "^6.14.2",
"redux-persist": "^6.0.0"
},
"devDependencies": {
"@types/react": "^18.2.15",
......
import { Container, CssBaseline } from "@mui/material";
import AppToolbar from "./components/UI/AppToolbar/AppToolbar.tsx";
import { Route, Routes } from "react-router-dom";
import Products from "./containers/Products/Products.tsx";
import NewProduct from "./containers/Products/NewProduct.tsx";
import { useAppSelector } from "./store/hooks.ts";
import { Routes } from "./components/routes/routes.tsx";
const App = () => (
const App = () => {
const { user } = useAppSelector((state) => state.user);
return (
<>
<CssBaseline />
<header>
......@@ -12,13 +14,11 @@ const App = () => (
</header>
<main>
<Container maxWidth="xl">
<Routes>
<Route path="/" element={<Products />} />
<Route path="/products/new" element={<NewProduct />} />
</Routes>
<Routes user={user} />
</Container>
</main>
</>
);
);
};
export default App;
import axios from "axios";
import { apiURL } from "./constants.ts";
import { Store } from "@reduxjs/toolkit";
import { RootState } from "./store";
type AppStore = Store<RootState>;
let store: AppStore;
export const injectStore = (_store: AppStore) => {
store = _store;
};
const axiosApi = axios.create({
baseURL: apiURL,
});
axiosApi.interceptors.request.use((config) => {
try {
config.headers["Authorization"] = store.getState().user.user?.token;
} catch (e) {
console.log(e);
}
return config;
});
export default axiosApi;
import { ChangeEvent, FormEvent, useState } from "react";
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
import { Box, Button, Grid, TextField } from "@mui/material";
import FileInput from "../UI/Form/FileInput/FileInput.tsx";
import { useAppDispatch, useAppSelector } from "../../store/hooks.ts";
import { fetchCategories } from "../../features/categoriesSlice.ts";
import FormElement from "../UI/Form/FormElement/FormElement.tsx";
interface IState {
title: string;
price: string;
description: string;
image: string;
categoryId: string;
}
interface IProps {
onSubmit: (data: FormData) => void;
}
const ProductForm = ({ onSubmit }: IProps) => {
const dispatch = useAppDispatch();
const { categories } = useAppSelector((state) => state.categories);
const [state, setState] = useState<IState>({
title: "",
price: "",
description: "",
image: "",
categoryId: "",
});
const submitFormHandler = (e: FormEvent<HTMLFormElement>) => {
......@@ -51,6 +59,10 @@ const ProductForm = ({ onSubmit }: IProps) => {
}
};
useEffect(() => {
dispatch(fetchCategories());
}, [dispatch]);
return (
<Box
component="form"
......@@ -58,7 +70,7 @@ const ProductForm = ({ onSubmit }: IProps) => {
onSubmit={submitFormHandler}
paddingY={2}
>
<Grid container direction="column" spacing={2}>
<Grid container direction="column" spacing={2} sx={{ width: 500 }}>
<Grid item xs>
<TextField
fullWidth
......@@ -95,6 +107,16 @@ const ProductForm = ({ onSubmit }: IProps) => {
/>
</Grid>
<FormElement
label="Category"
value={state.categoryId}
onChange={inputChangeHandler}
name="categoryId"
select
options={categories}
fullWidth
/>
<Grid item xs>
<FileInput onChange={fileChangeHandler} name="image" label="Image" />
</Grid>
......
import { Navigate, Outlet } from "react-router-dom";
interface IProps {
isAllowed?: boolean;
redirectPath: string;
}
const ProtectedRoute = ({ isAllowed, redirectPath }: IProps) => {
if (!isAllowed) {
return <Navigate to={redirectPath} />;
}
return <Outlet />;
};
export default ProtectedRoute;
import { AppBar, Toolbar, Typography, styled, Box } from "@mui/material";
import { AppBar, Toolbar, Typography, styled, Box, Grid } from "@mui/material";
import { Link } from "react-router-dom";
import { useAppDispatch, useAppSelector } from "../../../store/hooks.ts";
import UserMenu from "../menus/userMenu/UserMenu.tsx";
import AnonymousMenu from "../menus/anonymousMenu/AnonymousMenu.tsx";
import { logoutUser } from "../../../features/usersSlice.ts";
const StyledLink = styled(Link)(() => ({
color: "inherit",
......@@ -8,13 +12,27 @@ const StyledLink = styled(Link)(() => ({
}));
const AppToolbar = () => {
const { user } = useAppSelector((state) => state.user);
const dispatch = useAppDispatch();
const logoutHandler = () => {
dispatch(logoutUser());
};
return (
<>
<AppBar position="fixed">
<Toolbar>
<Grid container alignItems="center" justifyContent="space-between">
<Typography variant="h6" component={StyledLink} to="/">
Computer parts shop
</Typography>
{user ? (
<UserMenu user={user} onLogoutHandler={logoutHandler} />
) : (
<AnonymousMenu />
)}
</Grid>
</Toolbar>
</AppBar>
<Box component={Toolbar} marginBottom={2} />
......
import { Grid, MenuItem, TextField } from "@mui/material";
import { ChangeEventHandler } from "react";
type Option = {
id: string | number;
title: string;
};
interface IProps {
label: string;
value: string;
onChange: ChangeEventHandler;
name: string;
required?: boolean;
error?: string;
type?: string;
multiline?: boolean;
fullWidth?: boolean;
select?: boolean;
options?: Option[];
}
const FormElement = (props: IProps) => {
return (
<Grid item xs>
<TextField
margin="normal"
fullWidth={props.fullWidth}
id={props.name}
label={props.label}
value={props.value}
onChange={props.onChange}
name={props.name}
autoComplete={props.name}
multiline={props.multiline}
error={!!props.error}
helperText={props.error}
required={props.required}
type={props.type}
select={props.select}
>
{props.select && props.options
? props.options.map((option) => {
return (
<MenuItem key={option.id} value={option.id}>
{option.title}
</MenuItem>
);
})
: null}
</TextField>
</Grid>
);
};
export default FormElement;
import { Button, Grid } from "@mui/material";
import { Link } from "react-router-dom";
const AnonymousMenu = () => {
return (
<Grid item>
<Button component={Link} to="/register" color="inherit">
Sign Up
</Button>
<Button component={Link} to="/login" color="inherit">
Sign In
</Button>
</Grid>
);
};
export default AnonymousMenu;
import { IUser } from "../../../../interfaces/IUser.ts";
import { Button, Menu, MenuItem } from "@mui/material";
import { useState } from "react";
interface IProps {
user: IUser;
onLogoutHandler: () => void;
}
const UserMenu = ({ user, onLogoutHandler }: IProps) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleLogout = () => {
onLogoutHandler();
handleClose();
};
return (
<div>
<Button
id="demo-positioned-button"
aria-controls={open ? "demo-positioned-menu" : undefined}
aria-haspopup="true"
aria-expanded={open ? "true" : undefined}
onClick={handleClick}
sx={{
backgroundColor: "white",
"&:hover": {
backgroundColor: "gray",
color: "white",
},
}}
>
Hello {user.username}
</Button>
<Menu
id="demo-positioned-menu"
aria-labelledby="demo-positioned-button"
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItem onClick={handleClose}>Profile</MenuItem>
<MenuItem onClick={handleClose}>My account</MenuItem>
<MenuItem onClick={handleLogout}>Logout</MenuItem>
</Menu>
</div>
);
};
export default UserMenu;
import { IUser } from "../../interfaces/IUser.ts";
import { Route, Routes as RouterRoutes } from "react-router-dom";
import Products from "../../containers/Products/Products.tsx";
import Register from "../../containers/Register/Register.tsx";
import Login from "../../containers/Login/Login.tsx";
import ProtectedRoute from "../ProtectedRoute/ProtectedRoute.tsx";
import NewProduct from "../../containers/Products/NewProduct.tsx";
interface IProps {
user: IUser | null;
}
export const Routes = ({ user }: IProps) => {
return (
<RouterRoutes>
<Route path="/" element={<Products />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route
element={
<ProtectedRoute
isAllowed={!!user && user.role === "admin"}
redirectPath="/login"
/>
}
>
<Route path="/products/new" element={<NewProduct />} />
</Route>
</RouterRoutes>
);
};
import { ChangeEvent, FormEvent, useState } from "react";
import {
Avatar,
Box,
Button,
Container,
Grid,
Link,
Typography,
Alert,
} from "@mui/material";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import FormElement from "../../components/UI/Form/FormElement/FormElement.tsx";
import { useAppDispatch, useAppSelector } from "../../store/hooks.ts";
import { loginUser } from "../../features/usersSlice.ts";
interface IState {
username: string;
password: string;
}
const initialState: IState = {
username: "",
password: "",
};
const Login = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { loginError } = useAppSelector((state) => state.user);
const [state, setState] = useState<IState>(initialState);
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setState((prevState) => ({ ...prevState, [name]: value }));
};
const submitFormHandler = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
dispatch(loginUser({ ...state }))
.unwrap()
.then(() => {
navigate("/");
});
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Sign in
</Typography>
{loginError ? <Alert severity="error">{loginError}</Alert> : null}
<Box
component="form"
onSubmit={submitFormHandler}
noValidate
sx={{ mt: 1 }}
>
<FormElement
required
label="Username"
name="username"
onChange={inputChangeHandler}
value={state.username}
/>
<FormElement
required
name="password"
label="Password"
type="password"
onChange={inputChangeHandler}
value={state.password}
/>
<Button
fullWidth
type="submit"
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign In
</Button>
<Grid container>
<Grid item>
<Link component={RouterLink} to={"/register"} variant="body2">
{"Don't have an account? Sign Up"}
</Link>
</Grid>
</Grid>
</Box>
</Box>
</Container>
);
};
export default Login;
......@@ -8,6 +8,7 @@ import ProductItem from "../../components/ProductItem/ProductItem.tsx";
const Products = () => {
const dispatch = useAppDispatch();
const { products } = useAppSelector((state) => state.products);
const { user } = useAppSelector((state) => state.user);
useEffect(() => {
dispatch(fetchProducts());
......@@ -25,11 +26,14 @@ const Products = () => {
<Grid item>
<Typography variant="h4">Products</Typography>
</Grid>
{user && user.role === "admin" && (
<Grid item>
<Button color="primary" component={Link} to="/products/new">
Add product
</Button>
</Grid>
)}
</Grid>
<Grid container item spacing={2}>
{products.map((product) => (
......
import { ChangeEvent, FormEvent, useState } from "react";
import {
Alert,
Avatar,
Box,
Button,
Container,
Grid,
Link,
Typography,
} from "@mui/material";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { useAppDispatch, useAppSelector } from "../../store/hooks.ts";
import { useNavigate } from "react-router-dom";
import { registerUser } from "../../features/usersSlice.ts";
import FormElement from "../../components/UI/Form/FormElement/FormElement.tsx";
interface IRegisterState {
username: string;
password: string;
displayName: string;
}
const Register = () => {
const { registerError } = useAppSelector((state) => state.user);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const [state, setState] = useState<IRegisterState>({
username: "",
password: "",
displayName: "",
});
const inputChangeHandler = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setState((prevState) => ({ ...prevState, [name]: value }));
};
const submitFormHandler = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
dispatch(registerUser({ ...state }))
.unwrap()
.then(() => {
navigate("/");
});
};
const getErrorsBy = (name: string) => {
if (Array.isArray(registerError)) {
const currentError = registerError.find(({ type }) => type === name);
return currentError?.messages.join(",");
}
};
return (
<Container component="main" maxWidth="xs">
<Box
sx={{
marginTop: 8,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<Avatar sx={{ m: 1, bgcolor: "secondary.main" }}>
<LockOutlinedIcon />
</Avatar>
<Typography component="h1" variant="h5">
Sign up
</Typography>
{registerError && !Array.isArray(registerError) ? (
<Alert severity="error">{registerError}</Alert>
) : null}
<Box
component="form"
onSubmit={submitFormHandler}
noValidate
sx={{ mt: 1 }}
>
<FormElement
label="Your name"
value={state.displayName}
onChange={inputChangeHandler}
name="displayName"
/>
<FormElement
label="Login"
value={state.username}
onChange={inputChangeHandler}
name="username"
error={getErrorsBy("username")}
/>
<FormElement
label="Password"
value={state.password}
onChange={inputChangeHandler}
name="password"
error={getErrorsBy("password")}
/>
<Button
type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
>
Sign Up
</Button>
<Grid container>
<Grid item>
<Link href="#" variant="body2">
{"Have an account? Sign In"}
</Link>
</Grid>
</Grid>
</Box>
</Box>
</Container>
);
};
export default Register;
import { ICategory } from "../interfaces/ICategory.ts";
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import axiosApi from "../axiosApi.ts";
interface IState {
categories: ICategory[];
error: Error | null;
loading: boolean;
}
const initialState: IState = {
categories: [],
error: null,
loading: false,
};
export const fetchCategories = createAsyncThunk(
"fetch/categories",
async () => {
return await axiosApi
.get<ICategory[]>("/categories")
.then((res) => res.data);
},
);
const categoriesSlice = createSlice({
name: "categories",
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchCategories.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchCategories.fulfilled, (state, action) => {
state.categories = action.payload;
state.loading = false;
})
.addCase(fetchCategories.rejected, (state, action) => {
state.error = action.error as Error;
state.loading = false;
});
},
});
export default categoriesSlice.reducer;
import { IUser } from "../interfaces/IUser.ts";
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import axiosApi from "../axiosApi.ts";
import { AxiosError, isAxiosError } from "axios";
type userResponseValidateError = {
type: string;
messages: string[];
}[];
interface IUserState {
user: IUser | null;
loading: boolean;
registerError: string | null | userResponseValidateError;
loginError: null | string;
}
type userRequest = {
username: string;
displayName?: string;
password: string;
};
type userResponseError = {
error: { message: string };
};
export const registerUser = createAsyncThunk<
IUser,
userRequest,
{ rejectValue: userResponseError | userResponseValidateError }
>("auth/register", async (userData: userRequest, { rejectWithValue }) => {
try {
const response = await axiosApi.post<IUser>("/auth/register", userData);
return response.data;
} catch (e) {
if (isAxiosError(e)) {
const error: AxiosError<userResponseError> = e;
return rejectWithValue(
error.response?.data || { error: { message: "An error occurred" } },
);
}
throw e;
}
});
export const loginUser = createAsyncThunk<
IUser,
userRequest,
{ rejectValue: string }
>("auth/login", async (userData: userRequest, { rejectWithValue }) => {
try {
const response = await axiosApi.post<IUser>("/auth/sign-in", userData);
return response.data;
} catch (e) {
if (isAxiosError(e)) {
const error: AxiosError<userResponseError> = e;
return rejectWithValue(
error.response?.data.error.message || "An error occured",
);
}
throw e;
}
});
export const logoutUser = createAsyncThunk(
"auth/logout",
async (_, { rejectWithValue }) => {
try {
const response = await axiosApi.delete("/auth/logout");
return response.data;
} catch (err) {
if (isAxiosError(err)) {
const error: AxiosError<userResponseError> = err;
return rejectWithValue(
error.response?.data.error.message || "Internet connection error",
);
}
throw err;
}
},
);
const initialState: IUserState = {
user: null,
loading: false,
registerError: null,
loginError: null,
};
const userSlice = createSlice({
name: "user",
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(registerUser.pending, (state) => {
state.loading = true;
state.registerError = null;
})
.addCase(registerUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload ?? null;
})
.addCase(registerUser.rejected, (state, action) => {
state.loading = false;
if (Array.isArray(action.payload)) {
state.registerError = action.payload;
} else {
state.registerError =
action.payload?.error.message ?? "Error occured";
}
})
.addCase(loginUser.pending, (state) => {
state.loading = true;
state.loginError = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(loginUser.rejected, (state, action) => {
state.loading = false;
state.loginError = action.payload || null;
})
.addCase(logoutUser.fulfilled, () => {
return initialState;
});
},
});
export default userSlice.reducer;
export interface ICategory {
id: number;
title: string;
description: string;
}
export interface IUser {
username: string;
token: string;
displayName?: string;
role: "user" | "admin";
}
import React from 'react'
import ReactDOM from 'react-dom/client'
import {Provider} from 'react-redux';
import {BrowserRouter} from 'react-router-dom';
import App from './App.tsx'
import './index.css'
import store from './store';
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { BrowserRouter } from "react-router-dom";
import App from "./App.tsx";
import "./index.css";
import store, { persistor } from "./store";
import { PersistGate } from "redux-persist/integration/react";
ReactDOM.createRoot(document.getElementById('root')!).render(
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<PersistGate persistor={persistor} loading={null}>
<BrowserRouter>
<App />
</BrowserRouter>
</PersistGate>
</Provider>
</React.StrictMode>,
)
);
import { configureStore } from "@reduxjs/toolkit";
import productsReducer from "../features/productsSlice.ts";
import { rootReducer } from "./rootReducer.ts";
import storage from "redux-persist/lib/storage";
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from "redux-persist";
import { injectStore } from "../axiosApi.ts";
const persistConfig = {
key: "root",
storage,
whitelist: ["user"],
};
const persistedReducer = persistReducer<ReturnType<typeof rootReducer>>(
persistConfig,
rootReducer,
);
const store = configureStore({
reducer: {
products: productsReducer,
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
});
export default store;
injectStore(store);
export default store;
export const persistor = persistStore(store);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
import { combineReducers } from "@reduxjs/toolkit";
import productsReducer from "../features/productsSlice.ts";
import userReducer from "../features/usersSlice.ts";
import categoriesReducer from "../features/categoriesSlice.ts";
export const rootReducer = combineReducers({
products: productsReducer,
user: userReducer,
categories: categoriesReducer,
});
......@@ -20,6 +20,7 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"files": ["./node_modules/redux-persist/types/storage/index.d.ts"],
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment