diff --git a/README.md b/README.md index 7247e9e..f001ec1 100644 --- a/README.md +++ b/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/) +![img.png](img.png) -```sh -npx degit reduxjs/redux-templates/packages/vite-template-redux my-app -``` +![img_1.png](img_1.png) -## 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) +![img_2.png](img_2.png) \ No newline at end of file diff --git a/img.png b/img.png new file mode 100644 index 0000000..691e402 Binary files /dev/null and b/img.png differ diff --git a/img_1.png b/img_1.png new file mode 100644 index 0000000..76f5967 Binary files /dev/null and b/img_1.png differ diff --git a/img_2.png b/img_2.png new file mode 100644 index 0000000..145fc88 Binary files /dev/null and b/img_2.png differ diff --git a/package.json b/package.json index 5d1252f..d94e89d 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^8.0.5", - "react-router-dom": "^6.11.2" + "react-router-dom": "^6.11.2", + "react-toastify": "^9.1.3" }, "devDependencies": { "@testing-library/dom": "^9.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2bb7c7..9dbe180 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ dependencies: react-router-dom: specifier: ^6.11.2 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: '@testing-library/dom': @@ -4895,6 +4898,17 @@ packages: react: 18.2.0 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): resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: diff --git a/src/App.tsx b/src/App.tsx index 4ecef4d..72c72c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,44 +1,60 @@ 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 { store } from "@/app/store" -import { Provider } from "react-redux" -import { createTheme, ThemeProvider } from "@mui/material" +import "react-toastify/dist/ReactToastify.css" +import { createTheme, LinearProgress, ThemeProvider } from "@mui/material" import { useAppDispatch, useAppSelector } from "@/app/hooks" import { useEffect } from "react" import { appActions } from "@/features/app/app.slice" import { authThunks } from "@/features/auth/auth.slice" +import { toast, ToastContainer } from "react-toastify" export const Test = () => { - const isLoading = useAppSelector((state) => state.app.isLoading) const error = useAppSelector((state) => state.app.error) const dispatch = useAppDispatch() - + const navigate = useNavigate() function handleErrorButtonClicked() { - dispatch(appActions.setError({ error: "new error" })) + toast.success("something's gone right") } useEffect(() => { setTimeout(() => { dispatch(appActions.setIsLoading({ isLoading: false })) - }, 3000) + }, 1000) }, [dispatch]) - if (isLoading) return
loading...
+ const handleLoginClicked = () => { + dispatch( + authThunks.login({ + email: "andres99.dev@gmail.com", + password: "123123123", + rememberMe: true, + }), + ) + .unwrap() + .then(() => navigate("/hello")) + .catch((err) => console.warn(err)) + } + return (
+ {!!error &&

{error}

} @@ -56,17 +72,49 @@ const router = createBrowserRouter([ element:
hello
, path: "/hello", }, + { + element: , + children: [ + { + element:
protected
, + }, + ], + }, ]) const theme = createTheme() function App() { + const isLoading = useAppSelector((state) => state.app.isLoading) return ( - - - - - + + + {isLoading && } + + ) } +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 +} export default App diff --git a/src/common/actions/index.ts b/src/common/actions/index.ts new file mode 100644 index 0000000..6835954 --- /dev/null +++ b/src/common/actions/index.ts @@ -0,0 +1 @@ +export * from "./unhandled-action" diff --git a/src/common/actions/unhandled-action.ts b/src/common/actions/unhandled-action.ts new file mode 100644 index 0000000..89cb872 --- /dev/null +++ b/src/common/actions/unhandled-action.ts @@ -0,0 +1,3 @@ +import { createAction } from "@reduxjs/toolkit" + +export const unhandledAction = createAction("common/unhandledAction") diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index bcae4d5..1ea16f3 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -1 +1,2 @@ export * from "./create-app-async-thunk" +export * from "./thunk-try-catch" diff --git a/src/common/utils/thunk-try-catch.ts b/src/common/utils/thunk-try-catch.ts new file mode 100644 index 0000000..ae256f5 --- /dev/null +++ b/src/common/utils/thunk-try-catch.ts @@ -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 ( + thunkAPI: BaseThunkAPI, + promise: () => Promise, + options?: Options, +): Promise> => { + const { showGlobalError } = options || {} + const { rejectWithValue } = thunkAPI + + try { + return await promise() + } catch (e) { + return rejectWithValue({ error: e, showGlobalError }) + } +} diff --git a/src/features/app/app.slice.ts b/src/features/app/app.slice.ts index 75c10c2..376f309 100644 --- a/src/features/app/app.slice.ts +++ b/src/features/app/app.slice.ts @@ -1,9 +1,12 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit" +import { isAxiosError } from "axios" +import { toast } from "react-toastify" const initialAppState = { error: null as null | string, isLoading: true, isAppInitialized: false, + unhandledActions: [] as Array, } export type InitialAppState = typeof initialAppState @@ -25,7 +28,58 @@ const slice = createSlice({ 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 appActions = slice.actions diff --git a/src/features/auth/auth.slice.ts b/src/features/auth/auth.slice.ts index ab2bd47..023f953 100644 --- a/src/features/auth/auth.slice.ts +++ b/src/features/auth/auth.slice.ts @@ -3,56 +3,56 @@ import { AuthApi, LoginArgs, RegisterArgs, + RegisterResponse, User, } 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 = { REGISTER: "auth/register", } +const createThunkAction = ( + promise: (arg: A) => Promise, + transformPromise: (arg: R) => T, +) => { + return ( + arg: A, + thunkAPI: BaseThunkAPI, + ) => { + return thunkTryCatch(thunkAPI, () => promise(arg).then(transformPromise)) + } +} -const register = createAppAsyncThunk( +const register = createAppAsyncThunk<{ user: RegisterResponse }, RegisterArgs>( THUNK_PREFIXES.REGISTER, - (arg) => { - AuthApi.register(arg) - .then((res) => { - console.log(res) - }) - .catch((res) => { - console.error(res) - }) - }, + createThunkAction(AuthApi.register, (res) => ({ user: res.data })), ) const login = createAppAsyncThunk<{ user: User }, LoginArgs>( "auth/login", - async (arg) => { - const res = await AuthApi.login(arg) - return { user: res.data } - }, + createThunkAction(AuthApi.login, (res) => ({ user: res.data })), ) const slice = createSlice({ name: "auth", - initialState: { user: null as User | null, isLoading: false }, + initialState: { + user: null as User | null, + isAuthed: null as null | boolean, + isLoading: false, + }, reducers: { setUser: (state, action: PayloadAction<{ user: User }>) => { state.user = action.payload.user }, }, extraReducers: (builder) => { - builder.addCase(login.pending, (state) => { - state.isLoading = true - }) builder.addCase(login.fulfilled, (state, action) => { if (action.payload?.user) { state.user = action.payload.user - state.isLoading = false } }) - builder.addCase(login.rejected, (state) => { - state.isLoading = false - }) }, }) diff --git a/src/main.tsx b/src/main.tsx index d250666..cfb5323 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,13 @@ import React from "react" import ReactDOM from "react-dom/client" import App from "./App" import "./index.css" +import { store } from "@/app/store" +import { Provider } from "react-redux" ReactDOM.createRoot(document.getElementById("root")!).render( - + + + , )