From 2aef8b91bd24e75f1a5e68084fd263ae6a4302e7 Mon Sep 17 00:00:00 2001 From: Philippe Torrel Date: Tue, 1 Sep 2026 08:47:49 +0200 Subject: [PATCH] feat: update project --- .../package-lock.json | 32 +- .../package.json | 8 +- .../tests/validation/step3.test.jsx | 72 ++--- webseite-react-php/react-app/README.md | 43 +++ .../src/components/tables/TableProducts.jsx | 35 +- .../src/stores/products/ProductsStore.jsx | 301 ++++++++++++++++-- .../react-app/src/stores/products/api.js | 54 ++++ .../react-app/src/stores/products/index.js | 9 + .../src/stores/products/initialState.js | 6 +- .../react-app/src/stores/products/reducer.js | 54 +++- 10 files changed, 491 insertions(+), 123 deletions(-) create mode 100644 webseite-react-php/react-app/src/stores/products/api.js diff --git a/06_js-debug/projects/debugging-multi-step-form-template/package-lock.json b/06_js-debug/projects/debugging-multi-step-form-template/package-lock.json index bb3d446..e5772a1 100644 --- a/06_js-debug/projects/debugging-multi-step-form-template/package-lock.json +++ b/06_js-debug/projects/debugging-multi-step-form-template/package-lock.json @@ -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": { diff --git a/06_js-debug/projects/debugging-multi-step-form-template/package.json b/06_js-debug/projects/debugging-multi-step-form-template/package.json index 2b353d1..0a3563e 100644 --- a/06_js-debug/projects/debugging-multi-step-form-template/package.json +++ b/06_js-debug/projects/debugging-multi-step-form-template/package.json @@ -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", diff --git a/06_js-debug/projects/debugging-multi-step-form-template/tests/validation/step3.test.jsx b/06_js-debug/projects/debugging-multi-step-form-template/tests/validation/step3.test.jsx index fafc8df..9f18107 100644 --- a/06_js-debug/projects/debugging-multi-step-form-template/tests/validation/step3.test.jsx +++ b/06_js-debug/projects/debugging-multi-step-form-template/tests/validation/step3.test.jsx @@ -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( - {}} - handleCheckboxChange={() => {}} - errors={errors} - /> - ); + render( {}} 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( - {}} - handleCheckboxChange={() => {}} - errors={errors} - /> - ); + render( {}} 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( - 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( - 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(); }); }); diff --git a/webseite-react-php/react-app/README.md b/webseite-react-php/react-app/README.md index d937833..34d7668 100644 --- a/webseite-react-php/react-app/README.md +++ b/webseite-react-php/react-app/README.md @@ -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. diff --git a/webseite-react-php/react-app/src/components/tables/TableProducts.jsx b/webseite-react-php/react-app/src/components/tables/TableProducts.jsx index 1457e0f..ad3fb62 100644 --- a/webseite-react-php/react-app/src/components/tables/TableProducts.jsx +++ b/webseite-react-php/react-app/src/components/tables/TableProducts.jsx @@ -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 ( +

+ Produkte werden geladen.... + +

+ ); + } return (
+ {error && ( +
+

{error}

+ +
+ )} - + diff --git a/webseite-react-php/react-app/src/stores/products/ProductsStore.jsx b/webseite-react-php/react-app/src/stores/products/ProductsStore.jsx index 5850318..c8a6235 100644 --- a/webseite-react-php/react-app/src/stores/products/ProductsStore.jsx +++ b/webseite-react-php/react-app/src/stores/products/ProductsStore.jsx @@ -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 + const persistTimers = useRef(new Map()); // Speichert: Map + + 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 ( - // - // {children} - // - - {children} + + {children} + ); }; -// 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; }; diff --git a/webseite-react-php/react-app/src/stores/products/api.js b/webseite-react-php/react-app/src/stores/products/api.js new file mode 100644 index 0000000..b8e0985 --- /dev/null +++ b/webseite-react-php/react-app/src/stores/products/api.js @@ -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}`); +}; diff --git a/webseite-react-php/react-app/src/stores/products/index.js b/webseite-react-php/react-app/src/stores/products/index.js index 2c35184..ec4f1c0 100644 --- a/webseite-react-php/react-app/src/stores/products/index.js +++ b/webseite-react-php/react-app/src/stores/products/index.js @@ -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'; diff --git a/webseite-react-php/react-app/src/stores/products/initialState.js b/webseite-react-php/react-app/src/stores/products/initialState.js index aa2c8ee..4a51d89 100644 --- a/webseite-react-php/react-app/src/stores/products/initialState.js +++ b/webseite-react-php/react-app/src/stores/products/initialState.js @@ -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 diff --git a/webseite-react-php/react-app/src/stores/products/reducer.js b/webseite-react-php/react-app/src/stores/products/reducer.js index 97f9447..1f3cfb2 100644 --- a/webseite-react-php/react-app/src/stores/products/reducer.js +++ b/webseite-react-php/react-app/src/stores/products/reducer.js @@ -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, ), };
SKU Stock