diff --git a/example/request/app/components/demo/index.tsx b/example/request/app/components/demo/index.tsx index 8853c81..e5c6c2f 100644 --- a/example/request/app/components/demo/index.tsx +++ b/example/request/app/components/demo/index.tsx @@ -3,35 +3,41 @@ import { request } from '@recats/cdeebee'; import JsonView from '@uiw/react-json-view'; import { nordTheme } from '@uiw/react-json-view/nord'; import { useAppDispatch, useAppSelector } from '@/lib/hooks'; +import useLoading from '@/app/hook/useLoading'; +const btn = 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 cursor:pointer'; + +const api = '/api/bundle'; export default function Counter () { const state = useAppSelector(state => state.cdeebee); + const loading = useLoading(api); const dispatch = useAppDispatch(); return ( <> - dispatch(request({ api: '/api/bundle', method: 'POST', body: { pending: 5000, } }))}> - Slow fetch + dispatch(request({ api, method: 'POST', body: { pending: 5000, } }))} className={btn}> + Slow fetch - dispatch(request({ api: '/api/bundle', method: 'POST', body: { pending: 1000, } }))}> + dispatch(request({ api, method: 'POST', body: { pending: 1000, } }))} className={btn}> Fast fetch - - - Cdeebee.storage - - + {loading ? Loading... : } + - Cdeebee.Settings + cdeebee.Settings - Cdeebee.request + cdeebee.request + + cdeebee.storage + + > ); diff --git a/example/request/app/hook/useLoading.ts b/example/request/app/hook/useLoading.ts new file mode 100644 index 0000000..100018b --- /dev/null +++ b/example/request/app/hook/useLoading.ts @@ -0,0 +1,6 @@ +import { useAppSelector } from '@/lib/hooks'; + +export default function useLoading(api: string) { + const active = useAppSelector(state => state.cdeebee.request.active); + return active.some(req => req.api === api); +} diff --git a/example/request/pnpm-lock.yaml b/example/request/pnpm-lock.yaml index 402a475..539692b 100644 --- a/example/request/pnpm-lock.yaml +++ b/example/request/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@recats/cdeebee': specifier: file:../.. - version: file:../..(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.2.37)(react@18.3.1)(redux@5.0.1))(react@18.3.1))(ramda@0.32.0) + version: file:../..(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.2.37)(react@18.3.1)(redux@5.0.1))(react@18.3.1)) '@reduxjs/toolkit': specifier: ^2.2.0 version: 2.11.2(react-redux@9.2.0(@types/react@18.2.37)(react@18.3.1)(redux@5.0.1))(react@18.3.1) @@ -273,7 +273,6 @@ packages: resolution: {directory: ../.., type: directory} peerDependencies: '@reduxjs/toolkit': '>=2' - ramda: '>=0.27.1' '@reduxjs/toolkit@2.11.2': resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} @@ -558,9 +557,6 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - ramda@0.32.0: - resolution: {integrity: sha512-GQWAHhxhxWBWA8oIBr1XahFVjQ9Fic6MK9ikijfd4TZHfE2+urfk+irVlR5VOn48uwMgM+loRRBJd6Yjsbc0zQ==} - react-dom@18.3.1: resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -795,10 +791,9 @@ snapshots: '@next/swc-win32-x64-msvc@16.1.1': optional: true - '@recats/cdeebee@file:../..(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.2.37)(react@18.3.1)(redux@5.0.1))(react@18.3.1))(ramda@0.32.0)': + '@recats/cdeebee@file:../..(@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.2.37)(react@18.3.1)(redux@5.0.1))(react@18.3.1))': dependencies: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@18.2.37)(react@18.3.1)(redux@5.0.1))(react@18.3.1) - ramda: 0.32.0 '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@18.2.37)(react@18.3.1)(redux@5.0.1))(react@18.3.1)': dependencies: @@ -1033,8 +1028,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - ramda@0.32.0: {} - react-dom@18.3.1(react@18.3.1): dependencies: loose-envify: 1.4.0 diff --git a/lib/reducer/index.ts b/lib/reducer/index.ts index 2fd4ec9..20c917b 100644 --- a/lib/reducer/index.ts +++ b/lib/reducer/index.ts @@ -33,7 +33,7 @@ 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); - }, + } }, extraReducers: builder => { builder diff --git a/package.json b/package.json index f470268..e3d1f4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@recats/cdeebee", - "version": "3.0.0.beta-1", + "version": "3.0.0-beta.2", "description": "React Redux data-logic library", "repository": "git@github.com:recats/cdeebee.git", "author": "recats", diff --git a/tests/lib/index.test.ts b/tests/lib/index.test.ts new file mode 100644 index 0000000..5b6c35e --- /dev/null +++ b/tests/lib/index.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; +import { + type CdeebeeState, + type CdeebeeValueList, + type CdeebeeRequestOptions, + batchingUpdate, + request, + factory, +} from '../../lib/index'; + +describe('lib/index exports', () => { + it('should export CdeebeeState type', () => { + // Type check - if this compiles, the export works + const state: CdeebeeState> = { + settings: { + modules: [], + fileKey: 'file', + bodyKey: 'body', + primaryKey: 'id', + mergeWithData: {}, + }, + storage: {}, + request: { + active: [], + errors: {}, + done: {}, + }, + }; + expect(state).toBeDefined(); + }); + + it('should export CdeebeeValueList type', () => { + // Type check - if this compiles, the export works + const valueList: CdeebeeValueList> = [ + { key: ['test'], value: 'value' }, + ]; + expect(valueList).toBeDefined(); + }); + + it('should export CdeebeeRequestOptions type', () => { + // Type check - if this compiles, the export works + const options: CdeebeeRequestOptions> = { + api: '/test', + }; + expect(options).toBeDefined(); + }); + + it('should export batchingUpdate function', () => { + expect(typeof batchingUpdate).toBe('function'); + }); + + it('should export request function', () => { + expect(typeof request).toBe('function'); + }); + + it('should export factory function', () => { + expect(typeof factory).toBe('function'); + }); +}); + diff --git a/tests/lib/reducer/helpers.test.ts b/tests/lib/reducer/helpers.test.ts index 6801260..02b0b82 100644 --- a/tests/lib/reducer/helpers.test.ts +++ b/tests/lib/reducer/helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; -import { checkModule, mergeDeepRight, omit, batchingUpdate } from '../../../lib/reducer/helpers'; +import { checkModule, mergeDeepRight, omit, batchingUpdate, assocPath } from '../../../lib/reducer/helpers'; import { type CdeebeeSettings, type CdeebeeModule } from '../../../lib/reducer/types'; describe('checkModule', () => { @@ -165,6 +165,30 @@ describe('mergeDeepRight', () => { expect(result).toEqual({ a: 1 }); }); + + it('should return right value when left is not a record', () => { + const left = [1, 2, 3] as unknown; + const right = { a: 1 }; + const result = mergeDeepRight(left as Record, right); + + expect(result).toEqual({ a: 1 }); + }); + + it('should return right value when right is not a record', () => { + const left = { a: 1 }; + const right = [1, 2, 3] as unknown; + const result = mergeDeepRight(left, right as Partial); + + expect(result).toEqual([1, 2, 3]); + }); + + it('should return right value when both are not records', () => { + const left = 'string' as unknown; + const right = 123 as unknown; + const result = mergeDeepRight(left as Record, right as Record); + + expect(result).toBe(123); + }); }); describe('omit', () => { @@ -227,6 +251,89 @@ describe('omit', () => { }); }); +describe('assocPath', () => { + it('should set value at top-level path', () => { + const obj = { a: 1, b: 2 }; + const result = assocPath(['a'], 10, obj); + + expect(result).toEqual({ a: 10, b: 2 }); + expect(result).not.toBe(obj); // Should return new object + }); + + it('should set value at nested path', () => { + const obj = { a: { b: { c: 1 } } }; + const result = assocPath(['a', 'b', 'c'], 10, obj); + + expect(result).toEqual({ a: { b: { c: 10 } } }); + }); + + it('should create nested structure if it does not exist', () => { + const obj = { a: 1 }; + const result = assocPath(['b', 'c', 'd'], 10, obj); + + expect(result).toEqual({ a: 1, b: { c: { d: 10 } } }); + }); + + it('should handle empty path by returning value', () => { + const obj = { a: 1 }; + const result = assocPath([], 10, obj); + + expect(result).toBe(10); + }); + + it('should handle numeric keys in path', () => { + const obj = { items: [{ id: 1 }, { id: 2 }] }; + const result = assocPath(['items', 0, 'name'], 'Item 1', obj); + + expect(result).toEqual({ items: [{ id: 1, name: 'Item 1' }, { id: 2 }] }); + }); + + it('should handle array as base object', () => { + const obj = [{ a: 1 }, { b: 2 }]; + const result = assocPath([0, 'a'], 10, obj); + + expect(result).toEqual([{ a: 10 }, { b: 2 }]); + expect(Array.isArray(result)).toBe(true); + }); + + it('should create object structure when path contains numeric key (treats as string)', () => { + const obj = {}; + const result = assocPath(['items', 0, 'name'], 'First', obj); + + // assocPath treats numeric keys as strings, so it creates an object with '0' key + expect(result).toEqual({ items: { '0': { name: 'First' } } }); + }); + + it('should handle deep nested paths with mixed types', () => { + const obj = { level1: { level2: [] } }; + const result = assocPath(['level1', 'level2', 0, 'value'], 'deep', obj); + + expect(result).toEqual({ level1: { level2: [{ value: 'deep' }] } }); + }); + + it('should not mutate original object', () => { + const obj = { a: { b: 1 } }; + const original = { a: { b: 1 } }; + assocPath(['a', 'b'], 2, obj); + + expect(obj).toEqual(original); + }); + + it('should handle path with single element', () => { + const obj = { a: 1, b: 2 }; + const result = assocPath(['c'], 3, obj); + + expect(result).toEqual({ a: 1, b: 2, c: 3 }); + }); + + it('should overwrite existing nested values', () => { + const obj = { a: { b: { c: 1, d: 2 } } }; + const result = assocPath(['a', 'b', 'c'], 10, obj); + + expect(result).toEqual({ a: { b: { c: 10, d: 2 } } }); + }); +}); + describe('batchingUpdate', () => { it('should update a single top-level key', () => { const state: Record = { a: 1, b: 2 }; @@ -391,4 +498,38 @@ describe('batchingUpdate', () => { // Should mutate the same object reference expect(state.a).toBe(2); }); + + it('should skip update when current becomes array after traversing path', () => { + const state: Record = { + items: [{ id: 1 }, { id: 2 }], + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const valueList: any = [ + { key: ['items', 0], value: 'should be skipped' }, + ]; + + batchingUpdate(state, valueList); + + // Should remain unchanged because current is an array at final step + // The continue statement triggers when trying to update an element in an array + const items = state.items as Array>; + expect(Array.isArray(state.items)).toBe(true); + expect(items[0]).toEqual({ id: 1 }); + expect(items[1]).toEqual({ id: 2 }); + }); + + it('should handle path where intermediate step is array but final is object', () => { + const state: Record = { + items: [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }], + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const valueList: any = [ + { key: ['items', 0, 'name'], value: 'Updated Item 1' }, + ]; + + batchingUpdate(state, valueList); + + const items = state.items as Array>; + expect(items[0].name).toBe('Updated Item 1'); + }); });
Loading...