diff --git a/README.md b/README.md index 407937d..9024a5c 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,362 @@ [![npm](https://img.shields.io/npm/v/@recats/cdeebee.svg)](https://www.npmjs.com/package/@recats/cdeebee) +A Redux-based data management library that provides a uniform way to access, fetch, update, and manage application data with minimal boilerplate code. + +## Installation + ```sh npm i @recats/cdeebee # or yarn add @recats/cdeebee +# or +pnpm add @recats/cdeebee +``` + +## What is cdeebee? + +cdeebee is a data management library built on top of Redux Toolkit that aims to: +- Provide a uniform way of accessing data +- Decrease boilerplate code when working with data fetching, updating, committing, and rollbacking +- Manage normalized data structures similar to relational databases +- Handle API requests with built-in normalization, error handling, and request cancellation + +## Core Concepts + +### Normalized Storage + +cdeebee stores data in a normalized structure similar to a relational database. Data is organized into **lists** (tables) where each list is a JavaScript object with keys representing primary keys (IDs) of entities. + +For example, a forum application might have this structure: + +```typescript +{ + forumList: {}, + threadList: {}, + postList: {} +} +``` + +After fetching data, the storage might look like: + +```typescript +{ + forumList: { + 1: { id: 1, title: 'Milky Way Galaxy' } + }, + threadList: { + 10001: { id: 10001, title: 'Solar system', forumID: 1 } + }, + postList: { + 2: { id: 2, title: 'Earth', threadID: 10001 } + } +} +``` + +### Modules + +cdeebee uses a modular architecture with the following modules: + +- **`storage`**: Automatically normalizes and stores API responses +- **`history`**: Tracks request history (successful and failed requests) +- **`listener`**: Tracks active requests for loading states +- **`cancelation`**: Manages request cancellation (automatically cancels previous requests to the same API) + +## Quick Start + +### 1. Setup Redux Store + +```typescript +import { configureStore, combineSlices } from '@reduxjs/toolkit'; +import { factory } from '@recats/cdeebee'; + +// Define your storage structure +interface Storage { + forumList: Record; + threadList: Record; + postList: Record; +} + +// Create cdeebee slice +export const cdeebeeSlice = factory( + { + modules: ['history', 'listener', 'cancelation', 'storage'], + fileKey: 'file', + bodyKey: 'value', + primaryKey: 'id', + listStrategy: { + forumList: 'merge', + threadList: 'replace', + }, + mergeWithData: { + sessionToken: 'your-session-token', + }, + mergeWithHeaders: { + 'Authorization': 'Bearer token', + }, + }, + { + // Optional initial storage state + forumList: {}, + threadList: {}, + postList: {}, + } +); + +// Combine with other reducers +const rootReducer = combineSlices(cdeebeeSlice); + +export const store = configureStore({ + reducer: rootReducer, +}); +``` + +### 2. Make API Requests + +```typescript +import { request } from '@recats/cdeebee'; +import { useAppDispatch } from './hooks'; + +function MyComponent() { + const dispatch = useAppDispatch(); + + const fetchForums = () => { + dispatch(request({ + api: '/api/forums', + method: 'POST', + body: { filter: 'active' }, + onResult: (result) => { + console.log('Request completed:', result); + }, + })); + }; + + return ; +} +``` + +### 3. Access Data and Loading States + +```typescript +import { useAppSelector } from './hooks'; + +function ForumsList() { + const forums = useAppSelector(state => state.cdeebee.storage.forumList); + const activeRequests = useAppSelector(state => state.cdeebee.request.active); + const isLoading = activeRequests.some(req => req.api === '/api/forums'); + + return ( +
+ {isLoading &&

Loading...

} + {Object.values(forums).map(forum => ( +
{forum.title}
+ ))} +
+ ); +} +``` + +## Configuration + +### Settings + +The `factory` function accepts a settings object with the following options: + +```typescript +interface CdeebeeSettings { + modules: CdeebeeModule[]; // Active modules: 'history' | 'listener' | 'storage' | 'cancelation' + fileKey: string; // Key name for file uploads in FormData + bodyKey: string; // Key name for request body in FormData + primaryKey: string; // Primary key field name in API responses (default: 'primaryKey') + listStrategy?: CdeebeeListStrategy; // Merge strategy per list: 'merge' | 'replace' + mergeWithData?: unknown; // Data to merge with every request body + mergeWithHeaders?: Record; // Headers to merge with every request + normalize?: (storage, result, strategyList) => T; // Custom normalization function +} +``` + +### Request Options + +```typescript +interface CdeebeeRequestOptions { + api: string; // API endpoint URL + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: unknown; // Request body + headers?: Record; // Additional headers (merged with mergeWithHeaders) + files?: File[]; // Files to upload + fileKey?: string; // Override default fileKey + bodyKey?: string; // Override default bodyKey + listStrategy?: CdeebeeListStrategy; // Override list strategy for this request + normalize?: (storage, result, strategyList) => T; // Override normalization + onResult?: (response: T) => void; // Callback called with response data on success +} +``` + +## Data Merging Strategies + +cdeebee supports two strategies for merging data: + +- **`merge`**: Merges new data with existing data, preserving existing keys not in the response +- **`replace`**: Completely replaces the list with new data + +```typescript +listStrategy: { + forumList: 'merge', // New forums are merged with existing ones + threadList: 'replace', // Thread list is completely replaced +} +``` + +## API Response Format + +cdeebee expects API responses in a specific format for automatic normalization: + +```typescript +{ + forumList: { + primaryKey: 'id', // The field name specified in settings.primaryKey + data: [ + { id: 1, title: 'Forum 1' }, + { id: 2, title: 'Forum 2' }, + ] + }, + threadList: { + primaryKey: 'id', + data: [ + { id: 101, title: 'Thread 1', forumID: 1 }, + ] + } +} +``` + +The library automatically normalizes this into: + +```typescript +{ + forumList: { + 1: { id: 1, title: 'Forum 1' }, + 2: { id: 2, title: 'Forum 2' }, + }, + threadList: { + 101: { id: 101, title: 'Thread 1', forumID: 1 }, + } +} +``` + +## Advanced Usage + +### File Uploads + +```typescript +const file = new File(['content'], 'document.pdf', { type: 'application/pdf' }); + +dispatch(request({ + api: '/api/upload', + method: 'POST', + files: [file], + body: { description: 'My document' }, + fileKey: 'file', // Optional: override default + bodyKey: 'metadata', // Optional: override default +})); +``` + +### Custom Headers + +```typescript +// Global headers (in settings) +mergeWithHeaders: { + 'Authorization': 'Bearer token', + 'X-Custom-Header': 'value', +} + +// Per-request headers (override global) +dispatch(request({ + api: '/api/data', + headers: { + 'Authorization': 'Bearer different-token', // Overrides global + 'X-Request-ID': '123', // Additional header + }, +})); ``` + +### Request Cancellation + +When the `cancelation` module is enabled, cdeebee automatically cancels previous requests to the same API endpoint when a new request is made: + +```typescript +// First request +dispatch(request({ api: '/api/data', body: { query: 'slow' } })); + +// Second request to same API - first one is automatically cancelled +dispatch(request({ api: '/api/data', body: { query: 'fast' } })); +``` + +### Manual State Updates + +You can manually update the storage using the `set` action: + +```typescript +import { batchingUpdate } from '@recats/cdeebee'; + +// Update multiple values at once +const updates = [ + { key: ['forumList', '1', 'title'], value: 'New Title' }, + { key: ['forumList', '1', 'views'], value: 100 }, + { key: ['threadList', '101', 'title'], value: 'Updated Thread' }, +]; + +dispatch(cdeebeeSlice.actions.set(updates)); +``` + +### Accessing Request History + +```typescript +const doneRequests = useAppSelector(state => state.cdeebee.request.done); +const errors = useAppSelector(state => state.cdeebee.request.errors); + +// Get history for specific API +const apiHistory = doneRequests['/api/forums'] || []; +const apiErrors = errors['/api/forums'] || []; +``` + +## TypeScript Support + +cdeebee is fully typed. Define your storage type and get full type safety: + +```typescript +interface MyStorage { + userList: Record; + postList: Record; +} + +const slice = factory(settings); + +// TypeScript knows the structure +const users = useSelector(state => state.cdeebee.storage.userList); +// users: Record +``` + +## Exports + +```typescript +// Main exports +export { factory } from '@recats/cdeebee'; // Create cdeebee slice +export { request } from '@recats/cdeebee'; // Request thunk +export { batchingUpdate } from '@recats/cdeebee'; // Batch update helper + +// Types +export type { + CdeebeeState, + CdeebeeSettings, + CdeebeeRequestOptions, + CdeebeeValueList, + CdeebeeActiveRequest, +} from '@recats/cdeebee'; +``` + +## Examples + +See the `example/` directory in the repository for a complete working example with Next.js. + +## License + +MIT diff --git a/example/request/app/components/demo/index.tsx b/example/request/app/components/demo/index.tsx index e5c6c2f..d0cea83 100644 --- a/example/request/app/components/demo/index.tsx +++ b/example/request/app/components/demo/index.tsx @@ -16,10 +16,10 @@ export default function Counter () { <>
- -
diff --git a/lib/reducer/index.ts b/lib/reducer/index.ts index 20c917b..9b5b934 100644 --- a/lib/reducer/index.ts +++ b/lib/reducer/index.ts @@ -14,6 +14,7 @@ const initialState: CdeebeeState = { primaryKey: 'primaryKey', listStrategy: {}, mergeWithData: {}, + mergeWithHeaders: {}, }, storage: {}, request: { diff --git a/lib/reducer/request.ts b/lib/reducer/request.ts index 01a6573..ee1d467 100644 --- a/lib/reducer/request.ts +++ b/lib/reducer/request.ts @@ -15,6 +15,7 @@ export const request = createAsyncThunk( try { const { method = 'POST', body, headers = {} } = options; + const extraHeaders: Record = { ...(settings.mergeWithHeaders ?? {}), ...headers }; const b = { ...(settings.mergeWithData ?? {}), ...(body ?? {}) }; let requestData: FormData | string = JSON.stringify(b); @@ -43,7 +44,7 @@ export const request = createAsyncThunk( headers: { 'ui-request-id': requestId, 'Content-Type': 'application/json', - ...headers, + ...extraHeaders, }, signal: abort.controller.signal, body: requestData, @@ -55,6 +56,9 @@ export const request = createAsyncThunk( return rejectWithValue(response); } const result = await response.json(); + if (options.onResult && typeof options.onResult === 'function') { + options.onResult(result); + } return { result, startedAt, endedAt: new Date().toUTCString() }; } catch (error) { checkModule(settings, 'cancelation', abort.drop); diff --git a/lib/reducer/types.ts b/lib/reducer/types.ts index b2c9392..978f5da 100644 --- a/lib/reducer/types.ts +++ b/lib/reducer/types.ts @@ -9,6 +9,7 @@ export interface CdeebeeSettings { bodyKey: string; primaryKey: string; mergeWithData: unknown; + mergeWithHeaders: unknown; listStrategy?: CdeebeeListStrategy; normalize?: (storage: CdeebeeState, result: T, strategyList: CdeebeeListStrategy) => T; } @@ -42,6 +43,7 @@ export interface CdeebeeRequestOptions extends Partial; + onResult?: (response: T) => void; } type KeyOf = Extract; diff --git a/package.json b/package.json index e3d1f4e..1434519 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@recats/cdeebee", - "version": "3.0.0-beta.2", + "version": "3.0.0-beta.3", "description": "React Redux data-logic library", "repository": "git@github.com:recats/cdeebee.git", "author": "recats", @@ -10,6 +10,7 @@ "lint:ts": "tsc --noEmit --project tsconfig.lint.json", "lint:all": "pnpm lint && pnpm lint:ts", "build": "vite build", + "prepublishOnly": "pnpm build", "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage" @@ -22,15 +23,27 @@ "server" ], "type": "module", - "main": "./lib/index.ts", - "types": "./lib/index.ts", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { - "types": "./lib/index.ts", - "import": "./lib/index.ts", - "default": "./lib/index.ts" + "types": "./dist/index.d.ts", + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.cjs" + } } }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], "devDependencies": { "@reduxjs/toolkit": "^2.11.2", "@types/lodash": "^4.17.21", diff --git a/tests/lib/reducer/request.test.ts b/tests/lib/reducer/request.test.ts index 20885e0..1ee78a0 100644 --- a/tests/lib/reducer/request.test.ts +++ b/tests/lib/reducer/request.test.ts @@ -289,6 +289,182 @@ describe('request', () => { expect(result.payload).toHaveProperty('endedAt'); } }); + + it('should call onResult callback with response data when provided', async () => { + const mockResponse = { data: 'test', id: 123 }; + const onResult = vi.fn(); + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const options: CdeebeeRequestOptions = { + api: '/api/test', + onResult, + }; + + await dispatch(request(options)); + + expect(onResult).toHaveBeenCalledTimes(1); + expect(onResult).toHaveBeenCalledWith(mockResponse); + }); + + it('should not call onResult when it is not provided', async () => { + const mockResponse = { data: 'test' }; + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const options: CdeebeeRequestOptions = { + api: '/api/test', + }; + + await dispatch(request(options)); + + // No error should occur when onResult is not provided + expect(global.fetch).toHaveBeenCalled(); + }); + + it('should not call onResult when request fails', async () => { + const onResult = vi.fn(); + + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: false, + status: 500, + } as Response); + + const options: CdeebeeRequestOptions = { + api: '/api/test', + onResult, + }; + + await dispatch(request(options)); + + expect(onResult).not.toHaveBeenCalled(); + }); + }); + + describe('mergeWithHeaders', () => { + it('should merge headers from settings with request headers', async () => { + const mockResponse = { data: 'test' }; + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + settings.mergeWithHeaders = { 'X-Custom-Header': 'from-settings', 'X-Another': 'settings-value' }; + + const slice = factory(settings); + store = configureStore({ + reducer: { + cdeebee: slice.reducer, + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dispatch = store.dispatch as any; + + const options: CdeebeeRequestOptions = { + api: '/api/test', + headers: { 'Authorization': 'Bearer token', 'X-Another': 'request-value' }, + }; + + await dispatch(request(options)); + + const callArgs = (global.fetch as ReturnType).mock.calls[0]; + expect(callArgs[1].headers).toHaveProperty('X-Custom-Header', 'from-settings'); + expect(callArgs[1].headers).toHaveProperty('Authorization', 'Bearer token'); + // Request headers should override settings headers + expect(callArgs[1].headers).toHaveProperty('X-Another', 'request-value'); + }); + + it('should use only settings headers when request headers are not provided', async () => { + const mockResponse = { data: 'test' }; + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + settings.mergeWithHeaders = { 'X-Settings-Header': 'settings-only' }; + + const slice = factory(settings); + store = configureStore({ + reducer: { + cdeebee: slice.reducer, + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dispatch = store.dispatch as any; + + const options: CdeebeeRequestOptions = { + api: '/api/test', + }; + + await dispatch(request(options)); + + const callArgs = (global.fetch as ReturnType).mock.calls[0]; + expect(callArgs[1].headers).toHaveProperty('X-Settings-Header', 'settings-only'); + }); + + it('should handle empty mergeWithHeaders', async () => { + const mockResponse = { data: 'test' }; + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + settings.mergeWithHeaders = {}; + + const slice = factory(settings); + store = configureStore({ + reducer: { + cdeebee: slice.reducer, + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dispatch = store.dispatch as any; + + const options: CdeebeeRequestOptions = { + api: '/api/test', + headers: { 'Authorization': 'Bearer token' }, + }; + + await dispatch(request(options)); + + const callArgs = (global.fetch as ReturnType).mock.calls[0]; + expect(callArgs[1].headers).toHaveProperty('Authorization', 'Bearer token'); + }); + + it('should handle undefined mergeWithHeaders', async () => { + const mockResponse = { data: 'test' }; + (global.fetch as ReturnType).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + settings.mergeWithHeaders = undefined; + + const slice = factory(settings); + store = configureStore({ + reducer: { + cdeebee: slice.reducer, + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + dispatch = store.dispatch as any; + + const options: CdeebeeRequestOptions = { + api: '/api/test', + headers: { 'Authorization': 'Bearer token' }, + }; + + await dispatch(request(options)); + + const callArgs = (global.fetch as ReturnType).mock.calls[0]; + expect(callArgs[1].headers).toHaveProperty('Authorization', 'Bearer token'); + }); }); }); diff --git a/vite.config.mjs b/vite.config.mjs index a54080f..b6b0a77 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -6,22 +6,32 @@ import dts from 'vite-plugin-dts'; export default defineConfig({ plugins: [ - dts({ include: ['lib'] }), + dts({ + include: ['lib'], + outDir: 'dist', + rollupTypes: true, + }), ], build: { lib: { - // eslint-disable-next-line entry: resolve(__dirname, 'lib/index.ts'), name: 'cdeebee', - fileName: 'cdeebee', + fileName: (format) => { + if (format === 'es') return 'index.js'; + if (format === 'cjs') return 'index.cjs'; + return 'index.umd.js'; + }, + formats: ['es', 'cjs'], }, rollupOptions: { - external: ['redux'], + external: ['@reduxjs/toolkit', 'redux'], output: { globals: { - redux: 'redux', + '@reduxjs/toolkit': 'ReduxToolkit', + redux: 'Redux', }, }, }, + sourcemap: true, }, })