Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand All @@ -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').
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ interface CdeebeeRequestOptions<T> {
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
}
```

Expand Down Expand Up @@ -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`).
Expand Down
18 changes: 18 additions & 0 deletions lib/reducer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ export const factory = <T>(settings: CdeebeeSettings<T>, storage?: T) => {
// This is more performant than creating a new object
// Immer will track changes and create minimal updates
batchingUpdate(state.storage as Record<string, unknown>, 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 => {
Expand All @@ -41,6 +52,13 @@ export const factory = <T>(settings: CdeebeeSettings<T>, 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);
});
Expand Down
1 change: 1 addition & 0 deletions lib/reducer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface CdeebeeRequestOptions<T> extends Partial<Pick<CdeebeeSettings<T
ignore?: boolean;
responseType?: 'json' | 'text' | 'blob';
listStrategy?: Partial<CdeebeeListStrategy<T>>;
historyClear?: boolean;
}

type KeyOf<T> = Extract<keyof T, string | number>;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
162 changes: 162 additions & 0 deletions tests/lib/reducer/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn>).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<typeof vi.fn>).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<Record<string, unknown>> = {
...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);
Expand Down