diff --git a/README.md b/README.md index 293b0b8..d1984fb 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ For example, a forum application might have this structure: } ``` -After fetching data, the storage might look like: +After fetching data from the API (which returns data in the format `{ data: [...], primaryKey: 'id' }`), cdeebee automatically normalizes it and the storage might look like: ```typescript { @@ -52,6 +52,8 @@ After fetching data, the storage might look like: } ``` +**Note:** The API should return list data in the format `{ data: [...], primaryKey: 'fieldName' }`. cdeebee automatically converts this format into the normalized storage structure shown above. See the [API Response Format](#api-response-format) section for details. + ### Modules cdeebee uses a modular architecture with the following modules: @@ -210,7 +212,32 @@ listStrategy: { ## API Response Format -cdeebee expects API responses in a normalized format where data is already organized as objects with keys representing entity IDs: +cdeebee expects API responses in a format where list data is provided as arrays with a `primaryKey` field. The library automatically normalizes this data into the storage structure. + +### List Format + +For lists (collections of entities), the API should return data in the following format: + +```typescript +{ + forumList: { + data: [ + { id: 1, title: 'Forum 1' }, + { id: 2, title: 'Forum 2' }, + ], + primaryKey: 'id', + }, + threadList: { + data: [ + { id: 101, title: 'Thread 1', forumID: 1 }, + { id: 102, title: 'Thread 2', forumID: 1 }, + ], + primaryKey: 'id', + } +} +``` + +cdeebee automatically converts this format into normalized storage: ```typescript { @@ -220,10 +247,62 @@ cdeebee expects API responses in a normalized format where data is already organ }, threadList: { 101: { id: 101, title: 'Thread 1', forumID: 1 }, + 102: { id: 102, title: 'Thread 2', forumID: 1 }, } } ``` +The `primaryKey` field specifies which property of each item should be used as the key in the normalized structure. The `primaryKey` value is converted to a string to ensure consistent key types. + +**Example:** + +If your API returns: +```typescript +{ + sessionList: { + data: [ + { + sessionID: 1, + token: 'da6ec385bc7e4f84a510c3ecca07f3', + expiresAt: '2034-03-28T22:36:09' + } + ], + primaryKey: 'sessionID', + } +} +``` + +It will be automatically normalized to: +```typescript +{ + sessionList: { + '1': { + sessionID: 1, + token: 'da6ec385bc7e4f84a510c3ecca07f3', + expiresAt: '2034-03-28T22:36:09' + } + } +} +``` + +### Non-List Data + +For non-list data (configuration objects, simple values, etc.), you can return them as regular objects: + +```typescript +{ + config: { + theme: 'dark', + language: 'en', + }, + userPreferences: { + notifications: true, + } +} +``` + +These will be stored as-is in the storage. + ## Advanced Usage ### File Uploads diff --git a/lib/reducer/storage.ts b/lib/reducer/storage.ts index a0e8bcb..0bf5b26 100644 --- a/lib/reducer/storage.ts +++ b/lib/reducer/storage.ts @@ -7,6 +7,42 @@ type IResponse = Record; type StorageData = Record; +function isDataWithPrimaryKey(value: unknown): value is { data: unknown[]; primaryKey: string } { + return ( + isRecord(value) && + Array.isArray(value.data) && + typeof value.primaryKey === 'string' + ); +} +function normalizeDataWithPrimaryKey(data: unknown[], primaryKey: string): Record { + const result: Record = {}; + + for (const item of data) { + if (isRecord(item) && primaryKey in item) { + const key = String(item[primaryKey]); + result[key] = item; + } + } + + return result; +} + +function applyStrategy( + existingValue: StorageData, + newValue: StorageData | ResponseValue, + strategy: string, + key: string +): ResponseValue { + if (strategy === 'replace') { + return newValue as ResponseValue; + } else if (strategy === 'merge') { + return mergeDeepRight(existingValue, newValue as StorageData) as ResponseValue; + } else { + console.warn(`Cdeebee: Unknown strategy "${strategy}" for key "${key}". Skipping normalization.`); + return mergeDeepRight(existingValue, newValue as StorageData) as ResponseValue; + } +} + export function defaultNormalize( cdeebee: CdeebeeState, response: IResponse, @@ -26,20 +62,17 @@ export function defaultNormalize( continue; } - const isNormalized = isRecord(responseValue); const strategy = strategyList[key as keyof T] ?? 'merge'; + const existingValue = key in currentStorage ? (currentStorage[key] as StorageData) : {}; + + if (isDataWithPrimaryKey(responseValue)) { + const normalizedValue = normalizeDataWithPrimaryKey(responseValue.data, responseValue.primaryKey); + result[key] = applyStrategy(existingValue, normalizedValue, strategy, key); + continue; + } - if (isNormalized) { - const existingValue = key in currentStorage ? (currentStorage[key] as StorageData) : {}; - - if (strategy === 'replace') { - result[key] = responseValue as ResponseValue; - } else if (strategy === 'merge') { - result[key] = mergeDeepRight(existingValue, responseValue as StorageData) as ResponseValue; - } else { - console.warn(`Cdeebee: Unknown strategy "${strategy}" for key "${key}". Skipping normalization.`); - result[key] = mergeDeepRight(existingValue, responseValue as StorageData) as ResponseValue; - } + if (isRecord(responseValue)) { + result[key] = applyStrategy(existingValue, responseValue as StorageData, strategy, key); } else { result[key] = responseValue; } diff --git a/package.json b/package.json index 7da84bf..55060ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@recats/cdeebee", - "version": "3.0.0-beta.7", + "version": "3.0.0-beta.8", "description": "React Redux data-logic library", "repository": "git@github.com:recats/cdeebee.git", "author": "recats", diff --git a/tests/lib/reducer/storage.test.ts b/tests/lib/reducer/storage.test.ts index 12e007d..54b840a 100644 --- a/tests/lib/reducer/storage.test.ts +++ b/tests/lib/reducer/storage.test.ts @@ -34,13 +34,16 @@ describe('defaultNormalize', () => { }); describe('replace strategy', () => { - it('should use already normalized response with replace strategy', () => { + it('should use data array with primaryKey and replace strategy', () => { const response = { userList: { - '1': { id: '1', name: 'John' }, - '2': { id: '2', name: 'Jane' }, + data: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + primaryKey: 'id', }, - }; + } as unknown as IResponse; const strategyList: CdeebeeListStrategy<{ userList: string }> = { userList: 'replace', @@ -54,7 +57,7 @@ describe('defaultNormalize', () => { }); }); - it('should replace existing data with new data', () => { + it('should replace existing data with new data from array format', () => { mockCdeebee.storage = { userList: { '1': { id: '1', name: 'Old John' }, @@ -64,10 +67,13 @@ describe('defaultNormalize', () => { const response = { userList: { - '1': { id: '1', name: 'New John' }, - '2': { id: '2', name: 'Jane' }, + data: [ + { id: '1', name: 'New John' }, + { id: '2', name: 'Jane' }, + ], + primaryKey: 'id', }, - }; + } as unknown as IResponse; const strategyList: CdeebeeListStrategy = { userList: 'replace', @@ -84,7 +90,7 @@ describe('defaultNormalize', () => { }); describe('merge strategy', () => { - it('should merge new data with existing state', () => { + it('should merge new data with existing state from array format', () => { mockCdeebee.storage = { userList: { '1': { id: '1', name: 'John', age: 30 }, @@ -94,10 +100,13 @@ describe('defaultNormalize', () => { const response = { userList: { - '1': { id: '1', name: 'John Updated', age: 31 }, - '2': { id: '2', name: 'Jane' }, + data: [ + { id: '1', name: 'John Updated', age: 31 }, + { id: '2', name: 'Jane' }, + ], + primaryKey: 'id', }, - }; + } as unknown as IResponse; const strategyList: CdeebeeListStrategy = { userList: 'merge', @@ -115,13 +124,16 @@ describe('defaultNormalize', () => { }); }); - it('should create new storage when no existing state', () => { + it('should create new storage when no existing state from array format', () => { const response = { userList: { - '1': { id: '1', name: 'John' }, - '2': { id: '2', name: 'Jane' }, + data: [ + { id: '1', name: 'John' }, + { id: '2', name: 'Jane' }, + ], + primaryKey: 'id', }, - }; + } as unknown as IResponse; const strategyList: CdeebeeListStrategy = { userList: 'merge', @@ -140,9 +152,12 @@ describe('defaultNormalize', () => { it('should fall back to merge strategy for unknown strategy', () => { const response = { userList: { - '1': { id: '1', name: 'John' }, + data: [ + { id: '1', name: 'John' }, + ], + primaryKey: 'id', }, - }; + } as unknown as IResponse; const strategyList: CdeebeeListStrategy = { userList: 'unknown' as any, @@ -163,7 +178,10 @@ describe('defaultNormalize', () => { it('should remove keys with null values', () => { const response = { userList: { - '1': { id: '1', name: 'John' }, + data: [ + { id: '1', name: 'John' }, + ], + primaryKey: 'id', }, invalid: null, } as unknown as IResponse; @@ -181,7 +199,10 @@ describe('defaultNormalize', () => { it('should remove keys with undefined values', () => { const response = { userList: { - '1': { id: '1', name: 'John' }, + data: [ + { id: '1', name: 'John' }, + ], + primaryKey: 'id', }, invalid: undefined, } as unknown as IResponse; @@ -199,7 +220,10 @@ describe('defaultNormalize', () => { it('should remove keys with string values', () => { const response = { userList: { - '1': { id: '1', name: 'John' }, + data: [ + { id: '1', name: 'John' }, + ], + primaryKey: 'id', }, message: 'some string', } as unknown as IResponse; @@ -439,4 +463,220 @@ describe('defaultNormalize', () => { }); }); }); + + describe('data with primaryKey format', () => { + it('should convert data array with primaryKey to normalized object structure', () => { + const response = { + sessionList: { + data: [ + { + sessionID: 1, + token: 'da6ec385bc7e4f84a510c3ecca07f3', + expiresAt: '2034-03-28T22:36:09', + }, + ], + primaryKey: 'sessionID', + }, + } as unknown as IResponse; + + const strategyList: CdeebeeListStrategy = { + sessionList: 'replace', + }; + + const result = defaultNormalize(mockCdeebee, response, strategyList); + + expect(result.sessionList).toEqual({ + '1': { + sessionID: 1, + token: 'da6ec385bc7e4f84a510c3ecca07f3', + expiresAt: '2034-03-28T22:36:09', + }, + }); + }); + + it('should handle multiple items in data array with primaryKey', () => { + const response = { + sessionList: { + data: [ + { + sessionID: 1, + token: 'token1', + expiresAt: '2034-03-28T22:36:09', + }, + { + sessionID: 2, + token: 'token2', + expiresAt: '2034-03-29T22:36:09', + }, + ], + primaryKey: 'sessionID', + }, + } as unknown as IResponse; + + const strategyList: CdeebeeListStrategy = { + sessionList: 'replace', + }; + + const result = defaultNormalize(mockCdeebee, response, strategyList); + + expect(result.sessionList).toEqual({ + '1': { + sessionID: 1, + token: 'token1', + expiresAt: '2034-03-28T22:36:09', + }, + '2': { + sessionID: 2, + token: 'token2', + expiresAt: '2034-03-29T22:36:09', + }, + }); + }); + + it('should merge data array with primaryKey with existing data using merge strategy', () => { + mockCdeebee.storage = { + sessionList: { + '1': { + sessionID: 1, + token: 'old-token', + expiresAt: '2034-03-27T22:36:09', + }, + }, + }; + + const response = { + sessionList: { + data: [ + { + sessionID: 1, + token: 'new-token', + expiresAt: '2034-03-28T22:36:09', + }, + { + sessionID: 2, + token: 'token2', + expiresAt: '2034-03-29T22:36:09', + }, + ], + primaryKey: 'sessionID', + }, + } as unknown as IResponse; + + const strategyList: CdeebeeListStrategy = { + sessionList: 'merge', + }; + + const result = defaultNormalize(mockCdeebee, response, strategyList); + + expect(result.sessionList).toEqual({ + '1': { + sessionID: 1, + token: 'new-token', + expiresAt: '2034-03-28T22:36:09', + }, + '2': { + sessionID: 2, + token: 'token2', + expiresAt: '2034-03-29T22:36:09', + }, + }); + }); + + it('should replace existing data when using replace strategy with data array', () => { + mockCdeebee.storage = { + sessionList: { + '1': { + sessionID: 1, + token: 'old-token', + expiresAt: '2034-03-27T22:36:09', + }, + '3': { + sessionID: 3, + token: 'token3', + expiresAt: '2034-03-30T22:36:09', + }, + }, + }; + + const response = { + sessionList: { + data: [ + { + sessionID: 1, + token: 'new-token', + expiresAt: '2034-03-28T22:36:09', + }, + ], + primaryKey: 'sessionID', + }, + } as unknown as IResponse; + + const strategyList: CdeebeeListStrategy = { + sessionList: 'replace', + }; + + const result = defaultNormalize(mockCdeebee, response, strategyList); + + expect(result.sessionList).toEqual({ + '1': { + sessionID: 1, + token: 'new-token', + expiresAt: '2034-03-28T22:36:09', + }, + }); + expect(result.sessionList).not.toHaveProperty('3'); + }); + + it('should handle empty data array with primaryKey', () => { + const response = { + sessionList: { + data: [], + primaryKey: 'sessionID', + }, + } as unknown as IResponse; + + const strategyList: CdeebeeListStrategy = { + sessionList: 'replace', + }; + + const result = defaultNormalize(mockCdeebee, response, strategyList); + + expect(result.sessionList).toEqual({}); + }); + + it('should handle string primaryKey values', () => { + const response = { + userList: { + data: [ + { + id: 'user-1', + name: 'John', + }, + { + id: 'user-2', + name: 'Jane', + }, + ], + primaryKey: 'id', + }, + } as unknown as IResponse; + + const strategyList: CdeebeeListStrategy = { + userList: 'replace', + }; + + const result = defaultNormalize(mockCdeebee, response, strategyList); + + expect(result.userList).toEqual({ + 'user-1': { + id: 'user-1', + name: 'John', + }, + 'user-2': { + id: 'user-2', + name: 'Jane', + }, + }); + }); + }); });