@okyrychenko-dev/react-action-guard 0.5.0 → 0.6.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/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1
+ import { useRef, useCallback, useEffect, useMemo, useState } from 'react';
2
2
  import { createStoreProvider, createShallowStore } from '@okyrychenko-dev/react-zustand-toolkit';
3
3
  export { createShallowStore } from '@okyrychenko-dev/react-zustand-toolkit';
4
4
  import { useStore } from 'zustand';
@@ -23,6 +23,16 @@ function normalizeScopeToArray(scope) {
23
23
  }
24
24
 
25
25
  // src/store/uiBlockingStore.actions.ts
26
+ var normalizePriority = (priority, fallback = DEFAULT_PRIORITY) => {
27
+ return priority !== void 0 ? Math.max(0, priority) : fallback;
28
+ };
29
+ var createMiddlewareContext = (action, blockerId, config, prevState) => ({
30
+ action,
31
+ blockerId,
32
+ config,
33
+ timestamp: Date.now(),
34
+ ...prevState && { prevState }
35
+ });
26
36
  var createUIBlockingActions = (set, get) => ({
27
37
  // State
28
38
  activeBlockers: /* @__PURE__ */ new Map(),
@@ -33,7 +43,6 @@ var createUIBlockingActions = (set, get) => ({
33
43
  newMiddlewares.set(name, middleware);
34
44
  return { middlewares: newMiddlewares };
35
45
  });
36
- console.log(`[UIBlocking] Registered middleware: ${name}`);
37
46
  },
38
47
  unregisterMiddleware: (name) => {
39
48
  set((state) => {
@@ -41,7 +50,6 @@ var createUIBlockingActions = (set, get) => ({
41
50
  newMiddlewares.delete(name);
42
51
  return { middlewares: newMiddlewares };
43
52
  });
44
- console.log(`[UIBlocking] Unregistered middleware: ${name}`);
45
53
  },
46
54
  runMiddlewares: async (context) => {
47
55
  const { middlewares } = get();
@@ -55,11 +63,38 @@ var createUIBlockingActions = (set, get) => ({
55
63
  },
56
64
  // Actions
57
65
  /**
58
- * Add a blocker to the store
59
- *
60
- * @param id - Unique blocker identifier
61
- * @param config - Optional blocker configuration
62
- *
66
+ * Adds a new blocker or overwrites an existing one.
67
+ *
68
+ * If a blocker with the same ID already exists, it will be replaced and any
69
+ * existing timeout will be cleared. The blocker is active immediately after
70
+ * being added. Triggers middleware with 'add' action.
71
+ *
72
+ * @param id - Unique identifier for the blocker. Must be unique within the store.
73
+ * @param config - Blocker configuration. All fields are optional with defaults applied.
74
+ *
75
+ * @example
76
+ * Basic usage
77
+ * ```ts
78
+ * store.getState().addBlocker('save-operation', {
79
+ * scope: 'form',
80
+ * reason: 'Saving data...',
81
+ * priority: 70
82
+ * });
83
+ * ```
84
+ *
85
+ * @example
86
+ * With timeout
87
+ * ```ts
88
+ * store.getState().addBlocker('api-call', {
89
+ * scope: 'global',
90
+ * reason: 'Loading...',
91
+ * timeout: 30000, // Auto-remove after 30 seconds
92
+ * onTimeout: (id) => console.warn(`${id} timed out`)
93
+ * });
94
+ * ```
95
+ *
96
+ * @see {@link removeBlocker} to remove a blocker
97
+ * @see {@link updateBlocker} to update an existing blocker
63
98
  */
64
99
  addBlocker: (id, config = {}) => {
65
100
  const existingBlocker = get().activeBlockers.get(id);
@@ -71,17 +106,14 @@ var createUIBlockingActions = (set, get) => ({
71
106
  timeoutId = setTimeout(() => {
72
107
  if (get().activeBlockers.has(id)) {
73
108
  config.onTimeout?.(id);
74
- void get().runMiddlewares({
75
- action: "timeout",
76
- blockerId: id,
77
- config: {
109
+ void get().runMiddlewares(
110
+ createMiddlewareContext("timeout", id, {
78
111
  scope: config.scope,
79
112
  reason: config.reason,
80
113
  priority: config.priority,
81
114
  timeout: config.timeout
82
- },
83
- timestamp: Date.now()
84
- });
115
+ })
116
+ );
85
117
  get().removeBlocker(id);
86
118
  }
87
119
  }, config.timeout);
@@ -91,7 +123,7 @@ var createUIBlockingActions = (set, get) => ({
91
123
  const storedBlocker = {
92
124
  scope: config.scope ?? DEFAULT_SCOPE,
93
125
  reason: config.reason ?? DEFAULT_REASON,
94
- priority: config.priority ?? DEFAULT_PRIORITY,
126
+ priority: normalizePriority(config.priority),
95
127
  timestamp: config.timestamp ?? Date.now(),
96
128
  timeout: config.timeout,
97
129
  timeoutId,
@@ -100,23 +132,131 @@ var createUIBlockingActions = (set, get) => ({
100
132
  newBlockers.set(id, storedBlocker);
101
133
  return { activeBlockers: newBlockers };
102
134
  });
103
- void get().runMiddlewares({
104
- action: "add",
105
- blockerId: id,
106
- config: {
135
+ void get().runMiddlewares(
136
+ createMiddlewareContext("add", id, {
107
137
  scope: config.scope,
108
138
  reason: config.reason,
109
139
  priority: config.priority,
110
140
  timeout: config.timeout
111
- },
112
- timestamp: Date.now()
141
+ })
142
+ );
143
+ },
144
+ /**
145
+ * Updates an existing blocker or creates it if it doesn't exist.
146
+ *
147
+ * Merges the provided configuration with existing blocker data. If the timeout
148
+ * is changed, the timeout timer is restarted. Triggers middleware with 'update' action.
149
+ *
150
+ * @param id - Identifier of the blocker to update
151
+ * @param config - Partial configuration to merge with existing blocker data
152
+ *
153
+ * @example
154
+ * Update blocker reason
155
+ * ```ts
156
+ * store.getState().updateBlocker('save', {
157
+ * reason: 'Saving additional data...'
158
+ * });
159
+ * ```
160
+ *
161
+ * @example
162
+ * Extend timeout
163
+ * ```ts
164
+ * store.getState().updateBlocker('api-call', {
165
+ * timeout: 60000 // Extend to 60 seconds
166
+ * });
167
+ * ```
168
+ *
169
+ * @see {@link addBlocker} to create a new blocker
170
+ */
171
+ updateBlocker: (id, config = {}) => {
172
+ const existingBlocker = get().activeBlockers.get(id);
173
+ if (!existingBlocker) {
174
+ get().addBlocker(id, config);
175
+ return;
176
+ }
177
+ let timeoutId = existingBlocker.timeoutId;
178
+ const timeoutChanged = config.timeout !== void 0 && config.timeout !== existingBlocker.timeout;
179
+ if (timeoutChanged) {
180
+ if (existingBlocker.timeoutId) {
181
+ clearTimeout(existingBlocker.timeoutId);
182
+ }
183
+ if (config.timeout && config.timeout > 0) {
184
+ const newTimeout = config.timeout;
185
+ const newOnTimeout = config.onTimeout ?? existingBlocker.onTimeout;
186
+ timeoutId = setTimeout(() => {
187
+ if (get().activeBlockers.has(id)) {
188
+ newOnTimeout?.(id);
189
+ void get().runMiddlewares(
190
+ createMiddlewareContext("timeout", id, {
191
+ scope: config.scope ?? existingBlocker.scope,
192
+ reason: config.reason ?? existingBlocker.reason,
193
+ priority: config.priority ?? existingBlocker.priority,
194
+ timeout: newTimeout
195
+ })
196
+ );
197
+ get().removeBlocker(id);
198
+ }
199
+ }, newTimeout);
200
+ } else {
201
+ timeoutId = void 0;
202
+ }
203
+ }
204
+ const updatedBlocker = {
205
+ ...existingBlocker,
206
+ scope: config.scope ?? existingBlocker.scope,
207
+ reason: config.reason ?? existingBlocker.reason,
208
+ priority: normalizePriority(config.priority, existingBlocker.priority),
209
+ timestamp: config.timestamp ?? existingBlocker.timestamp,
210
+ timeout: config.timeout ?? existingBlocker.timeout,
211
+ timeoutId,
212
+ onTimeout: config.onTimeout ?? existingBlocker.onTimeout
213
+ };
214
+ set((state) => {
215
+ const newBlockers = new Map(state.activeBlockers);
216
+ newBlockers.set(id, updatedBlocker);
217
+ return { activeBlockers: newBlockers };
113
218
  });
219
+ void get().runMiddlewares(
220
+ createMiddlewareContext(
221
+ "update",
222
+ id,
223
+ {
224
+ scope: updatedBlocker.scope,
225
+ reason: updatedBlocker.reason,
226
+ priority: updatedBlocker.priority,
227
+ timeout: updatedBlocker.timeout,
228
+ onTimeout: updatedBlocker.onTimeout,
229
+ timestamp: updatedBlocker.timestamp
230
+ },
231
+ {
232
+ scope: existingBlocker.scope,
233
+ reason: existingBlocker.reason,
234
+ priority: existingBlocker.priority,
235
+ timeout: existingBlocker.timeout,
236
+ onTimeout: existingBlocker.onTimeout,
237
+ timestamp: existingBlocker.timestamp
238
+ }
239
+ )
240
+ );
114
241
  },
115
242
  /**
116
- * Remove a blocker from the store
117
- *
118
- * @param id - Blocker identifier to remove
119
- *
243
+ * Removes a blocker from the store.
244
+ *
245
+ * Clears any associated timeout and triggers middleware with 'remove' action.
246
+ * Safe to call even if the blocker doesn't exist (no-op in that case).
247
+ *
248
+ * @param id - Identifier of the blocker to remove
249
+ *
250
+ * @example
251
+ * ```ts
252
+ * // Add and remove a blocker
253
+ * store.getState().addBlocker('temp', { scope: 'form' });
254
+ * // ... later
255
+ * store.getState().removeBlocker('temp');
256
+ * ```
257
+ *
258
+ * @see {@link addBlocker} to add a blocker
259
+ * @see {@link clearAllBlockers} to remove all blockers at once
120
260
  */
121
261
  removeBlocker: (id) => {
122
262
  const prevBlocker = get().activeBlockers.get(id);
@@ -129,21 +269,34 @@ var createUIBlockingActions = (set, get) => ({
129
269
  return { activeBlockers: newBlockers };
130
270
  });
131
271
  if (prevBlocker) {
132
- void get().runMiddlewares({
133
- action: "remove",
134
- blockerId: id,
135
- config: prevBlocker,
136
- timestamp: Date.now(),
137
- prevState: prevBlocker
138
- });
272
+ void get().runMiddlewares(createMiddlewareContext("remove", id, prevBlocker, prevBlocker));
139
273
  }
140
274
  },
141
275
  /**
142
- * Check if a scope is currently blocked
143
- *
144
- * @param scope - Scope or array of scopes to check (default: "global")
145
- * @returns True if any of the scopes are blocked
146
- *
276
+ * Checks if one or more scopes are currently blocked.
277
+ *
278
+ * Returns true if ANY blocker affects the specified scope(s). Global scope
279
+ * blockers will cause this to return true for any scope check.
280
+ *
281
+ * @param scope - Scope(s) to check. Defaults to "global" if not specified.
282
+ * Can be a single scope string or array of scopes.
283
+ * @returns true if any of the specified scopes are currently blocked
284
+ *
285
+ * @example
286
+ * Single scope check
287
+ * ```ts
288
+ * const isFormBlocked = store.getState().isBlocked('form');
289
+ * console.log(isFormBlocked); // true or false
290
+ * ```
291
+ *
292
+ * @example
293
+ * Multiple scopes check
294
+ * ```ts
295
+ * const isBlocked = store.getState().isBlocked(['form', 'navigation']);
296
+ * // Returns true if either 'form' OR 'navigation' has active blockers
297
+ * ```
298
+ *
299
+ * @see {@link getBlockingInfo} for detailed blocker information
147
300
  */
148
301
  isBlocked: (scope = DEFAULT_SCOPE) => {
149
302
  const { activeBlockers } = get();
@@ -160,11 +313,37 @@ var createUIBlockingActions = (set, get) => ({
160
313
  return false;
161
314
  },
162
315
  /**
163
- * Get detailed blocking information for a specific scope
164
- *
316
+ * Gets detailed information about all blockers affecting a specific scope.
317
+ *
318
+ * Returns an array of blocker objects sorted by priority (highest first).
319
+ * Includes global blockers and scope-specific blockers. Each blocker object
320
+ * contains id, reason, priority, scope, timestamp, and timeout information.
321
+ *
165
322
  * @param scope - Scope to get blocking information for
166
- * @returns Array of blockers affecting the scope, sorted by priority (highest first)
167
- *
323
+ * @returns Array of {@link BlockerInfo} objects, sorted by priority descending
324
+ *
325
+ * @example
326
+ * Display blocker details
327
+ * ```ts
328
+ * const blockers = store.getState().getBlockingInfo('form');
329
+ * blockers.forEach(blocker => {
330
+ * console.log(`${blocker.id}: ${blocker.reason}`);
331
+ * console.log(`Priority: ${blocker.priority}`);
332
+ * });
333
+ * ```
334
+ *
335
+ * @example
336
+ * Get highest priority blocker
337
+ * ```ts
338
+ * const blockers = store.getState().getBlockingInfo('navigation');
339
+ * const topBlocker = blockers[0]; // Highest priority
340
+ * if (topBlocker) {
341
+ * console.log(`Top blocker: ${topBlocker.reason}`);
342
+ * }
343
+ * ```
344
+ *
345
+ * @see {@link isBlocked} for a simple boolean check
346
+ * @see {@link BlockerInfo} for the structure of returned objects
168
347
  */
169
348
  getBlockingInfo: (scope) => {
170
349
  const { activeBlockers } = get();
@@ -178,29 +357,71 @@ var createUIBlockingActions = (set, get) => ({
178
357
  return blockers.sort((a, b) => b.priority - a.priority);
179
358
  },
180
359
  /**
181
- * Clear all blockers from the store
360
+ * Removes all active blockers from the store.
361
+ *
362
+ * Clears all timeouts and triggers middleware with 'clear' action.
363
+ * Useful for resetting the blocking state.
364
+ *
365
+ * @example
366
+ * ```ts
367
+ * // Clear all blockers on logout
368
+ * store.getState().clearAllBlockers();
369
+ * ```
370
+ *
371
+ * @see {@link clearBlockersForScope} to clear blockers for a specific scope
182
372
  */
183
373
  clearAllBlockers: () => {
184
374
  const { activeBlockers } = get();
375
+ const count = activeBlockers.size;
185
376
  for (const [, blocker] of activeBlockers) {
186
377
  if (blocker.timeoutId) {
187
378
  clearTimeout(blocker.timeoutId);
188
379
  }
189
380
  }
190
381
  set({ activeBlockers: /* @__PURE__ */ new Map() });
382
+ if (count > 0) {
383
+ void get().runMiddlewares({
384
+ action: "clear",
385
+ blockerId: "*",
386
+ timestamp: Date.now(),
387
+ count
388
+ });
389
+ }
191
390
  },
192
391
  /**
193
- * Clear all blockers for a specific scope
194
- *
392
+ * Removes all blockers that affect a specific scope.
393
+ *
394
+ * Clears timeouts for removed blockers and triggers middleware with
395
+ * 'clear_scope' action. Global scope blockers are NOT cleared by this method.
396
+ *
195
397
  * @param scope - Scope to clear blockers for
196
- *
398
+ *
399
+ * @example
400
+ * Clear form blockers
401
+ * ```ts
402
+ * // Clear all blockers affecting 'form' scope
403
+ * store.getState().clearBlockersForScope('form');
404
+ * ```
405
+ *
406
+ * @example
407
+ * Clear on navigation
408
+ * ```ts
409
+ * // Clear navigation blockers when user navigates away
410
+ * store.getState().clearBlockersForScope('navigation');
411
+ * ```
412
+ *
413
+ * @see {@link clearAllBlockers} to clear all blockers
197
414
  */
198
415
  clearBlockersForScope: (scope) => {
199
416
  const { activeBlockers } = get();
417
+ let count = 0;
200
418
  for (const [, blocker] of activeBlockers) {
201
419
  const blockerScopes = normalizeScopeToArray(blocker.scope);
202
- if (blockerScopes.includes(scope) && blocker.timeoutId) {
203
- clearTimeout(blocker.timeoutId);
420
+ if (blockerScopes.includes(scope)) {
421
+ if (blocker.timeoutId) {
422
+ clearTimeout(blocker.timeoutId);
423
+ }
424
+ count++;
204
425
  }
205
426
  }
206
427
  set((state) => {
@@ -213,6 +434,15 @@ var createUIBlockingActions = (set, get) => ({
213
434
  }
214
435
  return { activeBlockers: newBlockers };
215
436
  });
437
+ if (count > 0) {
438
+ void get().runMiddlewares({
439
+ action: "clear_scope",
440
+ blockerId: "*",
441
+ timestamp: Date.now(),
442
+ scope,
443
+ count
444
+ });
445
+ }
216
446
  }
217
447
  });
218
448
  var {
@@ -272,9 +502,11 @@ var useAsyncAction = (actionId, scope, options) => {
272
502
  addBlocker: state.addBlocker,
273
503
  removeBlocker: state.removeBlocker
274
504
  }));
505
+ const counterRef = useRef(0);
275
506
  const executeWithBlocking = useCallback(
276
507
  async (asyncFn) => {
277
- const blockerId = `${actionId}-${Date.now().toString()}`;
508
+ counterRef.current += 1;
509
+ const blockerId = `${actionId}-${String(counterRef.current)}`;
278
510
  try {
279
511
  addBlocker(blockerId, {
280
512
  scope,
@@ -609,14 +841,16 @@ var getActionEmoji = (action) => {
609
841
  switch (action) {
610
842
  case "add":
611
843
  return "\u2795";
612
- case "remove":
613
- return "\u2796";
614
844
  case "update":
615
845
  return "\u{1F504}";
616
- case "cancel":
617
- return "\u274C";
846
+ case "remove":
847
+ return "\u2796";
618
848
  case "timeout":
619
849
  return "\u23F1\uFE0F";
850
+ case "clear":
851
+ return "\u{1F9F9}";
852
+ case "clear_scope":
853
+ return "\u{1F3AF}";
620
854
  default:
621
855
  return "\u2753";
622
856
  }
@@ -637,13 +871,23 @@ var extractConfigDetails = (config) => {
637
871
  };
638
872
  var formatLogData = (context) => {
639
873
  const configDetails = extractConfigDetails(context.config);
874
+ const logData = {};
875
+ if (context.scope !== void 0) {
876
+ logData.scope = context.scope;
877
+ }
878
+ if (context.count !== void 0) {
879
+ logData.count = context.count;
880
+ }
881
+ if (Object.keys(configDetails).length > 0) {
882
+ logData.config = configDetails;
883
+ }
640
884
  if (context.prevState) {
641
- return {
642
- config: configDetails,
643
- prevState: extractConfigDetails(context.prevState)
644
- };
885
+ logData.prevState = extractConfigDetails(context.prevState);
886
+ }
887
+ if (Object.keys(logData).length === 0) {
888
+ return configDetails;
645
889
  }
646
- return configDetails;
890
+ return Object.keys(logData).length === 1 && logData.config ? configDetails : logData;
647
891
  };
648
892
 
649
893
  // src/middleware/loggerMiddleware.ts