first commit
This commit is contained in:
commit
3c1f32980d
35
.eslintrc.json
Normal file
35
.eslintrc.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"react-app",
|
||||||
|
"plugin:react/jsx-runtime",
|
||||||
|
"prettier"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": { "project": true, "tsconfigRootDir": "./" },
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"root": true,
|
||||||
|
"ignorePatterns": ["dist"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
|
2,
|
||||||
|
{ "fixStyle": "separate-type-imports" }
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-restricted-imports": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"paths": [
|
||||||
|
{
|
||||||
|
"name": "react-redux",
|
||||||
|
"importNames": ["useSelector", "useStore", "useDispatch"],
|
||||||
|
"message": "Please use pre-typed versions from `src/app/hooks.ts` instead."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{ "files": ["*.{c,m,}{t,j}s", "*.{t,j}sx"] },
|
||||||
|
{ "files": ["*{test,spec}.{t,j}s?(x)"], "env": { "jest": true } }
|
||||||
|
]
|
||||||
|
}
|
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
4
.prettierrc.json
Normal file
4
.prettierrc.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"arrowParens": "avoid"
|
||||||
|
}
|
27
README.md
Normal file
27
README.md
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# vite-template-redux
|
||||||
|
|
||||||
|
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)
|
14
index.html
Normal file
14
index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>React Redux App</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
8892
package-lock.json
generated
Normal file
8892
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "vite-template-redux",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"start": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint --fix .",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.0.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-redux": "^9.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/dom": "^9.3.4",
|
||||||
|
"@testing-library/jest-dom": "^6.2.0",
|
||||||
|
"@testing-library/react": "^14.1.2",
|
||||||
|
"@testing-library/user-event": "^14.5.2",
|
||||||
|
"@types/react": "^18.2.47",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-config-react-app": "^7.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
|
"jsdom": "^23.2.0",
|
||||||
|
"prettier": "^3.2.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.11",
|
||||||
|
"vitest": "^1.2.0"
|
||||||
|
}
|
||||||
|
}
|
39
src/App.css
Normal file
39
src/App.css
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
.App {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-logo {
|
||||||
|
height: 40vmin;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.App-logo {
|
||||||
|
animation: App-logo-float infinite 3s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-header {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: calc(10px + 2vmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-link {
|
||||||
|
color: rgb(112, 76, 182);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes App-logo-float {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
}
|
21
src/App.tsx
Normal file
21
src/App.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import "./App.css"
|
||||||
|
import { Counter } from "./features/counter/Counter"
|
||||||
|
import { Quotes } from "./features/quotes/Quotes"
|
||||||
|
import logo from "./logo.svg"
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
<header className="App-header">
|
||||||
|
<img src={logo} className="App-logo" alt="logo" />
|
||||||
|
<Counter />
|
||||||
|
<p>
|
||||||
|
Edit <code>src/App.tsx</code> and save to reload.
|
||||||
|
</p>
|
||||||
|
<Quotes />
|
||||||
|
</header>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
6
src/app/createAppSlice.ts
Normal file
6
src/app/createAppSlice.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { asyncThunkCreator, buildCreateSlice } from "@reduxjs/toolkit"
|
||||||
|
|
||||||
|
// `buildCreateSlice` allows us to create a slice with async thunks.
|
||||||
|
export const createAppSlice = buildCreateSlice({
|
||||||
|
creators: { asyncThunk: asyncThunkCreator },
|
||||||
|
})
|
12
src/app/hooks.ts
Normal file
12
src/app/hooks.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// This file serves as a central hub for re-exporting pre-typed Redux hooks.
|
||||||
|
// These imports are restricted elsewhere to ensure consistent
|
||||||
|
// usage of typed hooks throughout the application.
|
||||||
|
// We disable the ESLint rule here because this is the designated place
|
||||||
|
// for importing and re-exporting the typed versions of hooks.
|
||||||
|
/* eslint-disable @typescript-eslint/no-restricted-imports */
|
||||||
|
import { useDispatch, useSelector } from "react-redux"
|
||||||
|
import type { AppDispatch, RootState } from "./store"
|
||||||
|
|
||||||
|
// Use throughout your app instead of plain `useDispatch` and `useSelector`
|
||||||
|
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
||||||
|
export const useAppSelector = useSelector.withTypes<RootState>()
|
42
src/app/store.ts
Normal file
42
src/app/store.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import type { Action, ThunkAction } from "@reduxjs/toolkit"
|
||||||
|
import { combineSlices, configureStore } from "@reduxjs/toolkit"
|
||||||
|
import { setupListeners } from "@reduxjs/toolkit/query"
|
||||||
|
import { counterSlice } from "../features/counter/counterSlice"
|
||||||
|
import { quotesApiSlice } from "../features/quotes/quotesApiSlice"
|
||||||
|
|
||||||
|
// `combineSlices` automatically combines the reducers using
|
||||||
|
// their `reducerPath`s, therefore we no longer need to call `combineReducers`.
|
||||||
|
const rootReducer = combineSlices(counterSlice, quotesApiSlice)
|
||||||
|
// Infer the `RootState` type from the root reducer
|
||||||
|
export type RootState = ReturnType<typeof rootReducer>
|
||||||
|
|
||||||
|
// The store setup is wrapped in `makeStore` to allow reuse
|
||||||
|
// when setting up tests that need the same store config
|
||||||
|
export const makeStore = (preloadedState?: Partial<RootState>) => {
|
||||||
|
const store = configureStore({
|
||||||
|
reducer: rootReducer,
|
||||||
|
// Adding the api middleware enables caching, invalidation, polling,
|
||||||
|
// and other useful features of `rtk-query`.
|
||||||
|
middleware: getDefaultMiddleware => {
|
||||||
|
return getDefaultMiddleware().concat(quotesApiSlice.middleware)
|
||||||
|
},
|
||||||
|
preloadedState,
|
||||||
|
})
|
||||||
|
// configure listeners using the provided defaults
|
||||||
|
// optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors
|
||||||
|
setupListeners(store.dispatch)
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
export const store = makeStore()
|
||||||
|
|
||||||
|
// Infer the type of `store`
|
||||||
|
export type AppStore = typeof store
|
||||||
|
// Infer the `AppDispatch` type from the store itself
|
||||||
|
export type AppDispatch = AppStore["dispatch"]
|
||||||
|
export type AppThunk<ThunkReturnType = void> = ThunkAction<
|
||||||
|
ThunkReturnType,
|
||||||
|
RootState,
|
||||||
|
unknown,
|
||||||
|
Action
|
||||||
|
>
|
81
src/features/counter/Counter.module.css
Normal file
81
src/features/counter/Counter.module.css
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > button {
|
||||||
|
margin-left: 4px;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:not(:last-child) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-size: 78px;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-family: "Courier New", Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
appearance: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 32px;
|
||||||
|
padding-left: 12px;
|
||||||
|
padding-right: 12px;
|
||||||
|
outline: none;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
color: rgb(112, 76, 182);
|
||||||
|
padding-bottom: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: rgba(112, 76, 182, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textbox {
|
||||||
|
font-size: 32px;
|
||||||
|
padding: 2px;
|
||||||
|
width: 64px;
|
||||||
|
text-align: center;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover,
|
||||||
|
.button:focus {
|
||||||
|
border: 2px solid rgba(112, 76, 182, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
background-color: rgba(112, 76, 182, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asyncButton {
|
||||||
|
composes: button;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asyncButton:after {
|
||||||
|
content: "";
|
||||||
|
background-color: rgba(112, 76, 182, 0.15);
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
width 1s linear,
|
||||||
|
opacity 0.5s ease 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asyncButton:active:after {
|
||||||
|
width: 0%;
|
||||||
|
opacity: 1;
|
||||||
|
transition: 0s;
|
||||||
|
}
|
78
src/features/counter/Counter.tsx
Normal file
78
src/features/counter/Counter.tsx
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { useAppDispatch, useAppSelector } from "../../app/hooks"
|
||||||
|
import styles from "./Counter.module.css"
|
||||||
|
import {
|
||||||
|
decrement,
|
||||||
|
increment,
|
||||||
|
incrementAsync,
|
||||||
|
incrementByAmount,
|
||||||
|
incrementIfOdd,
|
||||||
|
selectCount,
|
||||||
|
selectStatus,
|
||||||
|
} from "./counterSlice"
|
||||||
|
|
||||||
|
export const Counter = () => {
|
||||||
|
const dispatch = useAppDispatch()
|
||||||
|
const count = useAppSelector(selectCount)
|
||||||
|
const status = useAppSelector(selectStatus)
|
||||||
|
const [incrementAmount, setIncrementAmount] = useState("2")
|
||||||
|
|
||||||
|
const incrementValue = Number(incrementAmount) || 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
aria-label="Decrement value"
|
||||||
|
onClick={() => dispatch(decrement())}
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span aria-label="Count" className={styles.value}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
aria-label="Increment value"
|
||||||
|
onClick={() => dispatch(increment())}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.row}>
|
||||||
|
<input
|
||||||
|
className={styles.textbox}
|
||||||
|
aria-label="Set increment amount"
|
||||||
|
value={incrementAmount}
|
||||||
|
type="number"
|
||||||
|
onChange={e => {
|
||||||
|
setIncrementAmount(e.target.value)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={() => dispatch(incrementByAmount(incrementValue))}
|
||||||
|
>
|
||||||
|
Add Amount
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.asyncButton}
|
||||||
|
disabled={status !== "idle"}
|
||||||
|
onClick={() => dispatch(incrementAsync(incrementValue))}
|
||||||
|
>
|
||||||
|
Add Async
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.button}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(incrementIfOdd(incrementValue))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add If Odd
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
6
src/features/counter/counterAPI.ts
Normal file
6
src/features/counter/counterAPI.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// A mock function to mimic making an async request for data
|
||||||
|
export const fetchCount = (amount = 1) => {
|
||||||
|
return new Promise<{ data: number }>(resolve =>
|
||||||
|
setTimeout(() => resolve({ data: amount }), 500),
|
||||||
|
)
|
||||||
|
}
|
58
src/features/counter/counterSlice.test.ts
Normal file
58
src/features/counter/counterSlice.test.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import type { AppStore } from "../../app/store"
|
||||||
|
import { makeStore } from "../../app/store"
|
||||||
|
import type { CounterSliceState } from "./counterSlice"
|
||||||
|
import {
|
||||||
|
counterSlice,
|
||||||
|
decrement,
|
||||||
|
increment,
|
||||||
|
incrementByAmount,
|
||||||
|
selectCount,
|
||||||
|
} from "./counterSlice"
|
||||||
|
|
||||||
|
interface LocalTestContext {
|
||||||
|
store: AppStore
|
||||||
|
}
|
||||||
|
|
||||||
|
describe<LocalTestContext>("counter reducer", it => {
|
||||||
|
beforeEach<LocalTestContext>(context => {
|
||||||
|
const initialState: CounterSliceState = {
|
||||||
|
value: 3,
|
||||||
|
status: "idle",
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = makeStore({ counter: initialState })
|
||||||
|
|
||||||
|
context.store = store
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle initial state", () => {
|
||||||
|
expect(counterSlice.reducer(undefined, { type: "unknown" })).toStrictEqual({
|
||||||
|
value: 0,
|
||||||
|
status: "idle",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle increment", ({ store }) => {
|
||||||
|
expect(selectCount(store.getState())).toBe(3)
|
||||||
|
|
||||||
|
store.dispatch(increment())
|
||||||
|
|
||||||
|
expect(selectCount(store.getState())).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle decrement", ({ store }) => {
|
||||||
|
expect(selectCount(store.getState())).toBe(3)
|
||||||
|
|
||||||
|
store.dispatch(decrement())
|
||||||
|
|
||||||
|
expect(selectCount(store.getState())).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle incrementByAmount", ({ store }) => {
|
||||||
|
expect(selectCount(store.getState())).toBe(3)
|
||||||
|
|
||||||
|
store.dispatch(incrementByAmount(2))
|
||||||
|
|
||||||
|
expect(selectCount(store.getState())).toBe(5)
|
||||||
|
})
|
||||||
|
})
|
89
src/features/counter/counterSlice.ts
Normal file
89
src/features/counter/counterSlice.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import type { PayloadAction } from "@reduxjs/toolkit"
|
||||||
|
import { createAppSlice } from "../../app/createAppSlice"
|
||||||
|
import type { AppThunk } from "../../app/store"
|
||||||
|
import { fetchCount } from "./counterAPI"
|
||||||
|
|
||||||
|
export interface CounterSliceState {
|
||||||
|
value: number
|
||||||
|
status: "idle" | "loading" | "failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: CounterSliceState = {
|
||||||
|
value: 0,
|
||||||
|
status: "idle",
|
||||||
|
}
|
||||||
|
|
||||||
|
// If you are not using async thunks you can use the standalone `createSlice`.
|
||||||
|
export const counterSlice = createAppSlice({
|
||||||
|
name: "counter",
|
||||||
|
// `createSlice` will infer the state type from the `initialState` argument
|
||||||
|
initialState,
|
||||||
|
// The `reducers` field lets us define reducers and generate associated actions
|
||||||
|
reducers: create => ({
|
||||||
|
increment: create.reducer(state => {
|
||||||
|
// Redux Toolkit allows us to write "mutating" logic in reducers. It
|
||||||
|
// doesn't actually mutate the state because it uses the Immer library,
|
||||||
|
// which detects changes to a "draft state" and produces a brand new
|
||||||
|
// immutable state based off those changes
|
||||||
|
state.value += 1
|
||||||
|
}),
|
||||||
|
decrement: create.reducer(state => {
|
||||||
|
state.value -= 1
|
||||||
|
}),
|
||||||
|
// Use the `PayloadAction` type to declare the contents of `action.payload`
|
||||||
|
incrementByAmount: create.reducer(
|
||||||
|
(state, action: PayloadAction<number>) => {
|
||||||
|
state.value += action.payload
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// The function below is called a thunk and allows us to perform async logic. It
|
||||||
|
// can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This
|
||||||
|
// will call the thunk with the `dispatch` function as the first argument. Async
|
||||||
|
// code can then be executed and other actions can be dispatched. Thunks are
|
||||||
|
// typically used to make async requests.
|
||||||
|
incrementAsync: create.asyncThunk(
|
||||||
|
async (amount: number) => {
|
||||||
|
const response = await fetchCount(amount)
|
||||||
|
// The value we return becomes the `fulfilled` action payload
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pending: state => {
|
||||||
|
state.status = "loading"
|
||||||
|
},
|
||||||
|
fulfilled: (state, action) => {
|
||||||
|
state.status = "idle"
|
||||||
|
state.value += action.payload
|
||||||
|
},
|
||||||
|
rejected: state => {
|
||||||
|
state.status = "failed"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
// You can define your selectors here. These selectors receive the slice
|
||||||
|
// state as their first argument.
|
||||||
|
selectors: {
|
||||||
|
selectCount: counter => counter.value,
|
||||||
|
selectStatus: counter => counter.status,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Action creators are generated for each case reducer function.
|
||||||
|
export const { decrement, increment, incrementByAmount, incrementAsync } =
|
||||||
|
counterSlice.actions
|
||||||
|
|
||||||
|
// Selectors returned by `slice.selectors` take the root state as their first argument.
|
||||||
|
export const { selectCount, selectStatus } = counterSlice.selectors
|
||||||
|
|
||||||
|
// We can also write thunks by hand, which may contain both sync and async logic.
|
||||||
|
// Here's an example of conditionally dispatching actions based on current state.
|
||||||
|
export const incrementIfOdd =
|
||||||
|
(amount: number): AppThunk =>
|
||||||
|
(dispatch, getState) => {
|
||||||
|
const currentValue = selectCount(getState())
|
||||||
|
|
||||||
|
if (currentValue % 2 === 1 || currentValue % 2 === -1) {
|
||||||
|
dispatch(incrementByAmount(amount))
|
||||||
|
}
|
||||||
|
}
|
20
src/features/quotes/Quotes.module.css
Normal file
20
src/features/quotes/Quotes.module.css
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
.select {
|
||||||
|
font-size: 25px;
|
||||||
|
padding: 5px;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
size: 50;
|
||||||
|
outline: none;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
color: rgb(112, 76, 182);
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: rgba(112, 76, 182, 0.1);
|
||||||
|
border-radius: 5px;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
59
src/features/quotes/Quotes.tsx
Normal file
59
src/features/quotes/Quotes.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import styles from "./Quotes.module.css"
|
||||||
|
import { useGetQuotesQuery } from "./quotesApiSlice"
|
||||||
|
|
||||||
|
const options = [5, 10, 20, 30]
|
||||||
|
|
||||||
|
export const Quotes = () => {
|
||||||
|
const [numberOfQuotes, setNumberOfQuotes] = useState(10)
|
||||||
|
// Using a query hook automatically fetches data and returns query values
|
||||||
|
const { data, isError, isLoading, isSuccess } =
|
||||||
|
useGetQuotesQuery(numberOfQuotes)
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>There was an error!!!</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Loading...</h1>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<h3>Select the Quantity of Quotes to Fetch:</h3>
|
||||||
|
<select
|
||||||
|
className={styles.select}
|
||||||
|
value={numberOfQuotes}
|
||||||
|
onChange={e => {
|
||||||
|
setNumberOfQuotes(Number(e.target.value))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{options.map(option => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{data.quotes.map(({ author, quote, id }) => (
|
||||||
|
<blockquote key={id}>
|
||||||
|
“{quote}”
|
||||||
|
<footer>
|
||||||
|
<cite>{author}</cite>
|
||||||
|
</footer>
|
||||||
|
</blockquote>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
38
src/features/quotes/quotesApiSlice.ts
Normal file
38
src/features/quotes/quotesApiSlice.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
// Need to use the React-specific entry point to import `createApi`
|
||||||
|
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"
|
||||||
|
|
||||||
|
interface Quote {
|
||||||
|
id: number
|
||||||
|
quote: string
|
||||||
|
author: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuotesApiResponse {
|
||||||
|
quotes: Quote[]
|
||||||
|
total: number
|
||||||
|
skip: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define a service using a base URL and expected endpoints
|
||||||
|
export const quotesApiSlice = createApi({
|
||||||
|
baseQuery: fetchBaseQuery({ baseUrl: "https://dummyjson.com/quotes" }),
|
||||||
|
reducerPath: "quotesApi",
|
||||||
|
// Tag types are used for caching and invalidation.
|
||||||
|
tagTypes: ["Quotes"],
|
||||||
|
endpoints: build => ({
|
||||||
|
// Supply generics for the return type (in this case `QuotesApiResponse`)
|
||||||
|
// and the expected query argument. If there is no argument, use `void`
|
||||||
|
// for the argument type instead.
|
||||||
|
getQuotes: build.query<QuotesApiResponse, number>({
|
||||||
|
query: (limit = 10) => `?limit=${limit}`,
|
||||||
|
// `providesTags` determines which 'tag' is attached to the
|
||||||
|
// cached data returned by the query.
|
||||||
|
providesTags: (result, error, id) => [{ type: "Quotes", id }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hooks are auto-generated by RTK-Query
|
||||||
|
// Same as `quotesApiSlice.endpoints.getQuotes.useQuery`
|
||||||
|
export const { useGetQuotesQuery } = quotesApiSlice
|
13
src/index.css
Normal file
13
src/index.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||||
|
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||||
|
monospace;
|
||||||
|
}
|
1
src/logo.svg
Normal file
1
src/logo.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g fill="#764ABC"><path d="M65.6 65.4c2.9-.3 5.1-2.8 5-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 1.5.7 2.8 1.6 3.7-3.4 6.7-8.6 11.6-16.4 15.7-5.3 2.8-10.8 3.8-16.3 3.1-4.5-.6-8-2.6-10.2-5.9-3.2-4.9-3.5-10.2-.8-15.5 1.9-3.8 4.9-6.6 6.8-8-.4-1.3-1-3.5-1.3-5.1-14.5 10.5-13 24.7-8.6 31.4 3.3 5 10 8.1 17.4 8.1 2 0 4-.2 6-.7 12.8-2.5 22.5-10.1 28-21.4z"/><path d="M83.2 53c-7.6-8.9-18.8-13.8-31.6-13.8H50c-.9-1.8-2.8-3-4.9-3h-.2c-3.1.1-5.5 2.7-5.4 5.8.1 3 2.6 5.4 5.6 5.4h.2c2.2-.1 4.1-1.5 4.9-3.4H52c7.6 0 14.8 2.2 21.3 6.5 5 3.3 8.6 7.6 10.6 12.8 1.7 4.2 1.6 8.3-.2 11.8-2.8 5.3-7.5 8.2-13.7 8.2-4 0-7.8-1.2-9.8-2.1-1.1 1-3.1 2.6-4.5 3.6 4.3 2 8.7 3.1 12.9 3.1 9.6 0 16.7-5.3 19.4-10.6 2.9-5.8 2.7-15.8-4.8-24.3z"/><path d="M32.4 67.1c.1 3 2.6 5.4 5.6 5.4h.2c3.1-.1 5.5-2.7 5.4-5.8-.1-3-2.6-5.4-5.6-5.4h-.2c-.2 0-.5 0-.7.1-4.1-6.8-5.8-14.2-5.2-22.2.4-6 2.4-11.2 5.9-15.5 2.9-3.7 8.5-5.5 12.3-5.6 10.6-.2 15.1 13 15.4 18.3 1.3.3 3.5 1 5 1.5-1.2-16.2-11.2-24.6-20.8-24.6-9 0-17.3 6.5-20.6 16.1-4.6 12.8-1.6 25.1 4 34.8-.5.7-.8 1.8-.7 2.9z"/></g></svg>
|
After Width: | Height: | Size: 1.1 KiB |
24
src/main.tsx
Normal file
24
src/main.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { createRoot } from "react-dom/client"
|
||||||
|
import { Provider } from "react-redux"
|
||||||
|
import App from "./App"
|
||||||
|
import { store } from "./app/store"
|
||||||
|
import "./index.css"
|
||||||
|
|
||||||
|
const container = document.getElementById("root")
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
const root = createRoot(container)
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<Provider store={store}>
|
||||||
|
<App />
|
||||||
|
</Provider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"Root element with ID 'root' was not found in the document. Ensure there is a corresponding HTML element with the ID 'root' in your HTML file.",
|
||||||
|
)
|
||||||
|
}
|
1
src/setupTests.ts
Normal file
1
src/setupTests.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest"
|
65
src/utils/test-utils.tsx
Normal file
65
src/utils/test-utils.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import type { RenderOptions } from "@testing-library/react"
|
||||||
|
import { render } from "@testing-library/react"
|
||||||
|
import userEvent from "@testing-library/user-event"
|
||||||
|
import type { PropsWithChildren, ReactElement } from "react"
|
||||||
|
import { Provider } from "react-redux"
|
||||||
|
import type { AppStore, RootState } from "../app/store"
|
||||||
|
import { makeStore } from "../app/store"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This type extends the default options for
|
||||||
|
* React Testing Library's render function. It allows for
|
||||||
|
* additional configuration such as specifying an initial Redux state and
|
||||||
|
* a custom store instance.
|
||||||
|
*/
|
||||||
|
interface ExtendedRenderOptions extends Omit<RenderOptions, "queries"> {
|
||||||
|
/**
|
||||||
|
* Defines a specific portion or the entire initial state for the Redux store.
|
||||||
|
* This is particularly useful for initializing the state in a
|
||||||
|
* controlled manner during testing, allowing components to be rendered
|
||||||
|
* with predetermined state conditions.
|
||||||
|
*/
|
||||||
|
preloadedState?: Partial<RootState>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allows the use of a specific Redux store instance instead of a
|
||||||
|
* default or global store. This flexibility is beneficial when
|
||||||
|
* testing components with unique store requirements or when isolating
|
||||||
|
* tests from a global store state. The custom store should be configured
|
||||||
|
* to match the structure and middleware of the store used by the application.
|
||||||
|
*
|
||||||
|
* @default makeStore(preloadedState)
|
||||||
|
*/
|
||||||
|
store?: AppStore
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the given React element with Redux Provider and custom store.
|
||||||
|
* This function is useful for testing components that are connected to the Redux store.
|
||||||
|
*
|
||||||
|
* @param ui - The React component or element to render.
|
||||||
|
* @param extendedRenderOptions - Optional configuration options for rendering. This includes `preloadedState` for initial Redux state and `store` for a specific Redux store instance. Any additional properties are passed to React Testing Library's render function.
|
||||||
|
* @returns An object containing the Redux store used in the render, User event API for simulating user interactions in tests, and all of React Testing Library's query functions for testing the component.
|
||||||
|
*/
|
||||||
|
export const renderWithProviders = (
|
||||||
|
ui: ReactElement,
|
||||||
|
extendedRenderOptions: ExtendedRenderOptions = {},
|
||||||
|
) => {
|
||||||
|
const {
|
||||||
|
preloadedState = {},
|
||||||
|
// Automatically create a store instance if no store was passed in
|
||||||
|
store = makeStore(preloadedState),
|
||||||
|
...renderOptions
|
||||||
|
} = extendedRenderOptions
|
||||||
|
|
||||||
|
const Wrapper = ({ children }: PropsWithChildren) => (
|
||||||
|
<Provider store={store}>{children}</Provider>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Return an object with the store and all of RTL's query functions
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
user: userEvent.setup(),
|
||||||
|
...render(ui, { wrapper: Wrapper, ...renderOptions }),
|
||||||
|
}
|
||||||
|
}
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["vitest/globals"]
|
||||||
|
},
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
9
tsconfig.node.json
Normal file
9
tsconfig.node.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
16
vite.config.ts
Normal file
16
vite.config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from "vitest/config"
|
||||||
|
import react from "@vitejs/plugin-react"
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
open: true,
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: "src/setupTests",
|
||||||
|
mockReset: true,
|
||||||
|
},
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user