@feardread/feature-factory 5.1.2 → 6.0.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.
- package/dist/bundle.min.js +2 -2
- package/dist/index.esm.js +3 -3
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/rollup.config.mjs +48 -35
- package/src/factory/api.js +44 -325
- package/src/factory/crud.js +177 -0
- package/src/factory/index.js +1 -1
- package/src/factory/state.js +5 -1
- package/src/factory/thunk.js +1 -0
- package/src/factory/utils.js +4 -6
- package/src/index.js +2 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Message } from "rsuite";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CrudFactory - Generic CRUD Handler using Promise then/catch pattern
|
|
5
|
+
*
|
|
6
|
+
* A reusable factory for creating CRUD handlers across different entities.
|
|
7
|
+
* Designed to work with FeatureFactory slices (index.js) and the API layer (api.js).
|
|
8
|
+
* Uses promise chaining instead of async/await for better error propagation control.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} entity - Display name of the entity (e.g. "User", "Product")
|
|
11
|
+
* @param {Object} actions - Async thunk actions from FeatureFactory (create, update, delete, fetchAll)
|
|
12
|
+
* Optionally includes a `setLoading` synchronous slice action
|
|
13
|
+
* @param {Object} selectors - Redux selectors for the entity slice (reserved for future use)
|
|
14
|
+
* @param {Object} toaster - RSuite toaster instance for push notifications
|
|
15
|
+
* @param {Function} dispatch - Redux dispatch function
|
|
16
|
+
* @returns {Function} - CRUD handler: (method, { formValue, selectedItem, callbacks }) => Promise
|
|
17
|
+
*/
|
|
18
|
+
export const CrudFactory = ({
|
|
19
|
+
entity,
|
|
20
|
+
actions,
|
|
21
|
+
selectors,
|
|
22
|
+
toaster,
|
|
23
|
+
dispatch,
|
|
24
|
+
}) => {
|
|
25
|
+
// Validation — fail fast if required dependencies are missing
|
|
26
|
+
if (!entity || typeof entity !== "string") {
|
|
27
|
+
throw new Error("CrudFactory: `entity` must be a non-empty string");
|
|
28
|
+
}
|
|
29
|
+
if (!actions || typeof actions !== "object") {
|
|
30
|
+
throw new Error("CrudFactory: `actions` must be an object of thunk action creators");
|
|
31
|
+
}
|
|
32
|
+
if (typeof dispatch !== "function") {
|
|
33
|
+
throw new Error("CrudFactory: `dispatch` must be a Redux dispatch function");
|
|
34
|
+
}
|
|
35
|
+
if (!toaster || typeof toaster.push !== "function") {
|
|
36
|
+
throw new Error("CrudFactory: `toaster` must be a valid RSuite toaster instance");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Internal helper — dispatches setLoading if the slice exposes it.
|
|
41
|
+
* Falls back silently when the slice manages loading via extraReducers only.
|
|
42
|
+
*/
|
|
43
|
+
const setLoading = (isLoading) => {
|
|
44
|
+
if (typeof actions.setLoading === "function") {
|
|
45
|
+
dispatch(actions.setLoading(isLoading));
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Internal helper — pushes a standardised RSuite toast message.
|
|
51
|
+
*/
|
|
52
|
+
const pushToast = (type, message) => {
|
|
53
|
+
toaster.push(
|
|
54
|
+
`<Message showIcon type={type} closable>
|
|
55
|
+
<strong>${type === "success" ? "Success!" : type === "warning" ? "Warning!" : "Error!"}</strong>${" "}
|
|
56
|
+
${message}
|
|
57
|
+
</Message>`,
|
|
58
|
+
{
|
|
59
|
+
placement: type === "success" ? "topCenter" : "topEnd",
|
|
60
|
+
duration: 3000,
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returned CRUD handler
|
|
67
|
+
*
|
|
68
|
+
* @param {"CREATE"|"UPDATE"|"DELETE"} method
|
|
69
|
+
* @param {Object} options
|
|
70
|
+
* @param {Object} options.formValue - Form data for CREATE / UPDATE
|
|
71
|
+
* @param {Object} options.selectedItem - Currently selected entity (requires ._id)
|
|
72
|
+
* @param {Object} [options.callbacks] - Optional lifecycle hooks: onSuccess, onError, onFinally
|
|
73
|
+
* @returns {Promise}
|
|
74
|
+
*/
|
|
75
|
+
return (method, { formValue, selectedItem, callbacks = {} }) => {
|
|
76
|
+
const operationMap = {
|
|
77
|
+
CREATE: {
|
|
78
|
+
action: actions.create,
|
|
79
|
+
message: `${entity} created successfully`,
|
|
80
|
+
requiresSelection: false,
|
|
81
|
+
},
|
|
82
|
+
UPDATE: {
|
|
83
|
+
action: actions.update,
|
|
84
|
+
message: `${entity} updated successfully`,
|
|
85
|
+
requiresSelection: true,
|
|
86
|
+
},
|
|
87
|
+
DELETE: {
|
|
88
|
+
action: actions.delete,
|
|
89
|
+
message: `${entity} deleted successfully`,
|
|
90
|
+
requiresSelection: true,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const operation = operationMap[method];
|
|
95
|
+
|
|
96
|
+
// Guard — unknown method
|
|
97
|
+
if (!operation) {
|
|
98
|
+
const error = new Error(`CrudFactory: Invalid operation "${method}". Expected CREATE, UPDATE, or DELETE.`);
|
|
99
|
+
return Promise.reject(error);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Guard — action creator missing from the slice
|
|
103
|
+
if (typeof operation.action !== "function") {
|
|
104
|
+
const error = new Error(
|
|
105
|
+
`CrudFactory: No action creator found for "${method}" on entity "${entity}". ` +
|
|
106
|
+
`Ensure FeatureFactory was initialised with the corresponding operation enabled.`
|
|
107
|
+
);
|
|
108
|
+
return Promise.reject(error);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Guard — selection required but absent
|
|
112
|
+
if (operation.requiresSelection && !selectedItem?._id) {
|
|
113
|
+
const error = new Error(`No ${entity.toLowerCase()} selected`);
|
|
114
|
+
pushToast("warning", error.message);
|
|
115
|
+
return Promise.reject(error);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Signal loading start (slice may also handle this via extraReducers pending)
|
|
119
|
+
setLoading(true);
|
|
120
|
+
|
|
121
|
+
// Build payload:
|
|
122
|
+
// DELETE → bare _id string
|
|
123
|
+
// CREATE → formValue only
|
|
124
|
+
// UPDATE → formValue merged with the existing _id
|
|
125
|
+
const payload =
|
|
126
|
+
method === "DELETE"
|
|
127
|
+
? selectedItem._id
|
|
128
|
+
: { ...formValue, ...(selectedItem?._id && { id: selectedItem._id }) };
|
|
129
|
+
|
|
130
|
+
// Execute operation with promise chain
|
|
131
|
+
return dispatch(operation.action(payload))
|
|
132
|
+
.unwrap()
|
|
133
|
+
.then((result) => {
|
|
134
|
+
pushToast("success", operation.message);
|
|
135
|
+
|
|
136
|
+
if (typeof callbacks.onSuccess === "function") {
|
|
137
|
+
callbacks.onSuccess(result);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Refresh list after mutation — swallows refresh failures to keep UX stable
|
|
141
|
+
if (typeof actions.fetchAll === "function") {
|
|
142
|
+
return dispatch(actions.fetchAll())
|
|
143
|
+
.unwrap()
|
|
144
|
+
.then(() => result)
|
|
145
|
+
.catch((fetchError) => {
|
|
146
|
+
console.warn(`CrudFactory: Failed to refresh ${entity} list after ${method}:`, fetchError);
|
|
147
|
+
return result;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return result;
|
|
152
|
+
})
|
|
153
|
+
.catch((error) => {
|
|
154
|
+
const errorMessage =
|
|
155
|
+
error.message || `Failed to ${method.toLowerCase()} ${entity.toLowerCase()}`;
|
|
156
|
+
|
|
157
|
+
pushToast("error", errorMessage);
|
|
158
|
+
|
|
159
|
+
if (typeof callbacks.onError === "function") {
|
|
160
|
+
callbacks.onError(error);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Re-throw so callers can chain their own .catch()
|
|
164
|
+
throw error;
|
|
165
|
+
})
|
|
166
|
+
.finally(() => {
|
|
167
|
+
// Signal loading end regardless of outcome
|
|
168
|
+
setLoading(false);
|
|
169
|
+
|
|
170
|
+
if (typeof callbacks.onFinally === "function") {
|
|
171
|
+
callbacks.onFinally();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export default CrudFactory;
|
package/src/factory/index.js
CHANGED
|
@@ -211,7 +211,7 @@ export function FeatureFactory(entity, customReducers = {}, endpoints = null) {
|
|
|
211
211
|
create: createFactorySlice,
|
|
212
212
|
createCrud: createCrudFeature,
|
|
213
213
|
createBasic: createBasicFeature,
|
|
214
|
-
createThunks: (operations) =>
|
|
214
|
+
createThunks: (operations) => UtilsFactory.createThunks(entity, operations),
|
|
215
215
|
createCustomThunk: (prefix, options) => ThunkFactory.custom(entity, prefix, options),
|
|
216
216
|
|
|
217
217
|
// Getters
|
package/src/factory/state.js
CHANGED
|
@@ -10,6 +10,7 @@ const DEFAULT_OPTIONS = {
|
|
|
10
10
|
includeValidation: false,
|
|
11
11
|
includeMetadata: true,
|
|
12
12
|
customFields: {},
|
|
13
|
+
DEBUG: true,
|
|
13
14
|
};
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -219,7 +220,10 @@ export const StateFactory = (namespace, options = {}) => {
|
|
|
219
220
|
...optionalStates,
|
|
220
221
|
...config.customFields,
|
|
221
222
|
};
|
|
222
|
-
|
|
223
|
+
|
|
224
|
+
if (config.DEBUG) {
|
|
225
|
+
console.log('Initial State: ', initialState);
|
|
226
|
+
}
|
|
223
227
|
return initialState;
|
|
224
228
|
};
|
|
225
229
|
|
package/src/factory/thunk.js
CHANGED
|
@@ -258,6 +258,7 @@ export const ThunkFactory = {
|
|
|
258
258
|
fetch: ThunkFactory.create(entity, 'all'),
|
|
259
259
|
read: ThunkFactory.create(entity, 'one'),
|
|
260
260
|
create: ThunkFactory.post(entity, 'create'),
|
|
261
|
+
new: ThunkFactory.post(entity, 'new'),
|
|
261
262
|
update: ThunkFactory.put(entity, 'update'),
|
|
262
263
|
patch: ThunkFactory.patch(entity, 'patch'),
|
|
263
264
|
delete: ThunkFactory.delete(entity, 'delete'),
|
package/src/factory/utils.js
CHANGED
|
@@ -252,6 +252,10 @@ export const createThunks = (entity, operations = {}) => {
|
|
|
252
252
|
thunks.delete = ThunkFactory.delete(entity, 'delete');
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
if (config.new) {
|
|
256
|
+
thunks.new = ThunkFactory.post(entity, 'new');
|
|
257
|
+
}
|
|
258
|
+
|
|
255
259
|
return thunks;
|
|
256
260
|
};
|
|
257
261
|
|
|
@@ -274,17 +278,13 @@ export const addHandlers = (builder, asyncActions, entity) => {
|
|
|
274
278
|
lastRun: new Date().toISOString(),
|
|
275
279
|
};
|
|
276
280
|
}
|
|
277
|
-
console.log('pending state = ', state);
|
|
278
281
|
})
|
|
279
282
|
.addCase(action.fulfilled, (state, actionPayload) => {
|
|
280
283
|
state.loading = false;
|
|
281
284
|
state.success = true;
|
|
282
285
|
state.loadingState = 'fulfilled';
|
|
283
|
-
|
|
284
286
|
// Handle different response structures
|
|
285
287
|
const payload = actionPayload.payload;
|
|
286
|
-
console.log('fulfilled state = ', state);
|
|
287
|
-
console.log('loading state = false (success)', payload);
|
|
288
288
|
// Update data based on operation type
|
|
289
289
|
if (key === 'fetch' || key === 'search') {
|
|
290
290
|
state.data = Array.isArray(payload) ? payload : (payload?.data || []);
|
|
@@ -328,7 +328,6 @@ export const addHandlers = (builder, asyncActions, entity) => {
|
|
|
328
328
|
lastRun: new Date().toISOString(),
|
|
329
329
|
};
|
|
330
330
|
}
|
|
331
|
-
console.log('fully fulfilled state :: ', state);
|
|
332
331
|
})
|
|
333
332
|
.addCase(action.rejected, (state, actionPayload) => {
|
|
334
333
|
state.loading = false;
|
|
@@ -344,7 +343,6 @@ export const addHandlers = (builder, asyncActions, entity) => {
|
|
|
344
343
|
lastRun: new Date().toISOString(),
|
|
345
344
|
};
|
|
346
345
|
}
|
|
347
|
-
console.log('Rejected state = ', state);
|
|
348
346
|
});
|
|
349
347
|
});
|
|
350
348
|
};
|
package/src/index.js
CHANGED