initial commit

This commit is contained in:
ray
2026-07-12 10:17:17 -04:00
commit dab5a4ebc6
1424 changed files with 330463 additions and 0 deletions
@@ -0,0 +1,170 @@
---
name: react-crud-patterns
description: "Common patterns and pitfalls in React CRUD applications — state hydration, save/sync routing, edit-path data flow, and relationship tracking."
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
hermes:
tags: [react, crud, debugging, state-management, data-flow, edit-path]
related_skills: [systematic-debugging, web-ui-repair, test-driven-development]
---
# React CRUD Patterns
## Overview
CRUD (Create-Read-Update-Delete) apps have a characteristic data flow that creates repeatable bug classes. The most common is a **state variable initialized from a transient source** (URL query param, navigation state) that is **never re-populated from the persisted backend record** when the record is re-opened for editing. This causes save/sync side-effects to silently do nothing.
## Common Patterns
### Pattern 1: Transient State Not Re-Hydrated on Edit
**Symptom**: A save/submit/sync operation works on newly created records but silently does nothing when editing an existing record. The form loads, data looks right, edits save — but a linked side-effect (syncing to another collection, sending a notification, updating a parent record) never fires.
**Root cause**: A `useState` variable is initialized from a transient source (URL query param, `location.state`, initial page navigation) but never restored from the backend record when the record is opened in edit mode.
**Zustand persist middleware makes this harder to spot.** On edit-page mount, the Zustand store rehydrates from localStorage (which has state from when the record was created, including the correct relationship ID and populated data). The component renders with correct-looking data — customer info, services, discount all populated. The developer sees the form looking right and doesn't suspect a missing variable. But the gate variable lives in React `useState`, not in Zustand — so `useQuoteStore.setState()` can't restore it. It stays `null` from the missing URL param, and the sync silently skips.
**Example flow that breaks:**
1. User navigates to create page with `?sourceId=xyz` in the URL
2. Component sets `const [sourceId, setSourceId] = useState(searchParams.get('sourceId'))`
3. Save handler gates a sync: `if (sourceId) { /* sync approved services back to source */ }`
4. Record saved in backend with `sourceId` field set to `xyz`
5. User re-opens record for editing — URL is now `?edit=rec-456` (no `sourceId` param)
6. `sourceId` stays `null` → sync is silently skipped
**Diagnosis:**
1. Check the URL on the edit view — does it carry the same relationship params as the create URL?
2. Search for every `useState` that feeds into save/sync logic. Is it ever set from the loaded record data, or only from initial URL params?
3. Read the edit-loading effect — does it read `record.sourceId` and call `setSourceId()`?
4. Trace the state through the save handler — `if (sourceId)` gates the whole sync block.
**Fix:**
Add the state restoration in the edit-loading code:
```tsx
// In the edit-loading useEffect, after fetching the record:
if (data.sourceId) {
setSourceId(data.sourceId);
}
```
Or from the component props in a parent-routing pattern:
```tsx
// Edit loading derives sourceId from the backend record, not the URL:
const loadEditRecord = async (editId: string) => {
const record = await pb.collection('records').getOne(editId);
const data = record as any;
// Restore sourceId from persisted data (not from URL params)
if (data.sourceId) {
setSourceId(data.sourceId);
}
setFormFields({ /* ... */ });
};
```
**Prevention checklist:**
- [ ] Every `useState` that gates a save/sync side-effect has a corresponding `setState` call in the edit-loading code path
- [ ] The edit-loading effect reads ALL relationship/foreign-key fields from the backend record, not just display fields
- [ ] URL query params are treated as **create-only** sources of state — edit mode derives from the persisted record
- [ ] Sync/test scenarios cover the edit-then-save path, not just create-then-save
### Pattern 2: Relationship ID Stored But Not Read During Edit
**Symptom**: Records have a `sourceId` / `parentId` / `originId` field stored in the backend, but editors load and save records without preserving or using the link.
**Root cause**: The edit-loading code only reads display-oriented fields (name, price, description) and ignores relationship fields. The save code then overwrites the record with no relationship link.
**Fix:** Include the relationship field in both the read and write paths:
```tsx
// Edit loading — read the relationship field:
const editRecord = await pb.collection('quotes').getOne(editId);
const data = editRecord as any;
// Include repairOrderId / sourceId in what's read
setCustomerInfo({ roNumber: data.roNumber || '' });
// Save — preserve the relationship field:
const data: Record<string, any> = {
...formFields,
sourceId: sourceId || originalRecord.sourceId, // fallback to original
};
```
### Pattern 3: Multi-Path Sync (Create vs Share vs Save All Do the Same Thing Differently)
**Symptom**: Three different user actions (Save, Share, Download PDF) all need to sync approved services back to a source record, but the sync code is copy-pasted in three places. One gets updated, the others don't.
**Fix:** Extract the sync logic into a shared function:
```tsx
async function syncApprovedServicesToOrigin(
repairOriginId: string,
services: QuoteService[],
settings: ShopSettings,
) {
const approved = services.filter((s) => s.approved);
if (approved.length === 0) return;
const ro = await pb.collection('repairOrders').getOne(repairOriginId);
let roServices = parseServices(ro.services);
// ... merge approved services ...
await pb.collection('repairOrders').update(repairOriginId, {
services: JSON.stringify(roServices),
});
}
```
## Investigation Workflow
When investigating a CRUD data-flow bug:
1. **Map the state sources**: For each `useState` involved in save/sync, identify ALL code paths that set it (URL params, API response, parent props, form inputs, localStorage).
2. **Distinguish `useState` from Zustand store state**: Variables in React `useState` are NOT restored by `useQuoteStore.setState()` — they need their own explicit `setState()`. Variables in the Zustand store CAN be restored by the store's `setState()`. Grep for `const \\[.*, set.*\\] = useState` to find React state, and `const {.*} = use.*Store()` for Zustand state.
3. **Check the edit path**: Does the edit-loading code path set every state variable that the create-loading code path sets? Cross-reference with step 2 — `useState` variables are the most commonly missed.
4. **Check the save path**: Does the save handler gate side-effects on state that could be null on edit? If so, add a fallback from the loaded record.
5. **Verify with the user**: If the symptom is "works on create but not edit", explain this class of bug and ask if they want you to trace the specific state variable.
### Multi-Layer Investigation: UI → API → Database
When a CRUD bug appears to still exist AFTER applying a structural fix (e.g., you added the missing `setState` call but the sync still doesn't fire), the issue may be at a different layer. Isolate it systematically:
**Layer 1 — Browser UI / Console:**
- Verify the action fires: check `browser_console()` for errors and debug logs after clicking the button
- Inject console.log markers (`[SPQ-DEBUG]` prefix) at every decision point in the suspect code path
- Check the page snapshot — did the button actually appear as enabled (not `disabled`)? Did a Toast appear?
- Compare in-memory state (Zustand store `getState()`) with persisted backend state
**Layer 2 — Direct API calls (bypass the UI):**
- Authenticate with the exact same credentials the frontend uses
- Manually perform the exact API call the sync code makes, with the exact same payload
- If the API call works, the code logic is correct — the issue is in the UI execution path (button not firing, wrong state at time of click, race condition)
- If the API call FAILS, the issue is server-side (auth, permissions, schema validation, API rules)
**Layer 3 — Database / Server state:**
- Query PocketBase records directly to compare frontend-visible state vs persisted state
- If the frontend shows correct state but PB still has the old data, the save handler never completed or the sync threw a caught exception
- Create test data (RO + child record) owned by the same user to isolate ownership variables
**Layer 4 — Auth / Ownership:**
- PocketBase's `userId = @request.auth.id` rule makes records invisible/unwritable to other users
- A 404 on GET or `Failed to authenticate` on update usually means the authenticated user doesn't own the record
- Always authenticate with the record owner's credentials when testing API calls — not an admin token, not a different user
- Verify ownership: check the `userId` field on the source record and the target record — they must match
- A sync that works when tested via curl but fails in the UI often means the UI's auth token belongs to a different user than the one who owns the data
**When to use this workflow:** Any time you've applied a fix that "should work" based on code inspection but the observable behavior hasn't changed. Each layer eliminates a category of root causes.
## Related Skills
- `systematic-debugging` — General 4-phase debugging methodology. Use this when the root cause isn't clearly a CRUD state-flow issue.
- `web-ui-repair` — Fix common static web app UI issues (display, styling, event handlers).
- `test-driven-development` — Write regression tests for CRUD state-flow fixes.
@@ -0,0 +1,87 @@
# Session Example: Quote-to-RO Service Sync
## Project Context
- SPQ-v2 (ShopProQuote): React/Vite/TS shop management app
- Backend: PocketBase v0.39.5 (self-hosted Docker container at 127.0.0.1:8091)
- State: Zustand with `persist` middleware (localStorage key: `spq-quote`)
- API rules: All user-scoped collections use `userId = @request.auth.id`
- App user login: pocketbase collections `users` (not superuser)
## The Bug
When customer-approved recommended services from a Quote (generated from an RO) were saved, they did NOT transfer back to the originating Repair Order.
## Root Cause 1 (Structural): Transient State Not Restored on Edit
The `repairOriginId` state variable was initialized from a URL query param (`?fromRO=RO_ID`) but never restored from the persisted `repairOrderId` field when re-opening the quote for editing (`?edit=QUOTE_ID`):
```tsx
// QuoteGenerator.tsx — the fromRO effect only ran on mount with [] deps
// When editing, fromRO wasn't in the URL, so repairOriginId stayed null
```
**Fix applied by previous agent:** Added `setRepairOriginId(data.repairOrderId)` in the edit-loading `useEffect` (QuoteGenerator.tsx:523-525).
## Root Cause 2 (Auth/Ownership): API Rules Blocked Cross-User Writes
Even after the structural fix, the sync STILL failed. Investigation revealed:
1. **The app user `demo@shop.com` didn't exist in PocketBase.** The actual data belonged to `mani8994@gmail.com`. Logging in as `demo@shop.com` showed the correct data due to Zustand persist rehydrating from localStorage — but any API call (load, save) was blocked.
2. **PocketBase's `userId = @request.auth.id` rule** on the `repairOrders` collection blocked the RO update when the user didn't own the RO. The sync code's `pb.collection('repairOrders').update()` returned a 404 (record "not found" from the auth perspective), which was caught by the inner `catch` and logged as a toast.
## The Full Investigation (Multi-Layer)
### Layer 1 — Code inspection
- Confirmed sync code exists in `QuoteSummary.tsx:handleSave` (lines 145-194) and `ensureShareToken` (lines 267-308)
- Confirmed the edit-loading code restores `repairOriginId` from `data.repairOrderId`
- Confirmed `sanitizeServices` doesn't strip `approved` (only null values)
- Confirmed Zustand persist rehydration is synchronous and the edit-load effect overwrites PB data correctly
### Layer 2 — Database inspection (Docker + curLl)
- Authenticated as superuser via PocketBase CLI:
```bash
docker exec pocketbase /usr/local/bin/pocketbase superuser upsert admin@shop.com TEMP_PASS --dir=/pb_data
```
- Queried quotes and ROs via the reST API:
```
POST /api/collections/_superusers/auth-with-password
GET /api/collections/quotes/records?perPage=100&fields=id,repairOrderId,customerName,services
```
- Found 10+ quotes with `repairOrderId` populated AND approved services
- Found linked ROs had **0 services** — confirming the sync never fired successfully
- Used `filter=repairOrderId!=%27%27` to find only linked quotes efficiently
### Layer 3 — Direct API test (curl as app user)
- Authenticated as `mani8994@gmail.com` (the record owner) and performed the exact same `PATCH /api/collections/repairOrders/records/RO_ID` with the same payload the frontend would send:
```bash
curl -s -X PATCH "http://127.0.0.1:8091/api/collections/repairOrders/records/RO_ID" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"services": "[{\"name\":\"Test\",\"total\":100,\"status\":\"pending\"}]"}'
```
- The API call **SUCCEEDED** — the RO now had services. This proved the code logic is correct.
- Created fresh test data (RO + quote) owned by the same user to reproduce from scratch
### Layer 4 — Auth verification
- Confirmed `demo@shop.com` could NOT update the RO owned by `mani8994@gmail.com` (returned 404)
- Confirmed `mani8994@gmail.com` COULD update the RO (returned 200)
- Conclusion: The sync code is correct BUT the frontend must be authenticated as the record owner
### Layer 5 — ensureShareToken sync gap
- When `editId` is set (editing an existing quote), the `ensureShareToken` function skips the entire `if (!quoteId)` block — including the sync code
- This means the sync only runs for NEW quote shares, not for shares of existing quotes
- The `handleSave` path's sync runs for both new and edit saves, so Save+Share in sequence works, but Share-without-intervening-Save doesn't sync
## Key Takeaways
1. **Pattern 1 fix is necessary but not sufficient.** Adding `setRepairOriginId()` to the edit-loading effect is required, but the sync still won't fire if the user doesn't own the target record.
2. **Zustand persist masks auth issues.** LocalStorage rehydration shows correct-looking data even when the authenticated user has no backend access. Saves silently fail.
3. **Verify at the API level.** When the UI shows correct state but the backend doesn't have it, test the API call directly. curl never lies.
4. **Create test data with known ownership.** Test data created via the frontend (authenticated as the target user) avoids cross-owner issues.
5. **Multi-path sync code must be audited for gaps.** When the same sync logic is copy-pasted in `handleSave` and `ensureShareToken`, the two code paths can diverge. `ensureShareToken` only syncs on new quote creation.