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
26 changes: 16 additions & 10 deletions example/request/app/components/demo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<header className='sticy top-0 margin-auto text-center p-3 w-full'>
<div className='flex items-center justify-center gap-2'>
<button onClick={() => dispatch(request({ api: '/api/bundle', method: 'POST', body: { pending: 5000, } }))}>
Slow fetch
<button onClick={() => dispatch(request({ api, method: 'POST', body: { pending: 5000, } }))} className={btn}>
Slow fetch
</button>
<button onClick={() => dispatch(request({ api: '/api/bundle', method: 'POST', body: { pending: 1000, } }))}>
<button onClick={() => dispatch(request({ api, method: 'POST', body: { pending: 1000, } }))} className={btn}>
Fast fetch
</button>
</div>
</header>
<section className='grid grid-cols-4 gap-4'>
<article>
<h3>Cdeebee.storage</h3>
<JsonView value={state.storage ?? {}} collapsed={false} displayDataTypes={false} style={nordTheme} />
</article>
{loading ? <p className='text-center text-red-600 font-bold'>Loading...</p> : <p>&nbsp;</p>}
<section className='grid grid-cols-3 gap-4'>
<article>
<h3>Cdeebee.Settings</h3>
<h3 className='font-bold text-center'>cdeebee.Settings</h3>
<JsonView value={state.settings} collapsed={false} displayDataTypes={false} style={nordTheme} />
</article>
<article>
<h3>Cdeebee.request</h3>
<h3 className='font-bold text-center'>cdeebee.request</h3>
<JsonView value={state.request} collapsed={false} displayDataTypes={false} style={nordTheme} />
</article>
<article>
<h3 className='font-bold text-center'>cdeebee.storage</h3>
<JsonView value={state.storage ?? {}} collapsed={false} displayDataTypes={false} style={nordTheme} />
</article>
</section>
</>
);
Expand Down
6 changes: 6 additions & 0 deletions example/request/app/hook/useLoading.ts
Original file line number Diff line number Diff line change
@@ -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);
}
11 changes: 2 additions & 9 deletions example/request/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/reducer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ 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);
},
}
},
extraReducers: builder => {
builder
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-1",
"version": "3.0.0-beta.2",
"description": "React Redux data-logic library",
"repository": "git@github.com:recats/cdeebee.git",
"author": "recats",
Expand Down
60 changes: 60 additions & 0 deletions tests/lib/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> = {
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<Record<string, unknown>> = [
{ key: ['test'], value: 'value' },
];
expect(valueList).toBeDefined();
});

it('should export CdeebeeRequestOptions type', () => {
// Type check - if this compiles, the export works
const options: CdeebeeRequestOptions<Record<string, unknown>> = {
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');
});
});

143 changes: 142 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 } 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', () => {
Expand Down Expand Up @@ -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<string, unknown>, 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<typeof left>);

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<string, unknown>, right as Record<string, unknown>);

expect(result).toBe(123);
});
});

describe('omit', () => {
Expand Down Expand Up @@ -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<string, unknown> = { a: 1, b: 2 };
Expand Down Expand Up @@ -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<string, unknown> = {
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<Record<string, unknown>>;
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<string, unknown> = {
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<Record<string, unknown>>;
expect(items[0].name).toBe('Updated Item 1');
});
});