lesson 2 finished

This commit is contained in:
andres
2023-06-02 22:00:45 +02:00
parent c80f5c4b42
commit 70bdba4312
14 changed files with 190 additions and 67 deletions

View File

@@ -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/) ![img.png](img.png)
```sh ![img_1.png](img_1.png)
npx degit reduxjs/redux-templates/packages/vite-template-redux my-app
```
## Goals ![img_2.png](img_2.png)
- 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)

BIN
img.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
img_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
img_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@@ -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
View File

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

View File

@@ -1,34 +1,34 @@
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 = () => {
return (
<div>
<button
onClick={() => {
dispatch( dispatch(
authThunks.login({ authThunks.login({
email: "andres99.dev@gmail.com", email: "andres99.dev@gmail.com",
@@ -36,9 +36,25 @@ export const Test = () => {
rememberMe: true, rememberMe: true,
}), }),
) )
.unwrap()
.then(() => navigate("/hello"))
.catch((err) => console.warn(err))
}
return (
<div>
<button onClick={handleLoginClicked}>login</button>
<button
onClick={() => {
dispatch(
authThunks.register({
email: "andres999.dev@gmail.com",
password: "123123123",
}),
)
}} }}
> >
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
position="top-center"
autoClose={5000}
closeOnClick
pauseOnFocusLoss
draggable
pauseOnHover
theme="light"
/>
{isLoading && <LinearProgress />}
<RouterProvider router={router} /> <RouterProvider router={router} />
</ThemeProvider> </ThemeProvider>
</Provider>
) )
} }
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

View File

@@ -0,0 +1 @@
export * from "./unhandled-action"

View File

@@ -0,0 +1,3 @@
import { createAction } from "@reduxjs/toolkit"
export const unhandledAction = createAction<string>("common/unhandledAction")

View File

@@ -1 +1,2 @@
export * from "./create-app-async-thunk" export * from "./create-app-async-thunk"
export * from "./thunk-try-catch"

View 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 })
}
}

View File

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

View File

@@ -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
})
}, },
}) })

View File

@@ -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>
<Provider store={store}>
<App /> <App />
</Provider>
</React.StrictMode>, </React.StrictMode>,
) )