@feardread/feature-factory 5.0.8 → 5.1.1

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.
@@ -1,25 +1,25 @@
1
1
  import { createSlice, createEntityAdapter, combineReducers } from '@reduxjs/toolkit';
2
2
  import ThunkFactory from './thunk';
3
3
  import StateFactory from './state';
4
+ import UtilsFactory from './utils';
4
5
 
5
6
  /**
6
7
  * Creates a Redux feature with standardized structure including slice, async actions, and reducers
7
8
  * @param {string} entity - The entity name for the feature
8
- * @param {Object} reducers - Custom reducers to include in the slice
9
+ * @param {Object} customReducers - Custom reducers to include in the slice
9
10
  * @param {Object|null} endpoints - API endpoints (currently unused)
10
11
  * @returns {Object} Feature factory instance with methods to create slices and manage reducers
11
12
  */
12
- export function FeatureFactory(entity, reducers = {}, endpoints = null) {
13
+ export function FeatureFactory(entity, customReducers = {}, endpoints = null) {
13
14
  if (!entity || typeof entity !== 'string') {
14
15
  throw new Error('Entity name must be provided as a string');
15
16
  }
16
17
 
17
18
  const adapter = createEntityAdapter();
19
+ const commonReducers = UtilsFactory.createReducers(entity, adapter);
18
20
 
19
21
  /**
20
22
  * Creates a dynamic reducer manager for adding/removing reducers at runtime
21
- * @param {Object} initialReducers - Initial set of reducers
22
- * @returns {Object} Reducer manager with methods to manipulate reducers
23
23
  */
24
24
  const createReducerManager = (initialReducers = {}) => {
25
25
  const reducersMap = { ...initialReducers };
@@ -27,7 +27,7 @@ export function FeatureFactory(entity, reducers = {}, endpoints = null) {
27
27
 
28
28
  return {
29
29
  reduce: (state, action) => combinedReducer(state, action),
30
-
30
+
31
31
  add: (key, reducer) => {
32
32
  if (!key || typeof key !== 'string') {
33
33
  console.warn('Invalid reducer key provided');
@@ -37,36 +37,33 @@ export function FeatureFactory(entity, reducers = {}, endpoints = null) {
37
37
  console.warn(`Reducer with key '${key}' already exists`);
38
38
  return;
39
39
  }
40
-
40
+
41
41
  reducersMap[key] = reducer;
42
42
  combinedReducer = combineReducers(reducersMap);
43
43
  },
44
-
44
+
45
45
  remove: (key) => {
46
46
  if (!key || !reducersMap[key]) {
47
47
  console.warn(`Reducer with key '${key}' does not exist`);
48
48
  return;
49
49
  }
50
-
50
+
51
51
  delete reducersMap[key];
52
52
  combinedReducer = combineReducers(reducersMap);
53
53
  },
54
-
54
+
55
55
  getReducerMap: () => ({ ...reducersMap }),
56
56
  };
57
57
  };
58
58
 
59
59
  /**
60
60
  * Merges properties from source object into destination object
61
- * @param {Object} source - Source object
62
- * @param {Object} destination - Destination object
63
- * @returns {Object} Merged destination object
64
61
  */
65
62
  const mergeObjects = (source, destination) => {
66
63
  if (!source || !destination) {
67
64
  throw new Error('Both source and destination objects must be provided');
68
65
  }
69
-
66
+
70
67
  return Object.keys(source).reduce((acc, key) => {
71
68
  if (Object.prototype.hasOwnProperty.call(source, key)) {
72
69
  acc[key] = source[key];
@@ -75,73 +72,57 @@ export function FeatureFactory(entity, reducers = {}, endpoints = null) {
75
72
  }, { ...destination });
76
73
  };
77
74
 
78
- /**
79
- * Creates standard async thunks for common operations
80
- * @param {string} sliceName - Name of the slice
81
- * @returns {Object} Standard async thunks
82
- */
83
- const createStandardThunks = (sliceName) => ({
84
- fetch: ThunkFactory.create(sliceName, 'all'),
85
- fetchOne: ThunkFactory.create(sliceName, 'one'),
86
- search: ThunkFactory.post(sliceName, 'search'),
87
- });
88
-
89
- /**
90
- * Adds standard async action handlers to builder
91
- * @param {Object} builder - RTK builder object
92
- * @param {Object} asyncActions - Async actions to handle
93
- * @param {string} sliceName - Name of the slice
94
- */
95
- const addAsyncActionHandlers = (builder, asyncActions, sliceName) => {
96
- Object.entries(asyncActions).forEach(([key, action]) => {
97
- builder
98
- .addCase(action.pending, (state) => {
99
- state.loading = true;
100
- state.error = null;
101
- })
102
- .addCase(action.fulfilled, (state, actionPayload) => {
103
- state.loading = false;
104
- state.success = true;
105
- state.data = actionPayload.payload;
106
-
107
- // Handle single item for fetchOne operation
108
- if (key === 'fetchOne' && Array.isArray(actionPayload.payload)) {
109
- state[sliceName] = actionPayload.payload[0];
110
- } else if (Array.isArray(actionPayload.payload) && actionPayload.payload.length > 0) {
111
- state[sliceName] = actionPayload.payload[0];
112
- }
113
- })
114
- .addCase(action.rejected, (state, actionPayload) => {
115
- state.loading = false;
116
- state.success = false;
117
- state.error = actionPayload.error || actionPayload.payload;
118
- });
119
- });
120
- };
121
-
122
75
  /**
123
76
  * Creates a Redux slice with standard and custom async actions
124
77
  * @param {Object} options - Configuration options
125
78
  * @param {Object} options.service - Custom service actions
126
79
  * @param {Object} options.initialState - Custom initial state
80
+ * @param {Object} options.stateOptions - State factory configuration
81
+ * @param {Object} options.operations - Which standard operations to include
82
+ * @param {boolean} options.includeCommonReducers - Whether to include common reducers
83
+ * @param {Array} options.excludeReducers - List of common reducers to exclude
127
84
  * @returns {Object} Created slice and async actions
128
85
  */
129
86
  const createFactorySlice = (options = {}) => {
130
- const { service = null, initialState = null } = options;
87
+ const {
88
+ service = null,
89
+ initialState = null,
90
+ stateOptions = {},
91
+ operations = {},
92
+ includeCommonReducers = true,
93
+ excludeReducers = [],
94
+ } = options;
95
+
131
96
  const sliceName = entity;
132
-
133
- // Create standard thunks
134
- const standardThunks = createStandardThunks(sliceName);
135
-
97
+
98
+ // Create standard thunks based on operations config
99
+ const standardThunks = UtilsFactory.createThunks(sliceName, operations);
100
+
101
+ // Filter common reducers based on exclude list
102
+ const filteredCommonReducers = includeCommonReducers
103
+ ? Object.entries(commonReducers).reduce((acc, [key, reducer]) => {
104
+ if (!excludeReducers.includes(key)) {
105
+ acc[key] = reducer;
106
+ }
107
+ return acc;
108
+ }, {})
109
+ : {};
110
+
111
+ // Merge all reducers (common + custom)
112
+ const allReducers = {
113
+ ...filteredCommonReducers,
114
+ ...customReducers,
115
+ };
116
+
136
117
  // Create the slice
137
118
  const factorySlice = createSlice({
138
119
  name: sliceName,
139
- initialState: initialState || StateFactory(sliceName),
140
- reducers,
120
+ initialState: initialState || StateFactory(sliceName, stateOptions),
121
+ reducers: allReducers,
141
122
  extraReducers: (builder) => {
142
123
  // Add standard async action handlers
143
- addAsyncActionHandlers(builder, standardThunks, sliceName);
144
-
124
+ UtilsFactory.addHandlers(builder, standardThunks, sliceName);
125
+
145
126
  // Add custom service action handlers
146
127
  if (service && typeof service === 'object') {
147
128
  const customActions = Object.entries(service).reduce((acc, [key, action]) => {
@@ -151,35 +132,100 @@ export function FeatureFactory(entity, reducers = {}, endpoints = null) {
151
132
  }
152
133
  return acc;
153
134
  }, {});
154
-
155
- addAsyncActionHandlers(builder, customActions, sliceName);
135
+
136
+ UtilsFactory.addHandlers(builder, customActions, sliceName);
156
137
  }
157
138
  },
158
139
  });
159
140
 
160
141
  // Merge standard and custom async actions
161
- const asyncActions = service
142
+ const asyncActions = service
162
143
  ? mergeObjects(standardThunks, service)
163
144
  : standardThunks;
164
145
 
165
146
  return {
166
147
  slice: factorySlice,
167
148
  asyncActions,
149
+ reducers: allReducers,
168
150
  };
169
151
  };
170
152
 
153
+ /**
154
+ * Creates a complete CRUD feature with all operations
155
+ */
156
+ const createCrudFeature = (options = {}) => {
157
+ return createFactorySlice({
158
+ ...options,
159
+ operations: {
160
+ fetch: true,
161
+ fetchOne: true,
162
+ search: true,
163
+ create: true,
164
+ update: true,
165
+ patch: true,
166
+ delete: true,
167
+ },
168
+ });
169
+ };
170
+
171
+ /**
172
+ * Creates a read-only feature (fetch, fetchOne, search only)
173
+ */
174
+ const createBasicFeature = (options = {}) => {
175
+ return createFactorySlice({
176
+ ...options,
177
+ operations: {
178
+ fetch: true,
179
+ fetchOne: true,
180
+ search: true,
181
+ create: false,
182
+ update: false,
183
+ patch: false,
184
+ delete: false,
185
+ },
186
+ });
187
+ };
188
+
171
189
  // Public API
172
190
  return {
173
191
  entity,
174
- reducers,
175
192
  adapter,
193
+ reducers: commonReducers,
176
194
  manager: createReducerManager,
177
- inject: mergeObjects,
195
+
196
+ // Utilities
197
+ inject: mergeObjects,
198
+ createReducer: (handler) => handler,
199
+ createHandler: (builder, action, handlers) => {
200
+ if (handlers.pending) {
201
+ builder.addCase(action.pending, handlers.pending);
202
+ }
203
+ if (handlers.fulfilled) {
204
+ builder.addCase(action.fulfilled, handlers.fulfilled);
205
+ }
206
+ if (handlers.rejected) {
207
+ builder.addCase(action.rejected, handlers.rejected);
208
+ }
209
+ },
210
+ // Creators
178
211
  create: createFactorySlice,
179
- // Utility methods
212
+ createCrud: createCrudFeature,
213
+ createBasic: createBasicFeature,
214
+ createThunks: (operations) => UtilsFactor.createThunks(entity, operations),
215
+ createCustomThunk: (prefix, options) => ThunkFactory.custom(entity, prefix, options),
216
+
217
+ // Getters
180
218
  getEntityName: () => entity,
181
219
  getAdapter: () => adapter,
182
- };
220
+ getCommonReducers: (exclude = []) => {
221
+ return Object.entries(commonReducers).reduce((acc, [key, reducer]) => {
222
+ if (!exclude.includes(key)) {
223
+ acc[key] = reducer;
224
+ }
225
+ return acc;
226
+ }, {});
227
+ },
228
+ }
183
229
  }
184
230
 
185
231
  export default FeatureFactory;
@@ -17,7 +17,7 @@ const HTTP_METHODS = {
17
17
  */
18
18
  const STANDARD_OPERATIONS = {
19
19
  all: { method: HTTP_METHODS.GET, useParams: false },
20
- one: { method: HTTP_METHODS.GET, useParams: false, useIdInUrl: true },
20
+ one: { method: HTTP_METHODS.GET, useParams: true, useIdInUrl: true },
21
21
  search: { method: HTTP_METHODS.POST, useParams: true },
22
22
  new: { method: HTTP_METHODS.POST, useParams: false },
23
23
  create: { method: HTTP_METHODS.POST, useParams: false },
@@ -52,9 +52,9 @@ const validateThunkParams = (entity, prefix) => {
52
52
  const buildUrl = (entity, prefix, params = {}, useIdInUrl = false) => {
53
53
  let url = `${entity}`;
54
54
 
55
- if (useIdInUrl && params?.id) {
55
+ if (params?.id || useIdInUrl) {
56
56
  url += `/${params.id}`;
57
- } else if (!useIdInUrl || prefix !== 'one') {
57
+ } else {
58
58
  url += `/${prefix}`;
59
59
  }
60
60
 
@@ -0,0 +1,355 @@
1
+ import { ThunkFactory } from "./thunk";
2
+ import { StateFactory } from "./state";
3
+
4
+ /**
5
+ * Common reducer actions that can be dynamically added to any slice
6
+ */
7
+ export const createReducers = (entity, adapter) => ({
8
+ // Entity management
9
+ setEntity: (state, action) => {
10
+ state[entity] = action.payload;
11
+ state.error = null;
12
+ },
13
+
14
+ clearEntity: (state) => {
15
+ state[entity] = null;
16
+ state.data = [];
17
+ },
18
+
19
+ // Data management
20
+ setData: (state, action) => {
21
+ state.data = action.payload;
22
+ state.error = null;
23
+ },
24
+
25
+ appendData: (state, action) => {
26
+ state.data = [...state.data, ...action.payload];
27
+ },
28
+
29
+ prependData: (state, action) => {
30
+ state.data = [...action.payload, ...state.data];
31
+ },
32
+
33
+ updateDataItem: (state, action) => {
34
+ const { id, updates } = action.payload;
35
+ const index = state.data.findIndex(item => item.id === id);
36
+ if (index !== -1) {
37
+ state.data[index] = { ...state.data[index], ...updates };
38
+ }
39
+ },
40
+
41
+ removeDataItem: (state, action) => {
42
+ const id = action.payload;
43
+ state.data = state.data.filter(item => item.id !== id);
44
+ },
45
+
46
+ // Loading state management
47
+ setLoading: (state, action) => {
48
+ state.loading = action.payload;
49
+ },
50
+
51
+ setSuccess: (state, action) => {
52
+ state.success = action.payload;
53
+ },
54
+
55
+ setError: (state, action) => {
56
+ state.error = action.payload;
57
+ state.loading = false;
58
+ state.success = false;
59
+ },
60
+
61
+ clearError: (state) => {
62
+ state.error = null;
63
+ },
64
+
65
+ // Reset operations
66
+ resetState: (state, action) => {
67
+ const namespace = action.payload?.namespace || entity;
68
+ const options = action.payload?.options || {};
69
+ return StateFactory(namespace, options);
70
+ },
71
+
72
+ resetData: (state) => {
73
+ state.data = [];
74
+ state[entity] = null;
75
+ state.error = null;
76
+ },
77
+
78
+ resetStatus: (state) => {
79
+ state.loading = false;
80
+ state.success = false;
81
+ state.error = null;
82
+ },
83
+
84
+ // Pagination reducers (if enabled)
85
+ setPagination: (state, action) => {
86
+ if (state.pagination) {
87
+ state.pagination = { ...state.pagination, ...action.payload };
88
+ }
89
+ },
90
+
91
+ setCurrentPage: (state, action) => {
92
+ if (state.pagination) {
93
+ state.pagination.currentPage = action.payload;
94
+ }
95
+ },
96
+
97
+ setPageSize: (state, action) => {
98
+ if (state.pagination) {
99
+ state.pagination.pageSize = action.payload;
100
+ state.pagination.currentPage = 1; // Reset to first page
101
+ }
102
+ },
103
+
104
+ // Filtering reducers (if enabled)
105
+ setFilters: (state, action) => {
106
+ if (state.filtering) {
107
+ state.filtering.filters = action.payload;
108
+ state.filtering.activeFilters = Object.keys(action.payload).filter(
109
+ key => action.payload[key] !== null && action.payload[key] !== undefined
110
+ );
111
+ }
112
+ },
113
+
114
+ clearFilters: (state) => {
115
+ if (state.filtering) {
116
+ state.filtering.filters = {};
117
+ state.filtering.activeFilters = [];
118
+ state.filtering.searchTerm = '';
119
+ }
120
+ },
121
+
122
+ setSearchTerm: (state, action) => {
123
+ if (state.filtering) {
124
+ state.filtering.searchTerm = action.payload;
125
+ }
126
+ },
127
+
128
+ // Sorting reducers (if enabled)
129
+ setSorting: (state, action) => {
130
+ if (state.sorting) {
131
+ state.sorting.sortBy = action.payload.sortBy;
132
+ state.sorting.sortOrder = action.payload.sortOrder || 'asc';
133
+ }
134
+ },
135
+
136
+ toggleSortOrder: (state) => {
137
+ if (state.sorting) {
138
+ state.sorting.sortOrder = state.sorting.sortOrder === 'asc' ? 'desc' : 'asc';
139
+ }
140
+ },
141
+
142
+ // Selection reducers (if enabled)
143
+ setSelectedItems: (state, action) => {
144
+ if (state.selection) {
145
+ state.selection.selectedItems = action.payload;
146
+ state.selection.selectedIds = action.payload.map(item => item.id);
147
+ }
148
+ },
149
+
150
+ toggleItemSelection: (state, action) => {
151
+ if (state.selection) {
152
+ const id = action.payload;
153
+ const index = state.selection.selectedIds.indexOf(id);
154
+
155
+ if (index > -1) {
156
+ state.selection.selectedIds.splice(index, 1);
157
+ state.selection.selectedItems = state.selection.selectedItems.filter(
158
+ item => item.id !== id
159
+ );
160
+ } else {
161
+ const item = state.data.find(item => item.id === id);
162
+ if (item) {
163
+ state.selection.selectedIds.push(id);
164
+ state.selection.selectedItems.push(item);
165
+ }
166
+ }
167
+
168
+ state.selection.allSelected =
169
+ state.selection.selectedIds.length === state.data.length && state.data.length > 0;
170
+ }
171
+ },
172
+
173
+ selectAll: (state) => {
174
+ if (state.selection) {
175
+ state.selection.selectedItems = [...state.data];
176
+ state.selection.selectedIds = state.data.map(item => item.id);
177
+ state.selection.allSelected = true;
178
+ }
179
+ },
180
+
181
+ clearSelection: (state) => {
182
+ if (state.selection) {
183
+ state.selection.selectedItems = [];
184
+ state.selection.selectedIds = [];
185
+ state.selection.allSelected = false;
186
+ }
187
+ },
188
+
189
+ // Metadata reducers (if enabled)
190
+ updateMetadata: (state, action) => {
191
+ if (state.metadata) {
192
+ state.metadata = { ...state.metadata, ...action.payload };
193
+ }
194
+ },
195
+
196
+ markAsStale: (state) => {
197
+ if (state.metadata) {
198
+ state.metadata.stale = true;
199
+ }
200
+ },
201
+
202
+ markAsFresh: (state) => {
203
+ if (state.metadata) {
204
+ state.metadata.stale = false;
205
+ state.metadata.lastFetch = new Date().toISOString();
206
+ }
207
+ },
208
+ });
209
+
210
+ /**
211
+ * Creates standard async thunks with configurable operations
212
+ */
213
+ export const createThunks = (entity, operations = {}) => {
214
+ const defaultOperations = {
215
+ fetch: true,
216
+ fetchOne: true,
217
+ search: true,
218
+ create: true,
219
+ update: true,
220
+ patch: true,
221
+ delete: true,
222
+ };
223
+
224
+ const config = { ...defaultOperations, ...operations };
225
+ const thunks = {};
226
+
227
+ if (config.fetch) {
228
+ thunks.fetch = ThunkFactory.create(entity, 'all');
229
+ }
230
+
231
+ if (config.fetchOne) {
232
+ thunks.fetchOne = ThunkFactory.create(entity, 'one');
233
+ }
234
+
235
+ if (config.search) {
236
+ thunks.search = ThunkFactory.post(entity, 'search');
237
+ }
238
+
239
+ if (config.create) {
240
+ thunks.create = ThunkFactory.post(entity, 'create');
241
+ }
242
+
243
+ if (config.update) {
244
+ thunks.update = ThunkFactory.put(entity, 'update');
245
+ }
246
+
247
+ if (config.patch) {
248
+ thunks.patch = ThunkFactory.patch(entity, 'patch');
249
+ }
250
+
251
+ if (config.delete) {
252
+ thunks.delete = ThunkFactory.delete(entity, 'delete');
253
+ }
254
+
255
+ return thunks;
256
+ };
257
+
258
+ /**
259
+ * Adds standard async action handlers to builder with enhanced logic
260
+ */
261
+ export const addHandlers = (builder, asyncActions, entity) => {
262
+ Object.entries(asyncActions).forEach(([key, action]) => {
263
+ builder
264
+ .addCase(action.pending, (state) => {
265
+ state.loading = true;
266
+ state.error = null;
267
+ state.loadingState = 'pending';
268
+
269
+ if (state.async?.operations) {
270
+ state.async.operations[key] = {
271
+ loading: true,
272
+ success: false,
273
+ error: null,
274
+ lastRun: new Date().toISOString(),
275
+ };
276
+ }
277
+ })
278
+ .addCase(action.fulfilled, (state, actionPayload) => {
279
+ state.loading = false;
280
+ state.success = true;
281
+ state.loadingState = 'fulfilled';
282
+
283
+ // Handle different response structures
284
+ const payload = actionPayload.payload;
285
+ console.log('payload = ', payload);
286
+ // Update data based on operation type
287
+ if (key === 'fetch' || key === 'search') {
288
+ state.data = Array.isArray(payload) ? payload : (payload?.data.result || []);
289
+ } else if (key === 'fetchOne') {
290
+ state[entity] = Array.isArray(payload) ? payload[0] : payload;
291
+ } else if (key === 'create') {
292
+ if (Array.isArray(state.data)) {
293
+ state.data.push(payload);
294
+ }
295
+ } else if (key === 'update' || key === 'patch') {
296
+ if (Array.isArray(state.data)) {
297
+ const index = state.data.findIndex(item => item.id === payload.id);
298
+ if (index !== -1) {
299
+ state.data[index] = payload;
300
+ }
301
+ }
302
+ if (state[entity]?.id === payload.id) {
303
+ state[entity] = payload;
304
+ }
305
+ } else if (key === 'delete') {
306
+ if (Array.isArray(state.data)) {
307
+ state.data = state.data.filter(item => item.id !== actionPayload.meta.arg?.id);
308
+ }
309
+ }
310
+
311
+ // Update metadata
312
+ if (state.metadata) {
313
+ state.metadata.lastUpdate = new Date().toISOString();
314
+ state.metadata.stale = false;
315
+ if (key === 'fetch' || key === 'fetchOne' || key === 'search') {
316
+ state.metadata.lastFetch = new Date().toISOString();
317
+ }
318
+ }
319
+
320
+ // Update async operations tracking
321
+ if (state.async?.operations) {
322
+ state.async.operations[key] = {
323
+ loading: false,
324
+ success: true,
325
+ error: null,
326
+ lastRun: new Date().toISOString(),
327
+ };
328
+ }
329
+ })
330
+ .addCase(action.rejected, (state, actionPayload) => {
331
+ state.loading = false;
332
+ state.success = false;
333
+ state.error = actionPayload.error?.message || actionPayload.payload || 'An error occurred';
334
+ state.loadingState = 'rejected';
335
+
336
+ if (state.async?.operations) {
337
+ state.async.operations[key] = {
338
+ loading: false,
339
+ success: false,
340
+ error: state.error,
341
+ lastRun: new Date().toISOString(),
342
+ };
343
+ }
344
+ });
345
+ });
346
+ };
347
+
348
+
349
+ export const UtilsFactory = {
350
+ createReducers,
351
+ createThunks,
352
+ addHandlers
353
+ }
354
+
355
+ export default UtilsFactory;