riducers 1.2.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,34 +1,328 @@
1
- # Dynamic Reducers for id based entities
1
+ # riducers
2
2
 
3
- Plug and play reducers to frontend entity based RESTful APIs.
3
+ Plug-and-play Redux reducers for entity-based state management. Supports four state types optimized for different use cases.
4
4
 
5
- These reducers come with insert, delete, replace, clear, and sort action types. Intercept AJAX API calls and dispatch appropriate events to maintain the entity states in redux automagically.
5
+ ## Installation
6
6
 
7
- ## Usage
7
+ ```bash
8
+ npm install riducers
9
+ ```
10
+
11
+ ## State Types
12
+
13
+ | Type | State Shape | Best For |
14
+ |------|-------------|----------|
15
+ | `list` | `ModelObj[]` | Ordered collections |
16
+ | `map` | `{ [id]: ModelObj }` | Fast lookups by ID |
17
+ | `mapList` | `{ [key]: ModelObj[] }` | Grouped/categorized data |
18
+ | `static` | `ModelObj \| null` | Single objects (auth, settings) |
8
19
 
9
- ### Initializing the reducers
20
+ ## Quick Start
10
21
 
11
- ```TypeScript
22
+ ```typescript
12
23
  import { configureStore } from '@reduxjs/toolkit'
13
24
  import { reducerBuilder } from 'riducers'
14
- import { combineReducers } from 'redux'
15
25
 
16
- const reducer = {
17
- users: reducerBuilder('user', {stateType: 'list'}),
18
- auth: reducerBuilder('auth', {}),
19
- ui: {
20
- api: reducerBuilder('ui/api', { stateType: 'map'}),
26
+ const store = configureStore({
27
+ reducer: {
28
+ users: reducerBuilder('users', { stateType: 'list' }),
29
+ usersById: reducerBuilder('usersById', { stateType: 'map' }),
30
+ tasksByProject: reducerBuilder('tasks', { stateType: 'mapList', mapKeyName: 'projectId' }),
31
+ currentUser: reducerBuilder('currentUser'),
21
32
  }
33
+ })
34
+ ```
35
+
36
+ ---
37
+
38
+ ## 1. List Reducer (`stateType: 'list'`)
39
+
40
+ Stores entities as an ordered array. Supports: `insert`, `unshift`, `replace`, `delete`, `shift`, `sort`, `clear`.
41
+
42
+ ```typescript
43
+ const usersReducer = reducerBuilder('users', { stateType: 'list' })
44
+ ```
45
+
46
+ ### Actions & State Transitions
47
+
48
+ ```typescript
49
+ // Initial state
50
+ state = []
51
+
52
+ // insert - append items
53
+ dispatch({ type: 'users/insert', payload: { id: 1, name: 'Alice' } })
54
+ state = [{ id: 1, name: 'Alice' }]
55
+
56
+ dispatch({ type: 'users/insert', payload: [{ id: 2, name: 'Bob' }, { id: 3, name: 'Carol' }] })
57
+ state = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Carol' }]
58
+
59
+ // unshift - prepend item
60
+ dispatch({ type: 'users/unshift', payload: { id: 0, name: 'Zoe' } })
61
+ state = [{ id: 0, name: 'Zoe' }, { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, { id: 3, name: 'Carol' }]
62
+
63
+ // delete - remove by id
64
+ dispatch({ type: 'users/delete', payload: { id: 2 } })
65
+ state = [{ id: 0, name: 'Zoe' }, { id: 1, name: 'Alice' }, { id: 3, name: 'Carol' }]
66
+
67
+ // shift - remove first item
68
+ dispatch({ type: 'users/shift' })
69
+ state = [{ id: 1, name: 'Alice' }, { id: 3, name: 'Carol' }]
70
+
71
+ // sort - reorder (default: ascending by keyName)
72
+ dispatch({ type: 'users/sort' })
73
+ state = [{ id: 1, name: 'Alice' }, { id: 3, name: 'Carol' }]
74
+
75
+ // sort with custom comparator
76
+ dispatch({ type: 'users/sort', sort: (a, b) => b.id - a.id })
77
+ state = [{ id: 3, name: 'Carol' }, { id: 1, name: 'Alice' }]
78
+
79
+ // replace - replace entire state
80
+ dispatch({ type: 'users/replace', payload: [{ id: 10, name: 'New' }] })
81
+ state = [{ id: 10, name: 'New' }]
82
+
83
+ // clear - reset to initial state
84
+ dispatch({ type: 'users/clear' })
85
+ state = []
86
+ ```
87
+
88
+ ### With Auto-Sort
89
+
90
+ ```typescript
91
+ const sortedReducer = reducerBuilder('users', {
92
+ stateType: 'list',
93
+ sort: (a, b) => a.name.localeCompare(b.name)
94
+ })
95
+
96
+ dispatch({ type: 'users/insert', payload: [{ id: 1, name: 'Zoe' }, { id: 2, name: 'Alice' }] })
97
+ state = [{ id: 2, name: 'Alice' }, { id: 1, name: 'Zoe' }] // auto-sorted
98
+ ```
99
+
100
+ ---
101
+
102
+ ## 2. Map Reducer (`stateType: 'map'`)
103
+
104
+ Stores entities keyed by ID for O(1) lookups. Supports: `insert`, `replace`, `delete`, `clear`.
105
+
106
+ ```typescript
107
+ const usersReducer = reducerBuilder('users', { stateType: 'map' })
108
+ ```
109
+
110
+ ### Actions & State Transitions
111
+
112
+ ```typescript
113
+ // Initial state
114
+ state = {}
115
+
116
+ // insert - add/update items
117
+ dispatch({ type: 'users/insert', payload: { id: 1, name: 'Alice' } })
118
+ state = { 1: { id: 1, name: 'Alice' } }
119
+
120
+ dispatch({ type: 'users/insert', payload: [{ id: 2, name: 'Bob' }, { id: 3, name: 'Carol' }] })
121
+ state = {
122
+ 1: { id: 1, name: 'Alice' },
123
+ 2: { id: 2, name: 'Bob' },
124
+ 3: { id: 3, name: 'Carol' }
22
125
  }
23
126
 
24
- const store = configureStore({ reducer })
127
+ // insert updates existing item
128
+ dispatch({ type: 'users/insert', payload: { id: 1, name: 'Alice Updated' } })
129
+ state = {
130
+ 1: { id: 1, name: 'Alice Updated' },
131
+ 2: { id: 2, name: 'Bob' },
132
+ 3: { id: 3, name: 'Carol' }
133
+ }
134
+
135
+ // delete - remove by id
136
+ dispatch({ type: 'users/delete', payload: { id: 2 } })
137
+ state = {
138
+ 1: { id: 1, name: 'Alice Updated' },
139
+ 3: { id: 3, name: 'Carol' }
140
+ }
25
141
 
26
- store.dispatch('user/insert', {payload: [{id: 1, name: 'John'}]})
27
- store.dispatch('user/delete', {payload: [{id: 1}]})
28
- store.dispatch('user/replace', {payload: [{id: 1, name: 'Foo'}, {id: 2, name: 'Bar'}]})
29
- store.dispatch('user/sort', {})
30
- store.dispatch('user/clear', {})
142
+ // replace - replace entire state
143
+ dispatch({ type: 'users/replace', payload: [{ id: 10, name: 'New' }] })
144
+ state = { 10: { id: 10, name: 'New' } }
31
145
 
146
+ // clear - reset to initial state
147
+ dispatch({ type: 'users/clear' })
148
+ state = {}
32
149
  ```
33
150
 
34
- See the Jest tests (test/main.test.ts) for more action types and uses.
151
+ ### Shorthand Syntax
152
+
153
+ Map reducers support using the key directly as the action type:
154
+
155
+ ```typescript
156
+ // Insert/update using key as action type
157
+ dispatch({ type: 'users/123', payload: { name: 'Alice' } })
158
+ state = { 123: { id: 123, name: 'Alice' } }
159
+
160
+ // Delete by dispatch with no payload
161
+ dispatch({ type: 'users/123', payload: null })
162
+ state = {}
163
+ ```
164
+
165
+ ---
166
+
167
+ ## 3. MapList Reducer (`stateType: 'mapList'`)
168
+
169
+ Groups entities into keyed arrays—ideal for categorized data. Requires `mapKeyName`. Supports: `insert`, `unshift`, `replace`, `delete`, `shift`, `sort`, `clear`.
170
+
171
+ ```typescript
172
+ const tasksReducer = reducerBuilder('tasks', {
173
+ stateType: 'mapList',
174
+ keyName: 'id',
175
+ mapKeyName: 'projectId',
176
+ initialState: {}
177
+ })
178
+ ```
179
+
180
+ ### Actions & State Transitions
181
+
182
+ ```typescript
183
+ // Initial state
184
+ state = {}
185
+
186
+ // insert - items grouped by mapKeyName
187
+ dispatch({ type: 'tasks/insert', payload: { id: 1, projectId: 'a', title: 'Task 1' } })
188
+ state = {
189
+ a: [{ id: 1, projectId: 'a', title: 'Task 1' }]
190
+ }
191
+
192
+ dispatch({ type: 'tasks/insert', payload: [
193
+ { id: 2, projectId: 'a', title: 'Task 2' },
194
+ { id: 3, projectId: 'b', title: 'Task 3' }
195
+ ]})
196
+ state = {
197
+ a: [{ id: 1, projectId: 'a', title: 'Task 1' }, { id: 2, projectId: 'a', title: 'Task 2' }],
198
+ b: [{ id: 3, projectId: 'b', title: 'Task 3' }]
199
+ }
200
+
201
+ // delete - items removed across all groups, empty groups removed
202
+ dispatch({ type: 'tasks/delete', payload: { id: 3 } })
203
+ state = {
204
+ a: [{ id: 1, projectId: 'a', title: 'Task 1' }, { id: 2, projectId: 'a', title: 'Task 2' }]
205
+ }
206
+
207
+ // sort - sorts within each group
208
+ dispatch({ type: 'tasks/sort', sort: (a, b) => b.id - a.id })
209
+ state = {
210
+ a: [{ id: 2, projectId: 'a', title: 'Task 2' }, { id: 1, projectId: 'a', title: 'Task 1' }]
211
+ }
212
+
213
+ // shift - removes first item from each group
214
+ dispatch({ type: 'tasks/shift' })
215
+ state = {
216
+ a: [{ id: 1, projectId: 'a', title: 'Task 1' }]
217
+ }
218
+
219
+ // replace - replace entire state
220
+ dispatch({ type: 'tasks/replace', payload: [{ id: 10, projectId: 'x', title: 'New' }] })
221
+ state = {
222
+ x: [{ id: 10, projectId: 'x', title: 'New' }]
223
+ }
224
+
225
+ // clear - reset to initial state
226
+ dispatch({ type: 'tasks/clear' })
227
+ state = {}
228
+ ```
229
+
230
+ ---
231
+
232
+ ## 4. Static Reducer (default)
233
+
234
+ Stores a single object or null. Supports: `insert`, `replace`, `delete`, `clear`.
235
+
236
+ ```typescript
237
+ const authReducer = reducerBuilder('auth')
238
+ ```
239
+
240
+ ### Actions & State Transitions
241
+
242
+ ```typescript
243
+ // Initial state
244
+ state = null
245
+
246
+ // insert - set the object
247
+ dispatch({ type: 'auth/insert', payload: { userId: 1, token: 'abc123' } })
248
+ state = { userId: 1, token: 'abc123' }
249
+
250
+ // replace - replace entire object
251
+ dispatch({ type: 'auth/replace', payload: { userId: 2, token: 'xyz789' } })
252
+ state = { userId: 2, token: 'xyz789' }
253
+
254
+ // delete - set to null
255
+ dispatch({ type: 'auth/delete' })
256
+ state = null
257
+
258
+ // clear - reset to initial state
259
+ dispatch({ type: 'auth/clear' })
260
+ state = null
261
+ ```
262
+
263
+ ---
264
+
265
+ ## Options
266
+
267
+ ```typescript
268
+ interface ReducerOpts {
269
+ stateType?: 'list' | 'map' | 'static' | 'mapList' // default: 'static'
270
+ keyName?: string // ID field name, default: 'id'
271
+ mapKeyName?: string // Required for mapList - key to group by
272
+ sort?: SortFn // Auto-sort on insert
273
+ initialState?: any // Custom initial state
274
+ }
275
+ ```
276
+
277
+ ## Full Store Example
278
+
279
+ ```typescript
280
+ import { configureStore } from '@reduxjs/toolkit'
281
+ import { reducerBuilder } from 'riducers'
282
+
283
+ const store = configureStore({
284
+ reducer: {
285
+ // Ordered list of posts
286
+ posts: reducerBuilder('posts', { stateType: 'list' }),
287
+
288
+ // Users indexed by ID for quick lookup
289
+ users: reducerBuilder('users', { stateType: 'map' }),
290
+
291
+ // Comments grouped by postId
292
+ comments: reducerBuilder('comments', {
293
+ stateType: 'mapList',
294
+ mapKeyName: 'postId',
295
+ initialState: {}
296
+ }),
297
+
298
+ // Current authenticated user
299
+ auth: reducerBuilder('auth'),
300
+ }
301
+ })
302
+
303
+ // After some dispatches, state might look like:
304
+ // {
305
+ // posts: [
306
+ // { id: 1, title: 'Hello World' },
307
+ // { id: 2, title: 'Redux Tips' }
308
+ // ],
309
+ // users: {
310
+ // 1: { id: 1, name: 'Alice' },
311
+ // 2: { id: 2, name: 'Bob' }
312
+ // },
313
+ // comments: {
314
+ // 1: [
315
+ // { id: 101, postId: 1, text: 'Great post!' },
316
+ // { id: 102, postId: 1, text: 'Thanks!' }
317
+ // ],
318
+ // 2: [
319
+ // { id: 201, postId: 2, text: 'Very helpful' }
320
+ // ]
321
+ // },
322
+ // auth: { userId: 1, token: 'abc123' }
323
+ // }
324
+ ```
325
+
326
+ ## License
327
+
328
+ MIT
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "riducers",
3
- "version": "1.2.5",
4
- "description": "Dynamic reducers with insert, delete, replace, clear, and sort actions on 'id' based list of objects",
3
+ "version": "2.0.0",
4
+ "description": "Plug-and-play Redux reducers for entity-based state management. Supports list, map, mapList, and static state types with built-in insert, delete, replace, sort, and clear actions.",
5
5
  "main": "dist/index.js",
6
6
  "module": "esm/index.js",
7
7
  "files": [
@@ -26,13 +26,13 @@
26
26
  "author": "Yusuf Bhabhrawala",
27
27
  "license": "ISC",
28
28
  "devDependencies": {
29
- "@types/jest": "^29.2.0",
30
- "jest": "^29.2.1",
31
- "rimraf": "^3.0.2",
32
- "ts-jest": "^29.0.3",
33
- "typescript": "^4.8.4"
29
+ "@types/jest": "^30.0.0",
30
+ "jest": "^30.2.0",
31
+ "rimraf": "^6.1.3",
32
+ "ts-jest": "^29.4.6",
33
+ "typescript": "^5.9.3"
34
34
  },
35
35
  "dependencies": {
36
- "redux": "^4.2.1"
36
+ "redux": "^5.0.1"
37
37
  }
38
38
  }
package/src/helpers.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { ModelObj, SortFn } from "./types";
2
+
3
+ /**
4
+ * Creates a sort function that sorts by a specific key
5
+ */
6
+ export const keySort = (key: string): SortFn => (a: ModelObj, b: ModelObj) => {
7
+ if (typeof a[key] === "number" && typeof b[key] === "number") {
8
+ return a[key] - b[key];
9
+ } else if (typeof a[key] === "string" && typeof b[key] === "string") {
10
+ return a[key].localeCompare(b[key]);
11
+ }
12
+ return 0;
13
+ };
14
+
15
+ /**
16
+ * Normalizes payload to always be an array
17
+ */
18
+ export const normalizePayload = <T>(payload: T | T[]): T[] => {
19
+ return Array.isArray(payload) ? payload : [payload];
20
+ };
21
+
22
+ /**
23
+ * Initializes state with default value if undefined/null
24
+ */
25
+ export const initializeState = <T>(state: T | undefined | null, defaultValue: T): T => {
26
+ return state ?? defaultValue;
27
+ };
package/src/index.ts CHANGED
@@ -1,241 +1,244 @@
1
- type Id = number | string;
1
+ import { ReducerOpts, ReducerAction, ModelObj, SortFn } from "./types";
2
+ import { mapInsertReducer, mapDeleteReducer, mapReplaceReducer } from "./mapReducers";
3
+ import {
4
+ listInsertReducer,
5
+ listUnshiftReducer,
6
+ listSortReducer,
7
+ listDeleteReducer,
8
+ listShiftReducer,
9
+ listReplaceReducer
10
+ } from "./listReducers";
11
+ import {
12
+ mapListInsertReducer,
13
+ mapListDeleteReducer,
14
+ mapListReplaceReducer,
15
+ mapListSortReducer,
16
+ mapListShiftReducer,
17
+ mapListUnshiftReducer,
18
+ MapListState
19
+ } from "./mapListReducers";
20
+ import { objInsertReducer, objReplaceReducer, objDeleteReducer } from "./objReducers";
21
+
22
+ // Re-export types for external usage
23
+ export * from "./types";
24
+ export { keySort } from "./helpers";
2
25
 
3
- interface ModelObj {
4
- [key: string]: any;
5
- }
6
-
7
- type SortFn = (a: ModelObj, b: ModelObj) => number;
8
-
9
- interface ListAction {
10
- payload: ModelObj | ModelObj[];
11
- sort?: SortFn;
12
- keyName: string;
13
- mapKeyName?: string;
14
- isMapList: boolean;
15
- }
16
-
17
- interface MapAction {
18
- payload: ModelObj | ModelObj[];
19
- sort?: SortFn;
20
- keyName: string;
21
- }
22
-
23
- interface ListSortAction {
24
- sort?: SortFn;
25
- keyName: string;
26
- mapKeyName?: string;
27
- isMapList: boolean;
28
- }
29
-
30
- const keySort = (key: string) => (a: ModelObj, b: ModelObj) => {
31
- if (typeof a[key] === "number" && typeof b[key] === "number") return a[key] - b[key];
32
- else if (typeof a[key] === "string" && typeof b[key] === "string") return a[key].localeCompare(b[key]);
33
- return 0;
34
- };
26
+ const OPS = ["insert", "replace", "delete", "clear", "sort"];
35
27
 
36
- const mapInsertReducer = (state: ModelObj, action: MapAction) => {
37
- if (!state) state = {};
38
- let resp: ModelObj[] = Array.isArray(action.payload) ? action.payload : [action.payload];
39
- let newState = { ...state };
40
- for (let i in resp) {
41
- if (resp[i]?.[action.keyName]) newState[resp[i][action.keyName]] = { ...resp[i] };
28
+ /**
29
+ * Gets the default initial state based on state type
30
+ */
31
+ const getDefaultInitialState = (stateType: string): any => {
32
+ switch (stateType) {
33
+ case "list":
34
+ return [];
35
+ case "map":
36
+ return {};
37
+ default:
38
+ return null;
42
39
  }
43
- return newState;
44
40
  };
45
41
 
46
- const mapDeleteReducer = (state: ModelObj, action: MapAction) => {
47
- if (!state) state = {};
48
- let resp: ModelObj[] = Array.isArray(action.payload) ? action.payload : [action.payload];
49
- let newState = { ...state };
50
- for (let i in resp) {
51
- delete newState[resp[i][action.keyName]];
42
+ /**
43
+ * Handles map-specific operation normalization
44
+ * When using map type, allows using the key as the operation (e.g., "users/123" for insert/delete)
45
+ */
46
+ const normalizeMapOperation = (
47
+ op: string,
48
+ payload: any,
49
+ keyName: string
50
+ ): { op: string; payload: any } => {
51
+ if (!OPS.includes(op)) {
52
+ if (payload && !Array.isArray(payload)) {
53
+ payload[keyName] = op;
54
+ return { op: "insert", payload };
55
+ }
56
+ if (!payload) {
57
+ const newPayload: ModelObj = { [keyName]: op };
58
+ return { op: "delete", payload: newPayload };
59
+ }
52
60
  }
53
- return newState;
61
+ return { op, payload };
54
62
  };
55
63
 
56
- const mapReplaceReducer = (state: ModelObj, action: MapAction) => {
57
- let newState = {} as any;
58
- let resp: ModelObj[] = Array.isArray(action.payload) ? action.payload : [action.payload];
59
- for (let i in resp) {
60
- if (resp[i]?.[action.keyName]) newState[resp[i][action.keyName]] = { ...resp[i] };
64
+ /**
65
+ * Handles list operations (insert, replace, delete, shift, sort)
66
+ */
67
+ const handleListOperation = (
68
+ state: ModelObj[],
69
+ op: string,
70
+ payload: any,
71
+ sort: SortFn | undefined,
72
+ keyName: string,
73
+ initialState: any
74
+ ): ModelObj[] | undefined => {
75
+ const isMapList = false;
76
+ switch (op) {
77
+ case "insert":
78
+ if (payload) return listInsertReducer(state, { payload, sort, keyName, isMapList });
79
+ break;
80
+ case "unshift":
81
+ if (payload) return listUnshiftReducer(state, { payload, sort, keyName, isMapList });
82
+ break;
83
+ case "replace":
84
+ return listReplaceReducer(state, { payload, sort, keyName, isMapList });
85
+ case "delete":
86
+ return listDeleteReducer(state, { payload, keyName, isMapList });
87
+ case "shift":
88
+ return listShiftReducer(state);
89
+ case "clear":
90
+ return initialState;
91
+ case "sort":
92
+ return listSortReducer(state, { sort, keyName, isMapList });
61
93
  }
62
- return newState;
94
+ return undefined;
63
95
  };
64
96
 
65
- const listInsertReducer = (state: ModelObj[], action: ListAction) => {
66
- if (!state) state = [];
67
- let resp: ModelObj[] = Array.isArray(action.payload) ? action.payload : [action.payload];
68
-
69
- let newState = [...state];
70
- let respMap: Id[] = resp.map((r: any) => r[action.keyName]);
71
- let stateMap: Id[] = newState.map((r: any) => r[action.keyName]);
72
- let toInsert: Id[] = respMap.filter((id: number | string) => stateMap.indexOf(id) < 0);
73
- let toReplace: Id[] = respMap.filter((id: number | string) => stateMap.indexOf(id) >= 0);
74
- for (let i in toReplace) {
75
- let stateIndex = stateMap.indexOf(toReplace[i]);
76
- let respIndex = respMap.indexOf(toReplace[i]);
77
- newState[stateIndex] = { ...resp[respIndex] };
78
- }
79
- if (toInsert.length > 0) {
80
- let inserts = toInsert.map((id: number | string) => resp[respMap.indexOf(id)]);
81
- newState = [...newState, ...inserts];
97
+ /**
98
+ * Handles mapList operations (insert, replace, delete, shift, sort, unshift)
99
+ * mapList is a hybrid: { [mapKeyValue]: ModelObj[] }
100
+ */
101
+ const handleMapListOperation = (
102
+ state: MapListState,
103
+ op: string,
104
+ payload: any,
105
+ sort: SortFn | undefined,
106
+ keyName: string,
107
+ mapKeyName: string,
108
+ initialState: any
109
+ ): MapListState | undefined => {
110
+ switch (op) {
111
+ case "insert":
112
+ if (payload) return mapListInsertReducer(state, { payload, sort, keyName, mapKeyName });
113
+ break;
114
+ case "unshift":
115
+ if (payload) return mapListUnshiftReducer(state, { payload, sort, keyName, mapKeyName });
116
+ break;
117
+ case "replace":
118
+ return mapListReplaceReducer(state, { payload, sort, keyName, mapKeyName });
119
+ case "delete":
120
+ return mapListDeleteReducer(state, { payload, keyName, mapKeyName });
121
+ case "shift":
122
+ return mapListShiftReducer(state);
123
+ case "clear":
124
+ return initialState;
125
+ case "sort":
126
+ return mapListSortReducer(state, { sort, keyName, mapKeyName });
82
127
  }
83
- if (action?.sort) newState.sort(action?.sort);
84
- return newState;
85
- };
86
-
87
- const listUnshiftReducer = (state: ModelObj[], action: ListAction) => {
88
- if (!state) state = [];
89
- let resp: ModelObj[] = Array.isArray(action.payload) ? action.payload : [action.payload];
90
-
91
- let newState = [...resp, ...state];
92
- return newState;
93
- }
94
-
95
- const listSortReducer = (state: ModelObj[], action: ListSortAction) => {
96
- if (!state) state = [];
97
- let newState = [...state];
98
- let sort = action?.sort ? action.sort : keySort(action.keyName);
99
- newState.sort(sort);
100
- return newState;
128
+ return undefined;
101
129
  };
102
130
 
103
- const listDeleteReducer = (state: ModelObj[], action: ListAction) => {
104
- if (!state || !Array.isArray(state)) state = [];
105
- let newState = [...state];
106
- let resp: ModelObj[] = Array.isArray(action.payload) ? action.payload : [action.payload];
107
-
108
- let stateMap: Id[] = newState.map((s: any) => s[action.keyName]);
109
- for (let i in resp) {
110
- let index = stateMap.indexOf(resp[i][action.keyName]);
111
- if (index >= 0) newState.splice(index, 1);
131
+ /**
132
+ * Handles map operations (insert, replace, delete, clear)
133
+ */
134
+ const handleMapOperation = (
135
+ state: ModelObj,
136
+ op: string,
137
+ payload: any,
138
+ keyName: string,
139
+ initialState: any
140
+ ): ModelObj | null | undefined => {
141
+ switch (op) {
142
+ case "insert":
143
+ if (payload) return mapInsertReducer(state, { payload, keyName });
144
+ break;
145
+ case "replace":
146
+ return mapReplaceReducer(state, { payload, keyName });
147
+ case "delete":
148
+ return mapDeleteReducer(state, { payload, keyName });
149
+ case "clear":
150
+ return initialState;
112
151
  }
113
- return newState;
152
+ return undefined;
114
153
  };
115
154
 
116
- const listShiftReducer = (state: ModelObj[]) => {
117
- if (!state || !Array.isArray(state)) state = [];
118
- let newState = [...state];
119
- newState.shift();
120
- return newState;
121
- }
122
-
123
- const listReplaceReducer = (state: ModelObj[], action: ListAction) => {
124
- let resp: ModelObj[] = Array.isArray(action.payload) ? action.payload : [action.payload];
125
- resp = resp.map((r: any) => ({ ...r }));
126
- if (action?.sort) resp.sort(action?.sort);
127
- return resp;
128
- };
129
-
130
- const objInsertReducer = (state: any, action: { payload: any }) => {
131
- return action.payload;
155
+ /**
156
+ * Handles static/object operations (insert, replace, delete, clear)
157
+ */
158
+ const handleStaticOperation = (
159
+ state: any,
160
+ op: string,
161
+ payload: any,
162
+ initialState: any
163
+ ): any | undefined => {
164
+ switch (op) {
165
+ case "insert":
166
+ return objInsertReducer(state, { payload });
167
+ case "replace":
168
+ return objReplaceReducer(state, { payload });
169
+ case "delete":
170
+ return objDeleteReducer();
171
+ case "clear":
172
+ return initialState;
173
+ }
174
+ return undefined;
132
175
  };
133
176
 
134
- const objReplaceReducer = (state: any, action: { payload: any }) => {
135
- return action.payload;
136
- };
177
+ /**
178
+ * Builds a reducer function for managing state with various operations
179
+ */
180
+ function reducerBuilder(key: string, opts?: ReducerOpts) {
181
+ const stateType = opts?.stateType ?? "static";
182
+ const isList = stateType === "list";
183
+ const isMap = stateType === "map";
184
+ const isMapList = stateType === "mapList";
137
185
 
138
- const objDeleteReducer = () => {
139
- return null;
140
- };
186
+ if (isMapList && !opts?.mapKeyName) {
187
+ throw new Error("mapKeyName is required for stateType: mapList");
188
+ }
141
189
 
142
- interface ReducerOpts {
143
- stateType?: "list" | "map" | "static" | "mapList";
144
- sort?: SortFn;
145
- keyName?: string;
146
- mapKeyName?: string;
147
- initialState?: any;
148
- }
190
+ // Use provided initialState if defined, otherwise get default based on state type
191
+ const initialState = opts?.initialState !== undefined ? opts.initialState : getDefaultInitialState(stateType);
192
+ const actionPattern = new RegExp(`^${key}/([^\/]+)$`);
149
193
 
150
- const OPS = ["insert", "replace", "delete", "clear", "sort"];
151
- function reducerBuilder(key: string, opts?: ReducerOpts) {
152
- let initialState = opts?.initialState;
153
- let stateType = opts?.stateType ?? "static";
154
- let isList = stateType === "list";
155
- let isMap = stateType === "map";
156
- let isMapList = stateType === "mapList";
157
-
158
- if (isMapList && !opts?.mapKeyName) throw new Error("mapKeyName is required for stateType: mapList");
159
-
160
- if (initialState === undefined) {
161
- if (isList) initialState = [];
162
- else if (isMap) initialState = {};
163
- else initialState = null;
164
- }
165
- return (state: any, action: { type: string; payload?: any; sort?: (a: ModelObj, b: ModelObj) => number }) => {
194
+ return (state: any, action: ReducerAction): any => {
195
+ // Initialize state
166
196
  if (state === undefined) {
167
- if (initialState !== undefined) return initialState;
168
- else if (isList) return [];
169
- else if (isMap) return {};
170
- else if (isMapList) return {};
171
- else return null;
197
+ return initialState !== undefined ? initialState : getDefaultInitialState(stateType);
172
198
  }
173
- let rx = new RegExp(`^${key}/([^\/]+)$`);
174
- let m = action.type.match(rx);
175
- if (!m) return state;
176
199
 
177
- let op = m[1];
200
+ // Match action type
201
+ const match = action.type.match(actionPattern);
202
+ if (!match) return state;
203
+
204
+ let op = match[1];
178
205
  let payload = action.payload;
179
- let keyName = opts?.keyName ?? "id";
180
- let mapKeyName = opts?.mapKeyName;
181
-
182
- if (isMap && !OPS.includes(op)) {
183
- if (payload && !Array.isArray(payload)) {
184
- payload[keyName] = op;
185
- op = "insert";
186
- }
187
- if (!payload) {
188
- payload = {};
189
- payload[keyName] = op;
190
- op = "delete";
191
- }
206
+ const keyName = opts?.keyName ?? "id";
207
+ const mapKeyName = opts?.mapKeyName;
208
+ const sort = action.sort ?? opts?.sort;
209
+
210
+ // Handle map-specific operation normalization
211
+ if (isMap) {
212
+ const normalized = normalizeMapOperation(op, payload, keyName);
213
+ op = normalized.op;
214
+ payload = normalized.payload;
192
215
  }
193
216
 
194
- let sort = action.sort ?? opts?.sort;
195
- if (op === "insert") {
196
-
197
- if ((isList || isMapList) && payload) return listInsertReducer(state, { payload, sort, keyName, mapKeyName, isMapList });
198
- else if (isMap && payload) return mapInsertReducer(state, { payload, keyName });
199
- return objInsertReducer(state, { payload });
200
-
201
- } else if (op === 'unshift') {
202
- if (isList && payload) return listUnshiftReducer(state, { payload, sort, keyName, mapKeyName, isMapList });
203
- else {
204
- console.error(`unshift is only valid for stateType: list or mapList`);
205
- }
206
- } else if (op === "replace") {
207
-
208
- if (isList || isMapList) return listReplaceReducer(state, { payload, sort, keyName, mapKeyName, isMapList });
209
- else if (isMap) return mapReplaceReducer(state, { payload, keyName });
210
- return objReplaceReducer(state, { payload });
211
-
212
- } else if (op === "delete") {
213
-
214
- if (isList || isMapList) return listDeleteReducer(state, { payload, keyName, mapKeyName, isMapList });
215
- else if (isMap) return mapDeleteReducer(state, { payload, keyName });
216
- return objDeleteReducer();
217
-
218
- } else if (op === "shift") {
219
- if (isList || isMapList) return listShiftReducer(state);
220
- else {
221
- console.error(`unshift is only valid for stateType: list or mapList`);
222
- }
223
-
224
- } else if (op === "clear") {
225
-
226
- return initialState;
227
-
228
- } else if (op === "sort") {
229
-
230
- if (isList || isMapList) return listSortReducer(state, { sort, keyName, mapKeyName, isMapList });
217
+ // Handle operations based on state type
218
+ let result: any;
231
219
 
220
+ if (isList) {
221
+ result = handleListOperation(state, op, payload, sort, keyName, initialState);
222
+ } else if (isMapList) {
223
+ result = handleMapListOperation(state, op, payload, sort, keyName, mapKeyName!, initialState);
224
+ } else if (isMap) {
225
+ result = handleMapOperation(state, op, payload, keyName, initialState);
232
226
  } else {
227
+ result = handleStaticOperation(state, op, payload, initialState);
228
+ }
233
229
 
234
- console.error(`unknown op (${op}) in action type: ${action.type}. Op should be one of: insert, delete, replace, clear, or sort.`);
230
+ if (result !== undefined) {
231
+ return result;
232
+ }
235
233
 
234
+ // Unknown operation
235
+ if (!["insert", "unshift", "replace", "delete", "shift", "clear", "sort"].includes(op)) {
236
+ console.error(
237
+ `unknown op (${op}) in action type: ${action.type}. Op should be one of: insert, delete, replace, clear, or sort.`
238
+ );
236
239
  }
237
-
238
- return state || (isList ? [] : isMap ? {} : null);
240
+
241
+ return state || getDefaultInitialState(stateType);
239
242
  };
240
243
  }
241
244
 
@@ -0,0 +1,86 @@
1
+ import { ModelObj, Id, ListAction, ListSortAction } from "./types";
2
+ import { normalizePayload, initializeState, keySort } from "./helpers";
3
+
4
+ /**
5
+ * Helper to get key mappings from items
6
+ */
7
+ const getKeyMap = (items: ModelObj[], keyName: string): Id[] => {
8
+ return items.map((item: ModelObj) => item[keyName]);
9
+ };
10
+
11
+ /**
12
+ * Helper to find items to insert (not in state) and items to replace (in state)
13
+ */
14
+ const partitionItems = (respMap: Id[], stateMap: Id[]) => {
15
+ const toInsert = respMap.filter((id: Id) => stateMap.indexOf(id) < 0);
16
+ const toReplace = respMap.filter((id: Id) => stateMap.indexOf(id) >= 0);
17
+ return { toInsert, toReplace };
18
+ };
19
+
20
+ export const listInsertReducer = (state: ModelObj[], action: ListAction): ModelObj[] => {
21
+ const currentState = initializeState(state, []);
22
+ const items = normalizePayload(action.payload);
23
+
24
+ let newState = [...currentState];
25
+ const respMap = getKeyMap(items, action.keyName);
26
+ const stateMap = getKeyMap(newState, action.keyName);
27
+ const { toInsert, toReplace } = partitionItems(respMap, stateMap);
28
+
29
+ // Update existing items
30
+ for (const id of toReplace) {
31
+ const stateIndex = stateMap.indexOf(id);
32
+ const respIndex = respMap.indexOf(id);
33
+ newState[stateIndex] = { ...items[respIndex] };
34
+ }
35
+
36
+ // Add new items
37
+ if (toInsert.length > 0) {
38
+ const inserts = toInsert.map((id: Id) => items[respMap.indexOf(id)]);
39
+ newState = [...newState, ...inserts];
40
+ }
41
+
42
+ if (action?.sort) {
43
+ newState.sort(action.sort);
44
+ }
45
+
46
+ return newState;
47
+ };
48
+
49
+ export const listUnshiftReducer = (state: ModelObj[], action: ListAction): ModelObj[] => {
50
+ const currentState = initializeState(state, []);
51
+ const items = normalizePayload(action.payload);
52
+ return [...items, ...currentState];
53
+ };
54
+
55
+ export const listSortReducer = (state: ModelObj[], action: ListSortAction): ModelObj[] => {
56
+ const currentState = initializeState(state, []);
57
+ const newState = [...currentState];
58
+ const sort = action?.sort ?? keySort(action.keyName);
59
+ newState.sort(sort);
60
+ return newState;
61
+ };
62
+
63
+ export const listDeleteReducer = (state: ModelObj[], action: ListAction): ModelObj[] => {
64
+ const currentState = !state || !Array.isArray(state) ? [] : state;
65
+ const items = normalizePayload(action.payload);
66
+ const deleteKeys = new Set<Id>(getKeyMap(items, action.keyName));
67
+ return currentState.filter((item: ModelObj) => !deleteKeys.has(item[action.keyName]));
68
+ };
69
+
70
+ export const listShiftReducer = (state: ModelObj[]): ModelObj[] => {
71
+ const currentState = !state || !Array.isArray(state) ? [] : state;
72
+ const newState = [...currentState];
73
+ newState.shift();
74
+ return newState;
75
+ };
76
+
77
+ export const listReplaceReducer = (state: ModelObj[], action: ListAction): ModelObj[] => {
78
+ const items = normalizePayload(action.payload);
79
+ const result = items.map((item: ModelObj) => ({ ...item }));
80
+
81
+ if (action?.sort) {
82
+ result.sort(action.sort);
83
+ }
84
+
85
+ return result;
86
+ };
@@ -0,0 +1,193 @@
1
+ import { ModelObj, Id, SortFn, MapListAction, MapListSortAction } from "./types";
2
+ import { normalizePayload, initializeState, keySort } from "./helpers";
3
+
4
+ /**
5
+ * State structure for mapList: { [mapKeyValue]: ModelObj[] }
6
+ */
7
+ export type MapListState = { [key: string]: ModelObj[] };
8
+
9
+ /**
10
+ * Helper to get key value from an item
11
+ */
12
+ const getKeyValue = (item: ModelObj, keyName: string): Id => item[keyName];
13
+
14
+ /**
15
+ * Helper to get map key value from an item
16
+ */
17
+ const getMapKeyValue = (item: ModelObj, mapKeyName: string): string => String(item[mapKeyName]);
18
+
19
+ /**
20
+ * Helper to insert/update an item in a list by keyName
21
+ */
22
+ const upsertInList = (
23
+ list: ModelObj[],
24
+ item: ModelObj,
25
+ keyName: string,
26
+ sort?: SortFn
27
+ ): ModelObj[] => {
28
+ const itemKey = getKeyValue(item, keyName);
29
+ const existingIndex = list.findIndex((i) => getKeyValue(i, keyName) === itemKey);
30
+
31
+ let newList: ModelObj[];
32
+ if (existingIndex >= 0) {
33
+ // Update existing item
34
+ newList = [...list];
35
+ newList[existingIndex] = { ...item };
36
+ } else {
37
+ // Add new item
38
+ newList = [...list, { ...item }];
39
+ }
40
+
41
+ if (sort) {
42
+ newList.sort(sort);
43
+ }
44
+
45
+ return newList;
46
+ };
47
+
48
+ /**
49
+ * Helper to delete items from a list by keyName
50
+ */
51
+ const deleteFromList = (
52
+ list: ModelObj[],
53
+ keysToDelete: Set<Id>,
54
+ keyName: string
55
+ ): ModelObj[] => {
56
+ return list.filter((item) => !keysToDelete.has(getKeyValue(item, keyName)));
57
+ };
58
+
59
+ /**
60
+ * Insert items into the mapList structure
61
+ * Groups items by mapKeyName, each group is a list managed by keyName
62
+ */
63
+ export const mapListInsertReducer = (
64
+ state: MapListState,
65
+ action: MapListAction
66
+ ): MapListState => {
67
+ const currentState = initializeState(state, {});
68
+ const items = normalizePayload(action.payload);
69
+ const newState = { ...currentState };
70
+
71
+ for (const item of items) {
72
+ if (!item?.[action.keyName] || !item?.[action.mapKeyName]) continue;
73
+
74
+ const mapKey = getMapKeyValue(item, action.mapKeyName);
75
+ const existingList = newState[mapKey] || [];
76
+ newState[mapKey] = upsertInList(existingList, item, action.keyName, action.sort);
77
+ }
78
+
79
+ return newState;
80
+ };
81
+
82
+ /**
83
+ * Delete items from the mapList structure
84
+ * Removes items by keyName, removes empty map keys
85
+ */
86
+ export const mapListDeleteReducer = (
87
+ state: MapListState,
88
+ action: MapListAction
89
+ ): MapListState => {
90
+ const currentState = initializeState(state, {});
91
+ const items = normalizePayload(action.payload);
92
+ const keysToDelete = new Set<Id>(items.map((item) => getKeyValue(item, action.keyName)));
93
+
94
+ const newState: MapListState = {};
95
+
96
+ for (const [mapKey, list] of Object.entries(currentState)) {
97
+ const filteredList = deleteFromList(list, keysToDelete, action.keyName);
98
+ if (filteredList.length > 0) {
99
+ newState[mapKey] = filteredList;
100
+ }
101
+ }
102
+
103
+ return newState;
104
+ };
105
+
106
+ /**
107
+ * Replace entire mapList state with new items
108
+ */
109
+ export const mapListReplaceReducer = (
110
+ state: MapListState,
111
+ action: MapListAction
112
+ ): MapListState => {
113
+ const items = normalizePayload(action.payload);
114
+ const newState: MapListState = {};
115
+
116
+ for (const item of items) {
117
+ if (!item?.[action.keyName] || !item?.[action.mapKeyName]) continue;
118
+
119
+ const mapKey = getMapKeyValue(item, action.mapKeyName);
120
+ const existingList = newState[mapKey] || [];
121
+ newState[mapKey] = [...existingList, { ...item }];
122
+ }
123
+
124
+ // Sort each list if sort function provided
125
+ if (action.sort) {
126
+ for (const mapKey of Object.keys(newState)) {
127
+ newState[mapKey].sort(action.sort);
128
+ }
129
+ }
130
+
131
+ return newState;
132
+ };
133
+
134
+ /**
135
+ * Sort all lists within the mapList structure
136
+ */
137
+ export const mapListSortReducer = (
138
+ state: MapListState,
139
+ action: MapListSortAction
140
+ ): MapListState => {
141
+ const currentState = initializeState(state, {});
142
+ const sort = action.sort ?? keySort(action.keyName);
143
+ const newState: MapListState = {};
144
+
145
+ for (const [mapKey, list] of Object.entries(currentState)) {
146
+ const sortedList = [...list];
147
+ sortedList.sort(sort);
148
+ newState[mapKey] = sortedList;
149
+ }
150
+
151
+ return newState;
152
+ };
153
+
154
+ /**
155
+ * Shift (remove first item) from all lists in mapList
156
+ * Removes empty map keys after shift
157
+ */
158
+ export const mapListShiftReducer = (state: MapListState): MapListState => {
159
+ const currentState = initializeState(state, {});
160
+ const newState: MapListState = {};
161
+
162
+ for (const [mapKey, list] of Object.entries(currentState)) {
163
+ const shiftedList = [...list];
164
+ shiftedList.shift();
165
+ if (shiftedList.length > 0) {
166
+ newState[mapKey] = shiftedList;
167
+ }
168
+ }
169
+
170
+ return newState;
171
+ };
172
+
173
+ /**
174
+ * Unshift (add to beginning) items into mapList
175
+ */
176
+ export const mapListUnshiftReducer = (
177
+ state: MapListState,
178
+ action: MapListAction
179
+ ): MapListState => {
180
+ const currentState = initializeState(state, {});
181
+ const items = normalizePayload(action.payload);
182
+ const newState = { ...currentState };
183
+
184
+ for (const item of items) {
185
+ if (!item?.[action.keyName] || !item?.[action.mapKeyName]) continue;
186
+
187
+ const mapKey = getMapKeyValue(item, action.mapKeyName);
188
+ const existingList = newState[mapKey] || [];
189
+ newState[mapKey] = [{ ...item }, ...existingList];
190
+ }
191
+
192
+ return newState;
193
+ };
@@ -0,0 +1,41 @@
1
+ import { ModelObj, MapAction } from "./types";
2
+ import { normalizePayload, initializeState } from "./helpers";
3
+
4
+ export const mapInsertReducer = (state: ModelObj, action: MapAction): ModelObj => {
5
+ const currentState = initializeState(state, {});
6
+ const items = normalizePayload(action.payload);
7
+ const newState = { ...currentState };
8
+
9
+ for (const item of items) {
10
+ if (item?.[action.keyName]) {
11
+ newState[item[action.keyName]] = { ...item };
12
+ }
13
+ }
14
+
15
+ return newState;
16
+ };
17
+
18
+ export const mapDeleteReducer = (state: ModelObj, action: MapAction): ModelObj => {
19
+ const currentState = initializeState(state, {});
20
+ const items = normalizePayload(action.payload);
21
+ const newState = { ...currentState };
22
+
23
+ for (const item of items) {
24
+ delete newState[item[action.keyName]];
25
+ }
26
+
27
+ return newState;
28
+ };
29
+
30
+ export const mapReplaceReducer = (state: ModelObj, action: MapAction): ModelObj => {
31
+ const items = normalizePayload(action.payload);
32
+ const newState: ModelObj = {};
33
+
34
+ for (const item of items) {
35
+ if (item?.[action.keyName]) {
36
+ newState[item[action.keyName]] = { ...item };
37
+ }
38
+ }
39
+
40
+ return newState;
41
+ };
@@ -0,0 +1,11 @@
1
+ export const objInsertReducer = <T>(state: T, action: { payload: T }): T => {
2
+ return action.payload;
3
+ };
4
+
5
+ export const objReplaceReducer = <T>(state: T, action: { payload: T }): T => {
6
+ return action.payload;
7
+ };
8
+
9
+ export const objDeleteReducer = (): null => {
10
+ return null;
11
+ };
package/src/types.ts ADDED
@@ -0,0 +1,55 @@
1
+ export type Id = number | string;
2
+
3
+ export interface ModelObj {
4
+ [key: string]: any;
5
+ }
6
+
7
+ export type SortFn = (a: ModelObj, b: ModelObj) => number;
8
+
9
+ export interface ListAction {
10
+ payload: ModelObj | ModelObj[];
11
+ sort?: SortFn;
12
+ keyName: string;
13
+ mapKeyName?: string;
14
+ isMapList: boolean;
15
+ }
16
+
17
+ export interface MapAction {
18
+ payload: ModelObj | ModelObj[];
19
+ sort?: SortFn;
20
+ keyName: string;
21
+ }
22
+
23
+ export interface ListSortAction {
24
+ sort?: SortFn;
25
+ keyName: string;
26
+ mapKeyName?: string;
27
+ isMapList: boolean;
28
+ }
29
+
30
+ export interface MapListAction {
31
+ payload: ModelObj | ModelObj[];
32
+ sort?: SortFn;
33
+ keyName: string;
34
+ mapKeyName: string;
35
+ }
36
+
37
+ export interface MapListSortAction {
38
+ sort?: SortFn;
39
+ keyName: string;
40
+ mapKeyName: string;
41
+ }
42
+
43
+ export interface ReducerOpts {
44
+ stateType?: "list" | "map" | "static" | "mapList";
45
+ sort?: SortFn;
46
+ keyName?: string;
47
+ mapKeyName?: string;
48
+ initialState?: any;
49
+ }
50
+
51
+ export interface ReducerAction {
52
+ type: string;
53
+ payload?: any;
54
+ sort?: SortFn;
55
+ }