mirror of
https://github.com/ershisan99/cards-front.git
synced 2025-12-18 04:59:28 +00:00
lesson 2 finished
This commit is contained in:
28
README.md
28
README.md
@@ -1,27 +1,7 @@
|
|||||||
# vite-template-redux
|
### typescript magic:
|
||||||
|
|
||||||
Uses [Vite](https://vitejs.dev/), [Vitest](https://vitest.dev/), and [React Testing Library](https://github.com/testing-library/react-testing-library) to create a modern [React](https://react.dev/) app compatible with [Create React App](https://create-react-app.dev/)
|

|
||||||
|
|
||||||
```sh
|

|
||||||
npx degit reduxjs/redux-templates/packages/vite-template-redux my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
## Goals
|

|
||||||
|
|
||||||
- Easy migration from Create React App or Vite
|
|
||||||
- As beginner friendly as Create React App
|
|
||||||
- Optimized performance compared to Create React App
|
|
||||||
- Customizable without ejecting
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
- `dev`/`start` - start dev server and open browser
|
|
||||||
- `build` - build for production
|
|
||||||
- `preview` - locally preview production build
|
|
||||||
- `test` - launch test runner
|
|
||||||
|
|
||||||
## Inspiration
|
|
||||||
|
|
||||||
- [Create React App](https://github.com/facebook/create-react-app/tree/main/packages/cra-template)
|
|
||||||
- [Vite](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react)
|
|
||||||
- [Vitest](https://github.com/vitest-dev/vitest/tree/main/examples/react-testing-lib)
|
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-redux": "^8.0.5",
|
"react-redux": "^8.0.5",
|
||||||
"react-router-dom": "^6.11.2"
|
"react-router-dom": "^6.11.2",
|
||||||
|
"react-toastify": "^9.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/dom": "^9.3.0",
|
"@testing-library/dom": "^9.3.0",
|
||||||
|
|||||||
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
@@ -31,6 +31,9 @@ dependencies:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.11.2
|
specifier: ^6.11.2
|
||||||
version: 6.11.2(react-dom@18.2.0)(react@18.2.0)
|
version: 6.11.2(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
react-toastify:
|
||||||
|
specifier: ^9.1.3
|
||||||
|
version: 9.1.3(react-dom@18.2.0)(react@18.2.0)
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@testing-library/dom':
|
'@testing-library/dom':
|
||||||
@@ -4895,6 +4898,17 @@ packages:
|
|||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/react-toastify@9.1.3(react-dom@18.2.0)(react@18.2.0):
|
||||||
|
resolution: {integrity: sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16'
|
||||||
|
react-dom: '>=16'
|
||||||
|
dependencies:
|
||||||
|
clsx: 1.2.1
|
||||||
|
react: 18.2.0
|
||||||
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
|
dev: false
|
||||||
|
|
||||||
/react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
|
/react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
|
||||||
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
|
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|||||||
84
src/App.tsx
84
src/App.tsx
@@ -1,44 +1,60 @@
|
|||||||
import { Counter } from "./features/counter/Counter"
|
import { Counter } from "./features/counter/Counter"
|
||||||
import { createBrowserRouter, RouterProvider } from "react-router-dom"
|
import {
|
||||||
|
createBrowserRouter,
|
||||||
|
Outlet,
|
||||||
|
RouterProvider,
|
||||||
|
useNavigate,
|
||||||
|
} from "react-router-dom"
|
||||||
import "./App.css"
|
import "./App.css"
|
||||||
import { store } from "@/app/store"
|
import "react-toastify/dist/ReactToastify.css"
|
||||||
import { Provider } from "react-redux"
|
import { createTheme, LinearProgress, ThemeProvider } from "@mui/material"
|
||||||
import { createTheme, ThemeProvider } from "@mui/material"
|
|
||||||
import { useAppDispatch, useAppSelector } from "@/app/hooks"
|
import { useAppDispatch, useAppSelector } from "@/app/hooks"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { appActions } from "@/features/app/app.slice"
|
import { appActions } from "@/features/app/app.slice"
|
||||||
import { authThunks } from "@/features/auth/auth.slice"
|
import { authThunks } from "@/features/auth/auth.slice"
|
||||||
|
import { toast, ToastContainer } from "react-toastify"
|
||||||
|
|
||||||
export const Test = () => {
|
export const Test = () => {
|
||||||
const isLoading = useAppSelector((state) => state.app.isLoading)
|
|
||||||
const error = useAppSelector((state) => state.app.error)
|
const error = useAppSelector((state) => state.app.error)
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
|
const navigate = useNavigate()
|
||||||
function handleErrorButtonClicked() {
|
function handleErrorButtonClicked() {
|
||||||
dispatch(appActions.setError({ error: "new error" }))
|
toast.success("something's gone right")
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
dispatch(appActions.setIsLoading({ isLoading: false }))
|
dispatch(appActions.setIsLoading({ isLoading: false }))
|
||||||
}, 3000)
|
}, 1000)
|
||||||
}, [dispatch])
|
}, [dispatch])
|
||||||
|
|
||||||
if (isLoading) return <div>loading...</div>
|
const handleLoginClicked = () => {
|
||||||
|
dispatch(
|
||||||
|
authThunks.login({
|
||||||
|
email: "andres99.dev@gmail.com",
|
||||||
|
password: "123123123",
|
||||||
|
rememberMe: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.then(() => navigate("/hello"))
|
||||||
|
.catch((err) => console.warn(err))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<button onClick={handleLoginClicked}>login</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dispatch(
|
dispatch(
|
||||||
authThunks.login({
|
authThunks.register({
|
||||||
email: "andres99.dev@gmail.com",
|
email: "andres999.dev@gmail.com",
|
||||||
password: "123123123",
|
password: "123123123",
|
||||||
rememberMe: true,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
login
|
register
|
||||||
</button>
|
</button>
|
||||||
<button onClick={handleErrorButtonClicked}>create error</button>
|
<button onClick={handleErrorButtonClicked}>create error</button>
|
||||||
{!!error && <h2>{error}</h2>}
|
{!!error && <h2>{error}</h2>}
|
||||||
@@ -56,17 +72,49 @@ const router = createBrowserRouter([
|
|||||||
element: <div>hello</div>,
|
element: <div>hello</div>,
|
||||||
path: "/hello",
|
path: "/hello",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
element: <ProtectedRoute />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
element: <div>protected</div>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const theme = createTheme()
|
const theme = createTheme()
|
||||||
function App() {
|
function App() {
|
||||||
|
const isLoading = useAppSelector((state) => state.app.isLoading)
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<ThemeProvider theme={theme}>
|
||||||
<ThemeProvider theme={theme}>
|
<ToastContainer
|
||||||
<RouterProvider router={router} />
|
position="top-center"
|
||||||
</ThemeProvider>
|
autoClose={5000}
|
||||||
</Provider>
|
closeOnClick
|
||||||
|
pauseOnFocusLoss
|
||||||
|
draggable
|
||||||
|
pauseOnHover
|
||||||
|
theme="light"
|
||||||
|
/>
|
||||||
|
{isLoading && <LinearProgress />}
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
function ProtectedRoute() {
|
||||||
|
const isAuthed = useAppSelector((state) => state.auth.isAuthed)
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthed === null) return
|
||||||
|
|
||||||
|
if (!isAuthed) navigate("/")
|
||||||
|
}, [isAuthed, navigate])
|
||||||
|
|
||||||
|
if (!isAuthed) return null
|
||||||
|
|
||||||
|
return <Outlet />
|
||||||
|
}
|
||||||
|
|
||||||
export default App
|
export default App
|
||||||
|
|||||||
1
src/common/actions/index.ts
Normal file
1
src/common/actions/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./unhandled-action"
|
||||||
3
src/common/actions/unhandled-action.ts
Normal file
3
src/common/actions/unhandled-action.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { createAction } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
|
export const unhandledAction = createAction<string>("common/unhandledAction")
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./create-app-async-thunk"
|
export * from "./create-app-async-thunk"
|
||||||
|
export * from "./thunk-try-catch"
|
||||||
|
|||||||
17
src/common/utils/thunk-try-catch.ts
Normal file
17
src/common/utils/thunk-try-catch.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { BaseThunkAPI } from "@reduxjs/toolkit/dist/createAsyncThunk"
|
||||||
|
import { AppDispatch, RootState } from "@/app/store"
|
||||||
|
type Options = { showGlobalError?: boolean }
|
||||||
|
export const thunkTryCatch = async <T>(
|
||||||
|
thunkAPI: BaseThunkAPI<RootState, any, AppDispatch, unknown>,
|
||||||
|
promise: () => Promise<T>,
|
||||||
|
options?: Options,
|
||||||
|
): Promise<T | ReturnType<typeof thunkAPI.rejectWithValue>> => {
|
||||||
|
const { showGlobalError } = options || {}
|
||||||
|
const { rejectWithValue } = thunkAPI
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await promise()
|
||||||
|
} catch (e) {
|
||||||
|
return rejectWithValue({ error: e, showGlobalError })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
|
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
|
||||||
|
import { isAxiosError } from "axios"
|
||||||
|
import { toast } from "react-toastify"
|
||||||
|
|
||||||
const initialAppState = {
|
const initialAppState = {
|
||||||
error: null as null | string,
|
error: null as null | string,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAppInitialized: false,
|
isAppInitialized: false,
|
||||||
|
unhandledActions: [] as Array<any>,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type InitialAppState = typeof initialAppState
|
export type InitialAppState = typeof initialAppState
|
||||||
@@ -25,7 +28,58 @@ const slice = createSlice({
|
|||||||
state.isAppInitialized = action.payload.isAppInitialized
|
state.isAppInitialized = action.payload.isAppInitialized
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addMatcher(
|
||||||
|
(action) => {
|
||||||
|
return action.type.endsWith("/pending")
|
||||||
|
},
|
||||||
|
(state) => {
|
||||||
|
state.isLoading = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.addMatcher(
|
||||||
|
(action) => {
|
||||||
|
return action.type.endsWith("/rejected")
|
||||||
|
},
|
||||||
|
(state, { payload: { error } }) => {
|
||||||
|
state.isLoading = false
|
||||||
|
const errorMessage = getErrorMessage(error)
|
||||||
|
if (errorMessage === null) return
|
||||||
|
toast.error(errorMessage)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.addMatcher(
|
||||||
|
(action) => {
|
||||||
|
return action.type.endsWith("/fulfilled")
|
||||||
|
},
|
||||||
|
(state) => {
|
||||||
|
state.isLoading = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.addDefaultCase((state, action) => {
|
||||||
|
console.log("addDefaultCase 🚀", action.type)
|
||||||
|
state.unhandledActions.push(action)
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/* if null is returned no message should be shown */
|
||||||
|
function getErrorMessage(error: unknown): null | string {
|
||||||
|
if (isAxiosError(error)) {
|
||||||
|
if (
|
||||||
|
error?.response?.status === 400 &&
|
||||||
|
error?.request.responseURL.endsWith("/login")
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return error?.response?.data?.error ?? error.message
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return `Native error: ${error.message}`
|
||||||
|
}
|
||||||
|
return JSON.stringify(error)
|
||||||
|
}
|
||||||
|
|
||||||
export const appReducer = slice.reducer
|
export const appReducer = slice.reducer
|
||||||
export const appActions = slice.actions
|
export const appActions = slice.actions
|
||||||
|
|||||||
@@ -3,56 +3,56 @@ import {
|
|||||||
AuthApi,
|
AuthApi,
|
||||||
LoginArgs,
|
LoginArgs,
|
||||||
RegisterArgs,
|
RegisterArgs,
|
||||||
|
RegisterResponse,
|
||||||
User,
|
User,
|
||||||
} from "@/features/auth/auth.api"
|
} from "@/features/auth/auth.api"
|
||||||
import { createAppAsyncThunk } from "@/common"
|
import { createAppAsyncThunk, thunkTryCatch } from "@/common"
|
||||||
|
import { BaseThunkAPI } from "@reduxjs/toolkit/dist/createAsyncThunk"
|
||||||
|
import { AppDispatch, RootState } from "@/app/store"
|
||||||
|
|
||||||
const THUNK_PREFIXES = {
|
const THUNK_PREFIXES = {
|
||||||
REGISTER: "auth/register",
|
REGISTER: "auth/register",
|
||||||
}
|
}
|
||||||
|
const createThunkAction = <A, R, T>(
|
||||||
|
promise: (arg: A) => Promise<R>,
|
||||||
|
transformPromise: (arg: R) => T,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
arg: A,
|
||||||
|
thunkAPI: BaseThunkAPI<RootState, any, AppDispatch, unknown>,
|
||||||
|
) => {
|
||||||
|
return thunkTryCatch(thunkAPI, () => promise(arg).then(transformPromise))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const register = createAppAsyncThunk<any, RegisterArgs>(
|
const register = createAppAsyncThunk<{ user: RegisterResponse }, RegisterArgs>(
|
||||||
THUNK_PREFIXES.REGISTER,
|
THUNK_PREFIXES.REGISTER,
|
||||||
(arg) => {
|
createThunkAction(AuthApi.register, (res) => ({ user: res.data })),
|
||||||
AuthApi.register(arg)
|
|
||||||
.then((res) => {
|
|
||||||
console.log(res)
|
|
||||||
})
|
|
||||||
.catch((res) => {
|
|
||||||
console.error(res)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const login = createAppAsyncThunk<{ user: User }, LoginArgs>(
|
const login = createAppAsyncThunk<{ user: User }, LoginArgs>(
|
||||||
"auth/login",
|
"auth/login",
|
||||||
async (arg) => {
|
createThunkAction(AuthApi.login, (res) => ({ user: res.data })),
|
||||||
const res = await AuthApi.login(arg)
|
|
||||||
return { user: res.data }
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const slice = createSlice({
|
const slice = createSlice({
|
||||||
name: "auth",
|
name: "auth",
|
||||||
initialState: { user: null as User | null, isLoading: false },
|
initialState: {
|
||||||
|
user: null as User | null,
|
||||||
|
isAuthed: null as null | boolean,
|
||||||
|
isLoading: false,
|
||||||
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
setUser: (state, action: PayloadAction<{ user: User }>) => {
|
setUser: (state, action: PayloadAction<{ user: User }>) => {
|
||||||
state.user = action.payload.user
|
state.user = action.payload.user
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder.addCase(login.pending, (state) => {
|
|
||||||
state.isLoading = true
|
|
||||||
})
|
|
||||||
builder.addCase(login.fulfilled, (state, action) => {
|
builder.addCase(login.fulfilled, (state, action) => {
|
||||||
if (action.payload?.user) {
|
if (action.payload?.user) {
|
||||||
state.user = action.payload.user
|
state.user = action.payload.user
|
||||||
state.isLoading = false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
builder.addCase(login.rejected, (state) => {
|
|
||||||
state.isLoading = false
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import React from "react"
|
|||||||
import ReactDOM from "react-dom/client"
|
import ReactDOM from "react-dom/client"
|
||||||
import App from "./App"
|
import App from "./App"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
|
import { store } from "@/app/store"
|
||||||
|
import { Provider } from "react-redux"
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<Provider store={store}>
|
||||||
|
<App />
|
||||||
|
</Provider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user