feat: update project

This commit is contained in:
Philippe Torrel
2026-09-01 08:47:49 +02:00
parent 0bdbf245ee
commit 2aef8b91bd
10 changed files with 491 additions and 123 deletions

View File

@@ -15,16 +15,16 @@
"@eslint/js": "^10.0.1",
"@tailwindcss/forms": "^0.5.11",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/ui": "^4.1.11",
"autoprefixer": "^10.5.4",
"eslint": "^10.9.0",
"eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"eslint-plugin-react-refresh": "^0.5.5",
"globals": "^17.11.0",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",
@@ -1223,9 +1223,9 @@
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
"integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"version": "16.3.3",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz",
"integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1332,9 +1332,9 @@
}
},
"node_modules/@vitejs/plugin-react": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz",
"integrity": "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw==",
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
"integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2058,9 +2058,9 @@
}
},
"node_modules/eslint": {
"version": "10.9.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.0.tgz",
"integrity": "sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw==",
"version": "10.9.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz",
"integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==",
"dev": true,
"license": "MIT",
"workspaces": [
@@ -2137,9 +2137,9 @@
}
},
"node_modules/eslint-plugin-react-refresh": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.4.tgz",
"integrity": "sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==",
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.5.tgz",
"integrity": "sha512-vG7yLURXNvCHy0FBdbZRwIu0BLPJMlUUJS2Ep7ud9w1YCLftFZtuEjyjhym0Qq9yuZ6LJUitNlu/hMk0gakXAw==",
"dev": true,
"license": "MIT",
"peerDependencies": {

View File

@@ -19,16 +19,16 @@
"@eslint/js": "^10.0.1",
"@tailwindcss/forms": "^0.5.11",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitest/ui": "^4.1.11",
"autoprefixer": "^10.5.4",
"eslint": "^10.9.0",
"eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"eslint-plugin-react-refresh": "^0.5.5",
"globals": "^17.11.0",
"jsdom": "^30.0.1",
"postcss": "^8.5.26",

View File

@@ -18,17 +18,13 @@ describe('GoalSetting Component', () => {
handleChange={() => {}}
handleCheckboxChange={() => {}}
errors={{}}
/>
/>,
);
// Use getByRole to target the fieldset for "What are your goals?"
expect(
screen.getByRole('group', { name: /What are your goals\?/i })
).toBeInTheDocument();
expect(screen.getByRole('group', { name: /What are your goals\?/i })).toBeInTheDocument();
expect(
screen.getByLabelText(/How much time can you commit each day\?/i)
).toBeInTheDocument();
expect(screen.getByLabelText(/How much time can you commit each day\?/i)).toBeInTheDocument();
});
it('displays validation errors for empty fields', () => {
@@ -38,21 +34,10 @@ describe('GoalSetting Component', () => {
};
const errors = validate(formData, step);
render(
<GoalSetting
formData={formData}
handleChange={() => {}}
handleCheckboxChange={() => {}}
errors={errors}
/>
);
render(<GoalSetting formData={formData} handleChange={() => {}} handleCheckboxChange={() => {}} errors={errors} />);
expect(
screen.getByText(/At least one goal is required/i)
).toBeInTheDocument();
expect(
screen.getByText(/Time Commitment is required/i)
).toBeInTheDocument();
expect(screen.getByText(/At least one goal is required/i)).toBeInTheDocument();
expect(screen.getByText(/Time Commitment is required/i)).toBeInTheDocument();
});
it('displays no validation errors for valid data', () => {
@@ -62,14 +47,7 @@ describe('GoalSetting Component', () => {
};
const errors = validate(formData, step);
render(
<GoalSetting
formData={formData}
handleChange={() => {}}
handleCheckboxChange={() => {}}
errors={errors}
/>
);
render(<GoalSetting formData={formData} handleChange={() => {}} handleCheckboxChange={() => {}} errors={errors} />);
expect(screen.queryByText(/is required/i)).not.toBeInTheDocument();
});
@@ -79,9 +57,7 @@ describe('GoalSetting Component', () => {
let errors = validate(formData, step);
const handleCheckboxChangeTest = (field, id, isChecked) => {
const updatedGoals = isChecked
? [...formData[field], id]
: formData[field].filter((goal) => goal !== id);
const updatedGoals = isChecked ? [...formData[field], id] : formData[field].filter((goal) => goal !== id);
formData = { ...formData, [field]: updatedGoals };
errors = validate(formData, step);
@@ -93,7 +69,7 @@ describe('GoalSetting Component', () => {
handleChange={() => {}}
handleCheckboxChange={handleCheckboxChangeTest}
errors={errors}
/>
/>,
);
const weightLossCheckbox = screen.getByLabelText(/Weight Loss/i);
@@ -106,13 +82,11 @@ describe('GoalSetting Component', () => {
handleChange={() => {}}
handleCheckboxChange={handleCheckboxChangeTest}
errors={errors}
/>
/>,
);
expect(weightLossCheckbox).toBeChecked();
expect(
screen.queryByText(/At least one goal is required/i)
).not.toBeInTheDocument();
expect(screen.queryByText(/At least one goal is required/i)).not.toBeInTheDocument();
await userEvent.click(muscleGainCheckbox);
rerender(
@@ -121,7 +95,7 @@ describe('GoalSetting Component', () => {
handleChange={() => {}}
handleCheckboxChange={handleCheckboxChangeTest}
errors={errors}
/>
/>,
);
expect(muscleGainCheckbox).toBeChecked();
@@ -141,33 +115,25 @@ describe('GoalSetting Component', () => {
const { rerender } = render(
<GoalSetting
formData={formData}
handleChange={({ target }) =>
handleChangeTest(target.name, target.value)
}
handleChange={({ target }) => handleChangeTest(target.name, target.value)}
handleCheckboxChange={() => {}}
errors={errors}
/>
/>,
);
const timeCommitmentSelect = screen.getByLabelText(
/How much time can you commit each day\?/i
);
await userEvent.selectOptions(timeCommitmentSelect, '45');
const timeCommitmentSelect = screen.getByLabelText(/How much time can you commit each day\?/i);
await userEvent.selectOptions(timeCommitmentSelect, '30');
rerender(
<GoalSetting
formData={formData}
handleChange={({ target }) =>
handleChangeTest(target.name, target.value)
}
handleChange={({ target }) => handleChangeTest(target.name, target.value)}
handleCheckboxChange={() => {}}
errors={errors}
/>
/>,
);
expect(timeCommitmentSelect).toHaveValue('30');
expect(
screen.queryByText(/Time Commitment is required/i)
).not.toBeInTheDocument();
expect(screen.queryByText(/Time Commitment is required/i)).not.toBeInTheDocument();
});
});

View File

@@ -1,3 +1,46 @@
## Map
Ein **`Array`** ist eine geordnete Liste indizierter Werte (Zugriff über numerische Indizes `0, 1, 2...`), während eine **`Map`** eine geordnete Sammlung von **Schlüssel-Wert-Paaren (Key-Value Pairs)** ist, bei der beliebige Datentypen als Schlüssel dienen können.
| Merkmal | `Array` (`[]` / `new Array()`) | `Map` (`new Map()`) |
| --- | --- | --- |
| **Datenstruktur** | Geordnete Liste von Elementen | Geordnete Schlüssel-Wert-Paare |
| **Schlüssel / Indizes** | Ausschließlich sequentielle Integer (`0, 1, 2...`) | Beliebige Typen (Objekte, Funktionen, Strings, Booleans) |
| **Elementanzahl** | `array.length` | `map.size` |
| **Zugriff / Abfrage** | Index `arr[0]` oder Suche `arr.find(...)` ($O(n)$) | Direkter Lookup `map.get(key)` ($O(1)$) |
| **Vorhandensein prüfen** | `arr.includes(val)` ($O(n)$) | `map.has(key)` ($O(1)$) |
| **Einfügen / Löschen** | `push()`, `splice()` (Verschiebung im Speicher) | `set(key, val)`, `delete(key)` (Optimiert für Keys) |
---
**Wann macht `Map` Sinn?**
* **Häufiges Nachschlagen (Lookups):** Wenn du Werte anhand eines eindeutigen Identifikators (ID, Slug, Token) extrem schnell abrufen musst ($O(1)$ statt Array-Iteration mit `find()`).
* **Objekte oder Funktionen als Schlüssel:** Im Gegensatz zu regulären JS-Objekten (die Keys immer in Strings/Symbols umwandeln) akzeptiert `Map` Referenzen als Key:
```javascript
const userMap = new Map();
const userObj = { id: 42 };
userMap.set(userObj, { role: "admin", active: true });
console.log(userMap.get(userObj)); // { role: "admin", active: true }
```
* **Häufiges Hinzufügen und Entfernen:** `Map` ist intern für dynamisches Hinzufügen (`set`) und Löschen (`delete`) optimiert. Beim Entfernen aus einem Array via `splice()` müssen alle nachfolgenden Indizes neu berechnet werden.
* **Saubere Schlüssel ohne Prototype-Pollution:** Ein normales Objekt `{}` erbt Standard-Methoden wie `toString`. Eine `Map` enthält strikt nur die Schlüssel, die du selbst definierst.
---
**Wann bleibt `Array` die bessere Wahl?**
* Wenn die **Reihenfolge und Indexposition** im Vordergrund stehen (z. B. Sortieren, Paginieren).
* Wenn du Transformations-Pipelines wie `.map()`, `.filter()`, `.reduce()` oder `.flat()` brauchst.
* Für homogene Datenlisten, die direkt als JSON serialisiert werden sollen (`JSON.stringify()` unterstützt Arrays nativ, Maps müssen vorher konvertiert werden).
---
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.

View File

@@ -1,12 +1,19 @@
import { useCallback, useMemo } from 'react';
import RowProduct from './content/RowProduct';
import { ProductsProvider, useProductsState } from '../../stores/products';
import { ProductsProvider, useProductsActions, useProductsState } from '../../stores/products';
import { FaSpinner } from 'react-icons/fa6';
// import { useContext } from 'react';
// import { ProductsStateContext } from '../../stores/products/ProductsStore';
const TableProductsView = (props) => {
const { items } = useProductsState(); // const {items} = useContext(ProductsStateContext);
// const state = useContext(ProductsStateContext);
// const { items, loading, error } = state;
const { items, loading, error } = useProductsState(); // const {items} = useContext(ProductsStateContext);
const { loadProducts } = useProductsActions();
// const dispatch = useProductsDispatch() // const dispatch = useContext(ProductsDispatchContext);
//const [{ items }, dispatch] = useReducer(reducer, initialState(props));
// EventHandler ============
@@ -26,16 +33,28 @@ const TableProductsView = (props) => {
}, 0);
}, [items]);
// useCallback weiter Einsatzbereich in Kombination mit ref- Attribut
const theadRef = useCallback((el) => {
if (el) console.log(el);
}, []);
if (loading && items.length === 0) {
return (
<p className="text-muted alert alert-info">
Produkte werden geladen....
<FaSpinner className="fa-spin" />
</p>
);
}
return (
<div className="m-table-products table-products">
<div className="container py-2">
{error && (
<div className="alert alert-danger" role="alert">
<p className="mb-2">{error}</p>
<button type="button" className="btn btn-sm btn-dark" onClick={loadProducts}>
Erneut laden
</button>
</div>
)}
<table className="table table-striped">
<thead className="table-dark" ref={theadRef}>
<thead className="table-dark">
<tr>
<th>SKU</th>
<th>Stock</th>

View File

@@ -1,34 +1,281 @@
import { createContext, useCallback, useContext, useReducer } from 'react';
import { PRODUCTS_ACTIONS, productsReducer } from './reducer';
import { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react';
import { createInitialState } from './initialState';
import { PRODUCTS_ACTIONS, productsReducer } from './reducer';
const ProductsStateContext = createContext(null);
import { createProduct, deleteProduct, fetchProducts, getApiErrorMessage, patchProduct, updateProduct } from './api';
// Zeitverzögerung für das Debouncing (in ms), bevor Änderungen an den Server gesendet werden.
const PERSIST_DELAY_MS = 400;
export const ProductsStateContext = createContext(null);
const ProductsDispatchContext = createContext(null);
// const ProductsContext = createContext(null);
const ProductsActionsContext = createContext(null);
export const ProductsProvider = (props) => {
const { children, items = [] } = props;
const { children, items = [], autoLoad = true } = props;
const [state, dispatch] = useReducer(productsReducer, items, createInitialState);
const [state, dispatch] = useReducer(
productsReducer,
items,
createInitialState, // Lazy Initialization: Wird nur beim ersten Mounten ausgeführt.
);
/**
* WARUM `useRef(new Map())` FÜR PATCHES & TIMER?
*
* 1. Überleben von Rerendern: `useRef` behält Werte über Renders hinweg stabil,
* ohne bei einer Änderung ein neues Rerendern der Komponente auszulösen.
* 2. Granularität pro Produkt: Da wir `Map` nutzen (Key = Product-ID), verwalten wir
* ausstehende Patches und Timer isoliert pro Item. Ein Ändern von Produkt A bricht
* den Timer für Produkt B nicht ab.
* 3. Kein API-Spam (Debouncing + Merging): Schnelle Änderungen (z. B. Tippen im Input-Feld)
* werden lokal gepuffert und erst nach Ablauf der Inaktivitätszeit als gebündelter Request gesendet.
*/
const pendingPatches = useRef(new Map()); // Speichert: Map<productId, { stock?: number, price?: number }>
const persistTimers = useRef(new Map()); // Speichert: Map<productId, setTimeoutId>
useEffect(() => {
if (!autoLoad) {
return undefined;
}
const controller = new AbortController();
const load = async () => {
dispatch({ type: PRODUCTS_ACTIONS.SET_LOADING, payload: true });
try {
const loadedItems = await fetchProducts({ signal: controller.signal });
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEMS, payload: loadedItems });
} catch (error) {
if (controller.signal.aborted) {
return;
}
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
}
};
load();
return () => {
controller.abort(); // Bricht laufende Requests ab, falls die Komponente unmountet.
};
}, [autoLoad]);
const loadProducts = useCallback(async () => {
dispatch({ type: PRODUCTS_ACTIONS.SET_LOADING, payload: true });
try {
const loadedItems = await fetchProducts();
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEMS, payload: loadedItems });
} catch (error) {
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
}
}, []);
/**
* `flushPatch(id)`
*
* Zweck: Führt das tatsächliche Senden der gepufferten Änderungen an das Backend aus.
*
* Ablauf:
* 1. Liest und entfernt den gepufferten Patch für die Produkt-ID aus `pendingPatches`.
* 2. Löscht ggf. den aktiven Timer für dieses Produkt aus `persistTimers`.
* 3. Sendet den `patchProduct`-Request an die API.
* 4. Bei Erfolg: Aktualisiert das Produkt im lokalen Reducer-State mit den Server-Daten.
* 5. Bei Fehler: Setzt eine Fehlermeldung und ruft `loadProducts()` auf, um inkonsistente
* optimistische UI-Zustände mit dem Serverzustand zu synchronisieren (Rollback).
*/
const flushPatch = useCallback(
async (id) => {
const patch = pendingPatches.current.get(id);
pendingPatches.current.delete(id);
if (persistTimers.current.has(id)) {
clearTimeout(persistTimers.current.get(id));
persistTimers.current.delete(id);
}
if (!patch || Object.keys(patch).length === 0) {
return;
}
try {
const updated = await patchProduct(id, patch);
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEM, payload: updated });
} catch (error) {
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
await loadProducts();
}
},
[loadProducts],
);
/**
* `schedulePatch(id, patch)`
*
* Zweck: Plant das verzögerte Speichern ein (Debounce) und aggregiert Teildaten.
*
* Ablauf:
* 1. Merged neue Teiländerungen mit bereits ausstehenden Änderungen für diese ID
* (z. B. `{ stock: 5 }` + `{ price: 10 }` => `{ stock: 5, price: 10 }`).
* 2. Setzt einen eventuell laufenden Timeout für dieses Produkt zurück (Reset Debounce-Timer).
* 3. Startet einen neuen Timer über `PERSIST_DELAY_MS` (400 ms), nach dessen Ablauf `flushPatch(id)` aufgerufen wird.
*/
const schedulePatch = useCallback(
(id, patch) => {
const merged = { ...(pendingPatches.current.get(id) || {}), ...patch };
pendingPatches.current.set(id, merged);
if (persistTimers.current.has(id)) {
clearTimeout(persistTimers.current.get(id));
}
persistTimers.current.set(
id,
setTimeout(() => {
flushPatch(id);
}, PERSIST_DELAY_MS),
);
},
[flushPatch],
);
/**
* Unmount-Cleanup:
* Wenn der Provider unmountet wird (z. B. Seitenwechsel), werden alle offenen Timer gecancelt
* und noch ausstehende Patches sofort "best-effort" an die API geschickt, damit keine Daten verloren gehen.
*/
useEffect(() => {
const timers = persistTimers.current;
const patches = pendingPatches.current;
return () => {
timers.forEach((timer) => clearTimeout(timer));
timers.clear();
patches.forEach((patch, id) => {
if (patch && Object.keys(patch).length > 0) {
patchProduct(id, patch).catch(() => {});
}
});
patches.clear();
};
}, []);
/**
* `updateAmount` & `updatePrice`:
* Setzen auf Optimistic UI.
* 1. Der UI-State wird sofort synchron via `dispatch` aktualisiert (keine Eingabelatenz für den User).
* 2. Das Backend-Update wird verzögert via `schedulePatch` eingereiht.
*/
const updateAmount = useCallback(
(amount, id) => {
dispatch({
type: PRODUCTS_ACTIONS.UPDATE_AMOUNT,
payload: { id, amount },
});
const stock = Number(amount);
if (Number.isFinite(stock)) {
schedulePatch(id, { stock });
}
},
[schedulePatch],
);
const updatePrice = useCallback(
(price, id) => {
dispatch({ type: PRODUCTS_ACTIONS.UPDATE_PRICE, payload: { id, price } });
const numericPrice = Number(price);
if (Number.isFinite(numericPrice)) {
schedulePatch(id, { price: numericPrice });
}
},
[schedulePatch],
);
const addProduct = useCallback(async (product) => {
try {
const created = await createProduct(product);
dispatch({ type: PRODUCTS_ACTIONS.ADD_ITEM, payload: created });
return created;
} catch (error) {
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
throw error;
}
}, []);
const saveProduct = useCallback(async (id, product) => {
try {
const updated = await updateProduct(id, product);
dispatch({ type: PRODUCTS_ACTIONS.SET_ITEM, payload: updated });
return updated;
} catch (error) {
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
throw error;
}
}, []);
const removeProduct = useCallback(async (id) => {
try {
await deleteProduct(id);
dispatch({ type: PRODUCTS_ACTIONS.REMOVE_ITEM, payload: id });
} catch (error) {
dispatch({
type: PRODUCTS_ACTIONS.SET_ERROR,
payload: getApiErrorMessage(error),
});
throw error;
}
}, []);
// Memoisierung der Action-Funktionen, damit Konsumenten des Actions-Contexts
// nicht bei jeder State-Änderung unnötig neu rendern.
const actions = useMemo(
() => ({
loadProducts,
updateAmount,
updatePrice,
addProduct,
saveProduct,
removeProduct,
}),
[loadProducts, updateAmount, updatePrice, addProduct, saveProduct, removeProduct],
);
return (
// <ProductsContext.Provider value={{state, dispatch}}>
// {children}
// </ProductsContext.Provider>
<ProductsStateContext.Provider value={state}>
<ProductsDispatchContext.Provider value={dispatch}>{children}</ProductsDispatchContext.Provider>
<ProductsDispatchContext.Provider value={dispatch}>
<ProductsActionsContext.Provider value={actions}>{children}</ProductsActionsContext.Provider>
</ProductsDispatchContext.Provider>
</ProductsStateContext.Provider>
);
};
// eigene Hooks mit ErrorHandler
export const useProductsState = () => {
const state = useContext(ProductsStateContext);
if (state == null) {
throw Error('useProductsState muss innerhalb von ProductsProvider verwendet werden.');
throw new Error('useProductsState muss innerhalb von ProductsProvider verwendet werden');
}
return state;
@@ -38,32 +285,18 @@ export const useProductsDispatch = () => {
const dispatch = useContext(ProductsDispatchContext);
if (dispatch == null) {
throw Error('useProductsDispatch muss innerhalb von ProductsProvider verwendet werden.');
throw new Error('useProductsDispatch muss innerhalb von ProductsProvider verwendet werden');
}
return dispatch;
};
// Factory Function
export const useProductsActions = () => {
const dispatch = useProductsDispatch();
const actions = useContext(ProductsActionsContext);
const updateAmount = useCallback(
(amount, id) => {
dispatch({ type: PRODUCTS_ACTIONS.UPDATE_AMOUNT, payload: { id, amount } });
},
[dispatch],
);
if (actions == null) {
throw new Error('useProductsActions muss innerhalb von ProductsProvider verwendet werden');
}
const updatePrice = useCallback(
(price, id) => {
dispatch({ type: PRODUCTS_ACTIONS.UPDATE_PRICE, payload: { id, price } });
},
[dispatch],
);
return {
updateAmount,
updatePrice,
};
return actions;
};

View File

@@ -0,0 +1,54 @@
import axios from 'axios';
const productsClient = axios.create({
baseURL: import.meta.env.VITE_API_URL ?? '/api',
timeout: 8000,
headers: { 'Content-Type': 'application/json' },
});
export const getApiErrorMessage = (error) => {
if (axios.isCancel(error) || error.code === 'ERR_CANCELED') {
return null;
}
return error.response?.data?.error || error.message || 'Unbekannter API Fehler';
};
export const fetchProducts = async (config = {}) => {
const { data } = await productsClient.get('/products', config);
if (!Array.isArray(data)) {
throw new Error('Ungültige API-Antwort: Produktliste wird erwartet.');
}
return data;
};
export const fetchProduct = async (id, config = {}) => {
const { data } = await productsClient.get(`/products/${id}`, config);
if (!Object.hasOwn(data, _id)) {
throw new Error('Ungültige API-Antwort: Produkt wird erwartet. (keine id)');
}
return data;
};
export const createProduct = async (product) => {
const { data } = await productsClient.post('/products', product);
return data;
};
export const updateProduct = async (id, product) => {
const { data } = await productsClient.put(`/products/${id}`, product);
return data;
};
export const patchProduct = async (id, patch) => {
const { data } = await productsClient.patch(`/products/${id}`, patch);
return data;
};
export const deleteProduct = async (id) => {
await productsClient.delete(`/products/${id}`);
};

View File

@@ -1,3 +1,12 @@
export { createInitialState } from './initialState';
export { PRODUCTS_ACTIONS, productsReducer } from './reducer';
export { ProductsProvider, useProductsActions, useProductsDispatch, useProductsState } from './ProductsStore';
export {
createProduct,
deleteProduct,
fetchProduct,
fetchProducts,
getApiErrorMessage,
patchProduct,
updateProduct,
} from './api';

View File

@@ -1,7 +1,11 @@
// import mit {}
export const createInitialState = (items) => {
// validation and sanitizing
return { items: items || [] };
return {
items: items || [], //
loading: true,
error: null,
};
};
export default createInitialState; // import mit eigener Benennung möglich

View File

@@ -1,5 +1,11 @@
// in ts enum
export const PRODUCTS_ACTIONS = {
SET_LOADING: 'SET_LOADING',
SET_ITEMS: 'SET_ITEMS',
SET_ITEM: 'SET_ITEM',
SET_ERROR: 'SET_ERROR',
ADD_ITEM: 'ADD_ITEM',
REMOVE_ITEM: 'REMOVE_ITEM',
UPDATE_AMOUNT: 'UPDATE_AMOUNT',
UPDATE_PRICE: 'UPDATE_PRICE',
};
@@ -7,25 +13,59 @@ export const PRODUCTS_ACTIONS = {
export const productsReducer = (state, action) => {
const { type, payload } = action;
console.log(state);
switch (type) {
case PRODUCTS_ACTIONS.SET_LOADING:
return {
...state,
loading: payload,
error: payload ? null : state.error,
};
case PRODUCTS_ACTIONS.SET_ITEMS:
return {
...state,
items: payload,
loading: false,
error: null,
};
case PRODUCTS_ACTIONS.SET_ITEM:
return {
...state,
items: state.items.map((item) => (item._id === payload._id ? payload : item)),
error: null,
};
case PRODUCTS_ACTIONS.SET_ERROR:
return {
...state,
loading: false,
error: payload,
};
case PRODUCTS_ACTIONS.ADD_ITEM:
return {
...state,
items: [...state.items, payload],
error: null,
};
case PRODUCTS_ACTIONS.REMOVE_ITEM:
return {
...state,
items: state.items.filter((item) => item._id !== payload),
error: null,
};
case PRODUCTS_ACTIONS.UPDATE_AMOUNT:
return {
...state,
items: state.items.map((item) =>
item._id === payload.id //
? { ...item, stock: payload.amount }
item._id === payload.id
? { ...item, stock: payload.amount } //
: item,
),
};
case PRODUCTS_ACTIONS.UPDATE_PRICE:
return {
...state,
items: state.items.map((item) =>
item._id === payload.id //
? { ...item, price: payload.price }
item._id === payload.id
? { ...item, price: payload.price } //
: item,
),
};