diff --git a/CLAUDE.md b/CLAUDE.md index b808528..6fc6c9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,9 @@ pnpm test tests/lib/reducer/storage.test.ts ### Key Files and Structure -**`lib/reducer/index.ts`**: Entry point for the Redux slice factory. The `factory()` function creates a Redux slice with the configured modules and returns it. The reducer handles three async thunk states (pending, fulfilled, rejected) for the request thunk. +**`lib/reducer/index.ts`**: Entry point for the Redux slice factory. The `factory()` function creates a Redux slice with the configured modules and returns it. The reducer handles three async thunk states (pending, fulfilled, rejected) for the request thunk. It also provides two synchronous reducer actions: +- `set`: Manually update storage values using path-based updates +- `historyClear`: Clear request history (success and error) for a specific API or all APIs **`lib/reducer/request.ts`**: Contains the `request` async thunk that handles all API calls. This is where: - FormData is built for file uploads @@ -91,7 +93,10 @@ All hooks use `react-redux`'s `useSelector` internally and are fully typed for T ### Data Flow 1. User dispatches `request()` thunk with API options -2. Request enters `pending` state → modules handle accordingly (cancelation, listener updates) +2. Request enters `pending` state → modules handle accordingly: + - If `historyClear: true` option is set, history for this API is cleared (only if history module enabled) + - Cancelation module aborts previous requests to same API (if enabled) + - Listener module adds request to active list (if enabled) 3. If `queryQueue` enabled, request is enqueued; otherwise executes immediately 4. Fetch executes with merged headers/body and abort signal 5. Response is parsed based on `responseType` (json/text/blob) @@ -133,6 +138,7 @@ Tests use Vitest with jsdom environment. Test files are in `tests/lib/` mirrorin - Request lifecycle (pending, fulfilled, rejected) - QueryQueue sequential processing - AbortController cancellation +- History clearing (manual and automatic) - Helper functions and type guards - React hooks (`tests/lib/hooks.test.ts`): Tests the selector logic for all hooks by dispatching Redux actions and verifying state selections @@ -150,6 +156,15 @@ When the API returns `{ data: [...], primaryKey: 'fieldName' }`, the normalizati The `onResult` callback is **always called** regardless of success or failure. It receives the parsed response data (or error) directly. This allows components to handle responses without needing to check Redux state. +### History Management + +The history module tracks both successful requests (`state.request.done`) and failed requests (`state.request.errors`). History can be cleared in two ways: + +1. **Automatic clearing**: Set `historyClear: true` in request options to clear history for that API before the request starts (happens in the `pending` state) +2. **Manual clearing**: Dispatch the `historyClear` action with an API string to clear history for that API, or without arguments to clear all history + +History clearing is module-aware and only executes when the `history` module is enabled. It deletes both success and error history for the specified API endpoint. + ### File Uploads When `files` array is provided, the request automatically switches to FormData. The body is serialized to JSON and attached with the key specified by `bodyKey` (default: 'value'). Files are attached with keys specified by `fileKey` (default: 'file'). diff --git a/README.md b/README.md index ca33994..48494bc 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,7 @@ interface CdeebeeRequestOptions { onResult?: (response: T) => void; // Callback called with response data (always called, even on errors) ignore?: boolean; // Skip storing result in storage responseType?: 'json' | 'text' | 'blob'; // Response parsing type (default: 'json') + historyClear?: boolean; // Auto-clear history for this API before making the request } ``` @@ -484,6 +485,23 @@ const doneRequests = useAppSelector(state => state.cdeebee.request.done); const errors = useAppSelector(state => state.cdeebee.request.errors); ``` +### Clearing Request History + +Clear old success/error history when needed (useful for forms that get reopened): + +```typescript +// Automatic: clear before request +dispatch(request({ + api: '/api/posts', + historyClear: true, // Clears old history for this API + body: formData, +})); + +// Manual: clear anytime +dispatch(cdeebeeSlice.actions.historyClear('/api/posts')); // Specific API +dispatch(cdeebeeSlice.actions.historyClear()); // All APIs +``` + ## React Hooks cdeebee provides a comprehensive set of React hooks for accessing state without writing selectors. These hooks assume your cdeebee slice is at `state.cdeebee` (which is the default when using `combineSlices`). diff --git a/lib/reducer/index.ts b/lib/reducer/index.ts index d1b69d9..89e8ce8 100644 --- a/lib/reducer/index.ts +++ b/lib/reducer/index.ts @@ -33,6 +33,17 @@ export const factory = (settings: CdeebeeSettings, storage?: T) => { // This is more performant than creating a new object // Immer will track changes and create minimal updates batchingUpdate(state.storage as Record, action.payload); + }, + historyClear(state, action: { payload?: string }) { + const api = action.payload; + + if (api) { + delete state.request.done[api]; + delete state.request.errors[api]; + } else { + state.request.done = {}; + state.request.errors = {}; + } } }, extraReducers: builder => { @@ -41,6 +52,13 @@ export const factory = (settings: CdeebeeSettings, storage?: T) => { const api = action.meta.arg.api; const requestId = action.meta.requestId; + if (action.meta.arg.historyClear) { + checkModule(state.settings, 'history', () => { + delete state.request.done[api]; + delete state.request.errors[api]; + }); + } + checkModule(state.settings, 'cancelation', () => { abortQuery(api, requestId); }); diff --git a/lib/reducer/types.ts b/lib/reducer/types.ts index 16e7d9d..e92a4b4 100644 --- a/lib/reducer/types.ts +++ b/lib/reducer/types.ts @@ -46,6 +46,7 @@ export interface CdeebeeRequestOptions extends Partial>; + historyClear?: boolean; } type KeyOf = Extract; diff --git a/package.json b/package.json index f47573a..52493b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@recats/cdeebee", - "version": "3.0.0-beta.10", + "version": "3.0.0-beta.11", "description": "React Redux data-logic library", "repository": "git@github.com:recats/cdeebee.git", "author": "recats", diff --git a/tests/lib/reducer/index.test.ts b/tests/lib/reducer/index.test.ts index 058534f..2cebfd8 100644 --- a/tests/lib/reducer/index.test.ts +++ b/tests/lib/reducer/index.test.ts @@ -587,6 +587,168 @@ describe('factory', () => { }); }); + describe('historyClear reducer', () => { + it('should clear history for a specific API', async () => { + const store = createTestStore(settings); + + // Create some history first + mockFetchAlways(createMockResponse({ json: async () => ({ data: 'test' }) })); + + const dispatch = store.dispatch as any; + await dispatch(request({ api: '/api/test1' })); + await dispatch(request({ api: '/api/test2' })); + + // Verify history exists + let state = store.getState().cdeebee; + expect(state.request.done['/api/test1']).toBeDefined(); + expect(state.request.done['/api/test2']).toBeDefined(); + + // Clear history for specific API + const slice = factory(settings); + dispatch(slice.actions.historyClear('/api/test1')); + + state = store.getState().cdeebee; + expect(state.request.done['/api/test1']).toBeUndefined(); + expect(state.request.done['/api/test2']).toBeDefined(); + }); + + it('should clear error history for a specific API', async () => { + const store = createTestStore(settings); + + // Create some error history + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ ok: false, status: 500 }) + ); + + const dispatch = store.dispatch as any; + await dispatch(request({ api: '/api/test' })); + + // Verify error history exists + let state = store.getState().cdeebee; + expect(state.request.errors['/api/test']).toBeDefined(); + expect(state.request.errors['/api/test'].length).toBe(1); + + // Clear error history + const slice = factory(settings); + dispatch(slice.actions.historyClear('/api/test')); + + state = store.getState().cdeebee; + expect(state.request.errors['/api/test']).toBeUndefined(); + }); + + it('should clear all history when no API is provided', async () => { + const store = createTestStore(settings); + + // Create history for multiple APIs + mockFetchAlways(createMockResponse({ json: async () => ({ data: 'test' }) })); + + const dispatch = store.dispatch as any; + await dispatch(request({ api: '/api/test1' })); + await dispatch(request({ api: '/api/test2' })); + await dispatch(request({ api: '/api/test3' })); + + // Verify history exists + let state = store.getState().cdeebee; + expect(state.request.done['/api/test1']).toBeDefined(); + expect(state.request.done['/api/test2']).toBeDefined(); + expect(state.request.done['/api/test3']).toBeDefined(); + + // Clear all history + const slice = factory(settings); + dispatch(slice.actions.historyClear()); + + state = store.getState().cdeebee; + expect(state.request.done).toEqual({}); + expect(state.request.errors).toEqual({}); + }); + + it('should clear both success and error history when no API is provided', async () => { + const store = createTestStore(settings); + + // Create success history + mockFetch(createMockResponse({ json: async () => ({ data: 'test' }) })); + const dispatch = store.dispatch as any; + await dispatch(request({ api: '/api/success' })); + + // Create error history + (global.fetch as ReturnType).mockResolvedValueOnce( + createMockResponse({ ok: false, status: 500 }) + ); + await dispatch(request({ api: '/api/error' })); + + // Verify both types of history exist + let state = store.getState().cdeebee; + expect(state.request.done['/api/success']).toBeDefined(); + expect(state.request.errors['/api/error']).toBeDefined(); + + // Clear all history + const slice = factory(settings); + dispatch(slice.actions.historyClear()); + + state = store.getState().cdeebee; + expect(state.request.done).toEqual({}); + expect(state.request.errors).toEqual({}); + }); + + it('should auto-clear history before request when historyClear option is true', async () => { + const store = createTestStore(settings); + + // Create initial history + mockFetch(createMockResponse({ json: async () => ({ data: 'first' }) })); + const dispatch = store.dispatch as any; + await dispatch(request({ api: '/api/test' })); + + // Verify history exists + let state = store.getState().cdeebee; + expect(state.request.done['/api/test']).toBeDefined(); + expect(state.request.done['/api/test'].length).toBe(1); + + // Make another request with historyClear: true + mockFetch(createMockResponse({ json: async () => ({ data: 'second' }) })); + await dispatch(request({ api: '/api/test', historyClear: true })); + + // History should only have the second request + state = store.getState().cdeebee; + expect(state.request.done['/api/test']).toBeDefined(); + expect(state.request.done['/api/test'].length).toBe(1); + expect(state.request.done['/api/test'][0].request).toHaveProperty('result'); + }); + + it('should not auto-clear history when historyClear option is false', async () => { + const store = createTestStore(settings); + + // Create initial history + mockFetchAlways(createMockResponse({ json: async () => ({ data: 'test' }) })); + const dispatch = store.dispatch as any; + await dispatch(request({ api: '/api/test' })); + await dispatch(request({ api: '/api/test', historyClear: false })); + + // Both requests should be in history + const state = store.getState().cdeebee; + expect(state.request.done['/api/test']).toBeDefined(); + expect(state.request.done['/api/test'].length).toBe(2); + }); + + it('should not clear history when history module is disabled', async () => { + const settingsWithoutHistory: CdeebeeSettings> = { + ...settings, + modules: ['listener', 'storage', 'cancelation'], + }; + + const store = createTestStore(settingsWithoutHistory); + + // Try to make a request with historyClear: true + mockFetch(createMockResponse({ json: async () => ({ data: 'test' }) })); + const dispatch = store.dispatch as any; + await dispatch(request({ api: '/api/test', historyClear: true })); + + // History should not be tracked at all + const state = store.getState().cdeebee; + expect(state.request.done).toEqual({}); + expect(state.request.errors).toEqual({}); + }); + }); + describe('set reducer', () => { it('should update a single top-level key in storage', () => { const slice = factory(settings);