@feardread/feature-factory 5.2.3 → 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.
@@ -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;
@@ -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) => UtilsFactor.createThunks(entity, 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/index.js CHANGED
@@ -8,4 +8,6 @@ export { default as StateFactory } from "./factory/state.js";
8
8
 
9
9
  export { default as CacheFactory } from "./factory/cache.js";
10
10
 
11
+ export { default as CrudFactory } from "./factory/crud.js";
12
+
11
13
  export { default as API } from "./factory/api.js";