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
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ function MyComponent() {
method: 'POST',
body: { filter: 'active' },
onResult: (result) => {
// onResult is always called with the response data
// For JSON responses, result is already parsed
console.log('Request completed:', result);
},
}));
Expand Down Expand Up @@ -185,7 +187,9 @@ interface CdeebeeRequestOptions<T> {
bodyKey?: string; // Override default bodyKey
listStrategy?: CdeebeeListStrategy<T>; // Override list strategy for this request
normalize?: (storage, result, strategyList) => T; // Override normalization
onResult?: (response: T) => void; // Callback called with response data on success
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')
}
```

Expand Down Expand Up @@ -236,6 +240,61 @@ dispatch(request({
}));
```

### Handling Different Response Types

By default, cdeebee parses responses as JSON. For other response types (CSV, text files, images, etc.), use the `responseType` option:

```typescript
// CSV/text response
dispatch(request({
api: '/api/export',
responseType: 'text',
ignore: true, // Don't store in storage
onResult: (csvData) => {
// csvData is a string
downloadCSV(csvData);
},
}));

// Binary file (image, PDF, etc.)
dispatch(request({
api: '/api/image/123',
responseType: 'blob',
ignore: true,
onResult: (blob) => {
// blob is a Blob object
const url = URL.createObjectURL(blob);
setImageUrl(url);
},
}));

// JSON (default)
dispatch(request({
api: '/api/data',
// responseType: 'json' is default
onResult: (data) => {
console.log(data); // Already parsed JSON
},
}));
```

### Ignoring Storage Updates

Use the `ignore` option to prevent storing the response in storage while still receiving it in the `onResult` callback:

```typescript
// Export CSV without storing in storage
dispatch(request({
api: '/api/export',
responseType: 'text',
ignore: true,
onResult: (csvData) => {
// Handle CSV data directly
downloadFile(csvData, 'export.csv');
},
}));
```

### Custom Headers

```typescript
Expand Down
9 changes: 7 additions & 2 deletions lib/reducer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,16 @@ export const factory = <T>(settings: CdeebeeSettings<T>, storage?: T) => {
state.request.done[api].push({ api, request: action.payload, requestId });
});
checkModule(state.settings, 'storage', () => {
if (action.meta.arg.ignore) {
return;
}

const strategyList = action.meta.arg.listStrategy ?? state.settings.listStrategy ?? {};
const normalize = action.meta.arg.normalize ?? state.settings.normalize ?? defaultNormalize;

const currentState = current(state) as CdeebeeState<T>;
const normalizedData = normalize(currentState, action.payload.result, strategyList);
// Type assertion is safe here because we've already checked isRecord
const normalizedData = normalize(currentState, action.payload.result as Record<string, Record<string, unknown>>, strategyList);

// Normalize already handles merge/replace and preserves keys not in response
// Simply apply the result
Expand All @@ -75,7 +80,7 @@ export const factory = <T>(settings: CdeebeeSettings<T>, storage?: T) => {
.addCase(request.rejected, (state, action) => {
const requestId = action.meta.requestId;
const api = action.meta.arg.api;

checkModule(state.settings, 'listener', () => {
state.request.active = state.request.active.filter(q => !(q.api === api && q.requestId === requestId));
});
Expand Down
33 changes: 22 additions & 11 deletions lib/reducer/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const request = createAsyncThunk(
const { cdeebee: { settings } } = getState() as { cdeebee: CdeebeeState<unknown> };

const abort = abortManager(signal, options.api, requestId);
const withCallback = options.onResult && typeof options.onResult === 'function';

checkModule(settings, 'cancelation', abort.init);

Expand Down Expand Up @@ -52,25 +53,35 @@ export const request = createAsyncThunk(

checkModule(settings, 'cancelation', abort.drop);

let result: unknown;
const responseType = options.responseType || 'json';

if (responseType === 'text') {
result = await response.text();
} else if (responseType === 'blob') {
result = await response.blob();
} else {
// default: json
result = await response.json();
}

if (!response.ok) {
if (withCallback) options.onResult!(result);
return rejectWithValue(response);
}
const result = await response.json();
if (options.onResult && typeof options.onResult === 'function') {
options.onResult(result);
}

if (withCallback) options.onResult!(result);
return { result, startedAt, endedAt: new Date().toUTCString() };
} catch (error) {
checkModule(settings, 'cancelation', abort.drop);

if (withCallback) options.onResult!(error);

if (error instanceof Error && error.name === 'AbortError') {
return rejectWithValue({
message: 'Request was cancelled',
cancelled: true,
});
return rejectWithValue({ message: 'Request was cancelled', cancelled: true });
}
return rejectWithValue({
message: error instanceof Error ? error.message : 'Unknown error occurred',
});

return rejectWithValue({ message: error instanceof Error ? error.message : 'Unknown error occurred' });
}
},
);
Expand Down
2 changes: 2 additions & 0 deletions lib/reducer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface CdeebeeRequestOptions<T> extends Partial<Pick<CdeebeeSettings<T
body?: unknown;
headers?: Record<string, string>;
onResult?: (response: T) => void;
ignore?: boolean;
responseType?: 'json' | 'text' | 'blob';
}

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.4",
"version": "3.0.0-beta.5",
"description": "React Redux data-logic library",
"repository": "git@github.com:recats/cdeebee.git",
"author": "recats",
Expand Down
48 changes: 47 additions & 1 deletion tests/lib/reducer/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { checkModule, mergeDeepRight, omit, batchingUpdate, assocPath } from '../../../lib/reducer/helpers';
import { checkModule, mergeDeepRight, omit, batchingUpdate, assocPath, hasDataProperty, hasProperty } from '../../../lib/reducer/helpers';
import { type CdeebeeSettings, type CdeebeeModule } from '../../../lib/reducer/types';

describe('checkModule', () => {
Expand Down Expand Up @@ -533,3 +533,49 @@ describe('batchingUpdate', () => {
expect(items[0].name).toBe('Updated Item 1');
});
});

describe('hasDataProperty', () => {
it('should return true for object with data array property', () => {
const value = { data: [1, 2, 3] };
expect(hasDataProperty(value)).toBe(true);
});

it('should return false for object without data property', () => {
const value = { other: 'value' };
expect(hasDataProperty(value)).toBe(false);
});

it('should return false for object with non-array data', () => {
const value = { data: 'not an array' };
expect(hasDataProperty(value)).toBe(false);
});

it('should return false for non-object values', () => {
expect(hasDataProperty(null)).toBe(false);
expect(hasDataProperty(undefined)).toBe(false);
expect(hasDataProperty('string')).toBe(false);
expect(hasDataProperty(123)).toBe(false);
expect(hasDataProperty([])).toBe(false);
});
});

describe('hasProperty', () => {
it('should return true when object has the property', () => {
const value = { prop: 'value', other: 'data' };
expect(hasProperty(value, 'prop')).toBe(true);
expect(hasProperty(value, 'other')).toBe(true);
});

it('should return false when object does not have the property', () => {
const value = { prop: 'value' };
expect(hasProperty(value, 'missing')).toBe(false);
});

it('should return false for non-object values', () => {
expect(hasProperty(null, 'prop')).toBe(false);
expect(hasProperty(undefined, 'prop')).toBe(false);
expect(hasProperty('string', 'prop')).toBe(false);
expect(hasProperty(123, 'prop')).toBe(false);
expect(hasProperty([], 'prop')).toBe(false);
});
});
Loading