@okyrychenko-dev/react-action-guard 0.1.0 → 0.2.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/README.md CHANGED
@@ -20,16 +20,12 @@ npm install @okyrychenko-dev/react-action-guard zustand
20
20
  ## Quick Start
21
21
 
22
22
  ```jsx
23
- import { useBlocker, useIsBlocked } from '@okyrychenko-dev/react-action-guard';
23
+ import { useBlocker, useIsBlocked } from "@okyrychenko-dev/react-action-guard";
24
24
 
25
25
  function MyComponent() {
26
- const isBlocked = useIsBlocked('my-scope');
26
+ const isBlocked = useIsBlocked("my-scope");
27
27
 
28
- return (
29
- <button disabled={isBlocked}>
30
- Click me
31
- </button>
32
- );
28
+ return <button disabled={isBlocked}>Click me</button>;
33
29
  }
34
30
  ```
35
31
 
@@ -42,6 +38,7 @@ function MyComponent() {
42
38
  Automatically adds a blocker when the component mounts and removes it on unmount.
43
39
 
44
40
  **Parameters:**
41
+
45
42
  - `blockerId: string` - Unique identifier for the blocker
46
43
  - `config: BlockerConfig` - Configuration object
47
44
  - `scope?: string | string[]` - Scope(s) to block (default: "global")
@@ -53,10 +50,10 @@ Automatically adds a blocker when the component mounts and removes it on unmount
53
50
 
54
51
  ```jsx
55
52
  function MyComponent() {
56
- useBlocker('my-blocker', {
57
- scope: 'form',
58
- reason: 'Form is saving',
59
- priority: 10
53
+ useBlocker("my-blocker", {
54
+ scope: "form",
55
+ reason: "Form is saving",
56
+ priority: 10,
60
57
  });
61
58
 
62
59
  return <div>Content</div>;
@@ -68,6 +65,7 @@ function MyComponent() {
68
65
  Checks if a specific scope (or scopes) is currently blocked.
69
66
 
70
67
  **Parameters:**
68
+
71
69
  - `scope?: string | string[]` - Scope(s) to check (default: "global")
72
70
 
73
71
  **Returns:** `boolean` - Whether the scope is blocked
@@ -76,13 +74,9 @@ Checks if a specific scope (or scopes) is currently blocked.
76
74
 
77
75
  ```jsx
78
76
  function SubmitButton() {
79
- const isFormBlocked = useIsBlocked('form');
77
+ const isFormBlocked = useIsBlocked("form");
80
78
 
81
- return (
82
- <button disabled={isFormBlocked}>
83
- Submit
84
- </button>
85
- );
79
+ return <button disabled={isFormBlocked}>Submit</button>;
86
80
  }
87
81
  ```
88
82
 
@@ -91,6 +85,7 @@ function SubmitButton() {
91
85
  Wraps an async function with automatic blocking/unblocking.
92
86
 
93
87
  **Parameters:**
88
+
94
89
  - `actionId: string` - Identifier for the action
95
90
  - `scope?: string | string[]` - Scope(s) to block during execution
96
91
 
@@ -100,7 +95,7 @@ Wraps an async function with automatic blocking/unblocking.
100
95
 
101
96
  ```jsx
102
97
  function MyComponent() {
103
- const executeWithBlocking = useAsyncAction('save-data', 'form');
98
+ const executeWithBlocking = useAsyncAction("save-data", "form");
104
99
 
105
100
  const handleSave = async () => {
106
101
  await executeWithBlocking(async () => {
@@ -119,6 +114,7 @@ function MyComponent() {
119
114
  Direct access to the Zustand store for advanced use cases.
120
115
 
121
116
  **Methods:**
117
+
122
118
  - `addBlocker(id, config)` - Manually add a blocker
123
119
  - `removeBlocker(id)` - Manually remove a blocker
124
120
  - `isBlocked(scope)` - Check if scope is blocked
@@ -129,21 +125,21 @@ Direct access to the Zustand store for advanced use cases.
129
125
  **Example:**
130
126
 
131
127
  ```jsx
132
- import { useUIBlockingStore } from '@okyrychenko-dev/react-action-guard';
128
+ import { useUIBlockingStore } from "@okyrychenko-dev/react-action-guard";
133
129
 
134
130
  function AdvancedComponent() {
135
131
  const { addBlocker, removeBlocker } = useUIBlockingStore();
136
132
 
137
133
  const startBlocking = () => {
138
- addBlocker('custom-blocker', {
139
- scope: ['form', 'navigation'],
140
- reason: 'Critical operation in progress',
141
- priority: 100
134
+ addBlocker("custom-blocker", {
135
+ scope: ["form", "navigation"],
136
+ reason: "Critical operation in progress",
137
+ priority: 100,
142
138
  });
143
139
  };
144
140
 
145
141
  const stopBlocking = () => {
146
- removeBlocker('custom-blocker');
142
+ removeBlocker("custom-blocker");
147
143
  };
148
144
 
149
145
  return (
@@ -160,11 +156,7 @@ function AdvancedComponent() {
160
156
  The package is written in TypeScript and includes full type definitions.
161
157
 
162
158
  ```typescript
163
- import type {
164
- BlockerConfig,
165
- BlockerInfo,
166
- UIBlockingState
167
- } from '@okyrychenko-dev/react-action-guard';
159
+ import type { BlockerConfig, BlockerInfo, UIBlockingState } from "@okyrychenko-dev/react-action-guard";
168
160
  ```
169
161
 
170
162
  ## Use Cases
@@ -175,10 +167,14 @@ import type {
175
167
  function DataLoader() {
176
168
  const [isLoading, setIsLoading] = useState(false);
177
169
 
178
- useBlocker('data-loader', {
179
- scope: 'content',
180
- reason: 'Loading data'
181
- }, isLoading);
170
+ useBlocker(
171
+ "data-loader",
172
+ {
173
+ scope: "content",
174
+ reason: "Loading data",
175
+ },
176
+ isLoading
177
+ );
182
178
 
183
179
  // ... rest of component
184
180
  }
@@ -188,8 +184,8 @@ function DataLoader() {
188
184
 
189
185
  ```jsx
190
186
  function UserForm() {
191
- const executeWithBlocking = useAsyncAction('submit-form', 'form');
192
- const isBlocked = useIsBlocked('form');
187
+ const executeWithBlocking = useAsyncAction("submit-form", "form");
188
+ const isBlocked = useIsBlocked("form");
193
189
 
194
190
  const handleSubmit = async (data) => {
195
191
  await executeWithBlocking(async () => {
@@ -210,7 +206,7 @@ function UserForm() {
210
206
 
211
207
  ```jsx
212
208
  function App() {
213
- const isGloballyBlocked = useIsBlocked('global');
209
+ const isGloballyBlocked = useIsBlocked("global");
214
210
 
215
211
  return (
216
212
  <div>
@@ -239,4 +235,4 @@ npm run dev
239
235
 
240
236
  ## License
241
237
 
242
- MIT © Oleksandr Kyrychenko
238
+ MIT © Olexii Kyrychenko
package/dist/index.cjs CHANGED
@@ -25,6 +25,33 @@ function normalizeScopeToArray(scope) {
25
25
  var createUIBlockingActions = (set, get) => ({
26
26
  // State
27
27
  activeBlockers: /* @__PURE__ */ new Map(),
28
+ middlewares: /* @__PURE__ */ new Map(),
29
+ registerMiddleware: (name, middleware) => {
30
+ set((state) => {
31
+ const newMiddlewares = new Map(state.middlewares);
32
+ newMiddlewares.set(name, middleware);
33
+ return { middlewares: newMiddlewares };
34
+ });
35
+ console.log(`[UIBlocking] Registered middleware: ${name}`);
36
+ },
37
+ unregisterMiddleware: (name) => {
38
+ set((state) => {
39
+ const newMiddlewares = new Map(state.middlewares);
40
+ newMiddlewares.delete(name);
41
+ return { middlewares: newMiddlewares };
42
+ });
43
+ console.log(`[UIBlocking] Unregistered middleware: ${name}`);
44
+ },
45
+ runMiddlewares: async (context) => {
46
+ const { middlewares } = get();
47
+ for (const [name, middleware] of middlewares) {
48
+ try {
49
+ await middleware(context);
50
+ } catch (error) {
51
+ console.error(`[UIBlocking] Middleware "${name}" error:`, error);
52
+ }
53
+ }
54
+ },
28
55
  // Actions
29
56
  /**
30
57
  * Add a blocker to the store
@@ -45,6 +72,16 @@ var createUIBlockingActions = (set, get) => ({
45
72
  newBlockers.set(id, storedBlocker);
46
73
  return { activeBlockers: newBlockers };
47
74
  });
75
+ void get().runMiddlewares({
76
+ action: "add",
77
+ blockerId: id,
78
+ config: {
79
+ scope: config.scope,
80
+ reason: config.reason,
81
+ priority: config.priority
82
+ },
83
+ timestamp: Date.now()
84
+ });
48
85
  },
49
86
  /**
50
87
  * Remove a blocker from the store
@@ -53,11 +90,21 @@ var createUIBlockingActions = (set, get) => ({
53
90
  *
54
91
  */
55
92
  removeBlocker: (id) => {
93
+ const prevBlocker = get().activeBlockers.get(id);
56
94
  set((state) => {
57
95
  const newBlockers = new Map(state.activeBlockers);
58
96
  newBlockers.delete(id);
59
97
  return { activeBlockers: newBlockers };
60
98
  });
99
+ if (prevBlocker) {
100
+ void get().runMiddlewares({
101
+ action: "remove",
102
+ blockerId: id,
103
+ config: prevBlocker,
104
+ timestamp: Date.now(),
105
+ prevState: prevBlocker
106
+ });
107
+ }
61
108
  },
62
109
  /**
63
110
  * Check if a scope is currently blocked
@@ -173,10 +220,324 @@ var useAsyncAction = (actionId, scope) => {
173
220
  );
174
221
  return executeWithBlocking;
175
222
  };
223
+ function useConfigRef(config) {
224
+ const configRef = react.useRef(config);
225
+ react.useEffect(() => {
226
+ configRef.current = config;
227
+ }, [config]);
228
+ return configRef;
229
+ }
230
+
231
+ // src/hooks/useBlocker.utils.ts
232
+ function createBlockerConfig(config) {
233
+ return {
234
+ scope: config.scope,
235
+ reason: config.reason,
236
+ priority: config.priority
237
+ };
238
+ }
239
+
240
+ // src/hooks/useConditionalBlocker.ts
241
+ var useConditionalBlocker = (blockerId, config) => {
242
+ const { addBlocker, removeBlocker } = useUIBlockingStore();
243
+ const intervalRef = react.useRef(null);
244
+ const isBlockedRef = react.useRef(false);
245
+ const configRef = useConfigRef(config);
246
+ const checkCondition = react.useCallback(() => {
247
+ const currentConfig = configRef.current;
248
+ const shouldBlock = currentConfig.condition(currentConfig.state);
249
+ if (shouldBlock === isBlockedRef.current) {
250
+ return;
251
+ }
252
+ if (shouldBlock) {
253
+ addBlocker(blockerId, createBlockerConfig(currentConfig));
254
+ isBlockedRef.current = true;
255
+ } else {
256
+ removeBlocker(blockerId);
257
+ isBlockedRef.current = false;
258
+ }
259
+ }, [blockerId, addBlocker, removeBlocker, configRef]);
260
+ react.useEffect(() => {
261
+ checkCondition();
262
+ const interval = configRef.current.checkInterval ?? 1e3;
263
+ intervalRef.current = setInterval(checkCondition, interval);
264
+ return () => {
265
+ if (intervalRef.current) {
266
+ clearInterval(intervalRef.current);
267
+ intervalRef.current = null;
268
+ }
269
+ if (isBlockedRef.current) {
270
+ removeBlocker(blockerId);
271
+ isBlockedRef.current = false;
272
+ }
273
+ };
274
+ }, [checkCondition, blockerId, removeBlocker]);
275
+ };
276
+ var useConfirmableBlocker = (blockerId, config) => {
277
+ const [isDialogOpen, setIsDialogOpen] = react.useState(false);
278
+ const [isExecuting, setIsExecuting] = react.useState(false);
279
+ useBlocker(
280
+ blockerId,
281
+ {
282
+ ...config,
283
+ reason: config.reason ?? config.confirmMessage
284
+ },
285
+ isDialogOpen || isExecuting
286
+ );
287
+ const execute = react.useCallback(() => {
288
+ setIsDialogOpen(true);
289
+ }, []);
290
+ const onConfirm = react.useCallback(async () => {
291
+ setIsDialogOpen(false);
292
+ setIsExecuting(true);
293
+ try {
294
+ await config.onConfirm();
295
+ } catch (error) {
296
+ console.error("[UIBlocking] Confirmable action error:", error);
297
+ throw error;
298
+ } finally {
299
+ setIsExecuting(false);
300
+ }
301
+ }, [config]);
302
+ const onCancel = react.useCallback(() => {
303
+ setIsDialogOpen(false);
304
+ config.onCancel?.();
305
+ }, [config]);
306
+ return {
307
+ execute,
308
+ isDialogOpen,
309
+ isExecuting,
310
+ confirmConfig: {
311
+ title: config.confirmTitle ?? "Confirm Action",
312
+ message: config.confirmMessage,
313
+ confirmText: config.confirmButtonText ?? "Confirm",
314
+ cancelText: config.cancelButtonText ?? "Cancel"
315
+ },
316
+ onConfirm,
317
+ onCancel
318
+ };
319
+ };
320
+ var useScheduledBlocker = (blockerId, config) => {
321
+ const { addBlocker, removeBlocker } = useUIBlockingStore();
322
+ const timeoutsRef = react.useRef({});
323
+ const configRef = useConfigRef(config);
324
+ const cleanup = react.useCallback(() => {
325
+ if (timeoutsRef.current.start) {
326
+ clearTimeout(timeoutsRef.current.start);
327
+ timeoutsRef.current.start = void 0;
328
+ }
329
+ if (timeoutsRef.current.end) {
330
+ clearTimeout(timeoutsRef.current.end);
331
+ timeoutsRef.current.end = void 0;
332
+ }
333
+ }, []);
334
+ react.useEffect(() => {
335
+ const currentConfig = configRef.current;
336
+ const startTime = new Date(currentConfig.schedule.start).getTime();
337
+ const now = Date.now();
338
+ let endTime;
339
+ if (currentConfig.schedule.duration) {
340
+ endTime = startTime + currentConfig.schedule.duration;
341
+ } else if (currentConfig.schedule.end) {
342
+ endTime = new Date(currentConfig.schedule.end).getTime();
343
+ }
344
+ const scheduleEnd = (end) => {
345
+ const remainingTime = end - Date.now();
346
+ if (remainingTime > 0) {
347
+ timeoutsRef.current.end = setTimeout(() => {
348
+ removeBlocker(blockerId);
349
+ configRef.current.onScheduleEnd?.();
350
+ }, remainingTime);
351
+ }
352
+ };
353
+ if (startTime > now) {
354
+ timeoutsRef.current.start = setTimeout(() => {
355
+ const cfg = configRef.current;
356
+ addBlocker(blockerId, createBlockerConfig(cfg));
357
+ cfg.onScheduleStart?.();
358
+ if (endTime) {
359
+ scheduleEnd(endTime);
360
+ }
361
+ }, startTime - now);
362
+ } else if (endTime && endTime > now) {
363
+ addBlocker(blockerId, createBlockerConfig(currentConfig));
364
+ currentConfig.onScheduleStart?.();
365
+ scheduleEnd(endTime);
366
+ } else if (!endTime && startTime <= now) {
367
+ addBlocker(blockerId, createBlockerConfig(currentConfig));
368
+ currentConfig.onScheduleStart?.();
369
+ } else {
370
+ console.warn(`[UIBlocking] Schedule is in the past for blocker "${blockerId}"`);
371
+ }
372
+ return () => {
373
+ cleanup();
374
+ removeBlocker(blockerId);
375
+ };
376
+ }, [blockerId, addBlocker, removeBlocker, cleanup]);
377
+ };
378
+
379
+ // src/middleware/loggerMiddleware.utils.ts
380
+ var getActionEmoji = (action) => {
381
+ switch (action) {
382
+ case "add":
383
+ return "\u2795";
384
+ case "remove":
385
+ return "\u2796";
386
+ case "update":
387
+ return "\u{1F504}";
388
+ case "cancel":
389
+ return "\u274C";
390
+ case "timeout":
391
+ return "\u23F1\uFE0F";
392
+ default:
393
+ return "\u2753";
394
+ }
395
+ };
396
+ var extractConfigDetails = (config) => {
397
+ if (!config) {
398
+ return {};
399
+ }
400
+ const { scope, reason, priority } = config;
401
+ const details = {
402
+ scope,
403
+ reason
404
+ };
405
+ if (priority !== void 0) {
406
+ details.priority = priority;
407
+ }
408
+ return details;
409
+ };
410
+ var formatLogData = (context) => {
411
+ const configDetails = extractConfigDetails(context.config);
412
+ if (context.prevState) {
413
+ return {
414
+ config: configDetails,
415
+ prevState: extractConfigDetails(context.prevState)
416
+ };
417
+ }
418
+ return configDetails;
419
+ };
420
+
421
+ // src/middleware/loggerMiddleware.ts
422
+ var LOG_PREFIX = "[UIBlocking]";
423
+ var loggerMiddleware = (context) => {
424
+ const emoji = getActionEmoji(context.action);
425
+ const logData = formatLogData(context);
426
+ console.log(LOG_PREFIX, emoji, context.blockerId, logData);
427
+ };
428
+
429
+ // src/middleware/analyticsMiddleware.utils.ts
430
+ var capitalizeFirst = (str) => {
431
+ return str.charAt(0).toUpperCase() + str.slice(1);
432
+ };
433
+ var buildEventData = (context) => {
434
+ const eventData = {
435
+ blocker_id: context.blockerId
436
+ };
437
+ const { scope, reason, priority } = context.config ?? {};
438
+ if (scope !== void 0) {
439
+ eventData.scope = Array.isArray(scope) ? scope.join(",") : scope;
440
+ }
441
+ if (reason !== void 0) {
442
+ eventData.reason = reason;
443
+ }
444
+ if (priority !== void 0) {
445
+ eventData.priority = priority;
446
+ }
447
+ return eventData;
448
+ };
449
+ var trackGoogleAnalytics = (action, eventData) => {
450
+ if (window.gtag) {
451
+ window.gtag("event", `ui_blocking_${action}`, eventData);
452
+ }
453
+ };
454
+ var trackMixpanel = (action, eventData) => {
455
+ if (window.mixpanel) {
456
+ const eventName = `UI Blocking ${action.split("_").map(capitalizeFirst).join(" ")}`;
457
+ window.mixpanel.track(eventName, eventData);
458
+ }
459
+ };
460
+ var trackAmplitude = (action, eventData) => {
461
+ if (window.amplitude) {
462
+ const eventName = `UI Blocking ${action.split("_").map(capitalizeFirst).join(" ")}`;
463
+ window.amplitude.track(eventName, eventData);
464
+ }
465
+ };
466
+ var trackCustom = (trackFn, action, eventData) => {
467
+ try {
468
+ trackFn(`ui_blocking_${action}`, eventData);
469
+ } catch {
470
+ }
471
+ };
472
+
473
+ // src/middleware/analyticsMiddleware.ts
474
+ var trackEvent = (config, context, eventData) => {
475
+ if ("provider" in config) {
476
+ switch (config.provider) {
477
+ case "ga":
478
+ trackGoogleAnalytics(context.action, eventData);
479
+ return;
480
+ case "mixpanel":
481
+ trackMixpanel(context.action, eventData);
482
+ return;
483
+ case "amplitude":
484
+ trackAmplitude(context.action, eventData);
485
+ return;
486
+ }
487
+ }
488
+ if ("track" in config) {
489
+ trackCustom(config.track, context.action, eventData);
490
+ }
491
+ };
492
+ var createAnalyticsMiddleware = (config) => {
493
+ return (context) => {
494
+ const eventData = buildEventData(context);
495
+ trackEvent(config, context, eventData);
496
+ };
497
+ };
498
+
499
+ // src/middleware/performanceMiddleware.utils.ts
500
+ var handleAddAction = (blockerId, timestamp, blockStartTimes) => {
501
+ blockStartTimes.set(blockerId, timestamp);
502
+ };
503
+
504
+ // src/middleware/performanceMiddleware.ts
505
+ var DEFAULT_SLOW_BLOCK_THRESHOLD = 3e3;
506
+ var ACTION_ADD = "add";
507
+ var ACTION_REMOVE = "remove";
508
+ var handleRemoveAction = (context, blockStartTimes, slowBlockThreshold, onSlowBlock) => {
509
+ const startTime = blockStartTimes.get(context.blockerId);
510
+ if (startTime === void 0) {
511
+ return;
512
+ }
513
+ const duration = context.timestamp - startTime;
514
+ blockStartTimes.delete(context.blockerId);
515
+ if (duration >= slowBlockThreshold) {
516
+ console.warn(`[UIBlocking] Slow block detected: "${context.blockerId}" took ${duration.toString()}ms`);
517
+ onSlowBlock?.(context.blockerId, duration);
518
+ }
519
+ };
520
+ var createPerformanceMiddleware = (config = {}) => {
521
+ const { slowBlockThreshold = DEFAULT_SLOW_BLOCK_THRESHOLD, onSlowBlock } = config;
522
+ const blockStartTimes = /* @__PURE__ */ new Map();
523
+ return (context) => {
524
+ if (context.action === ACTION_ADD) {
525
+ handleAddAction(context.blockerId, context.timestamp, blockStartTimes);
526
+ } else if (context.action === ACTION_REMOVE) {
527
+ handleRemoveAction(context, blockStartTimes, slowBlockThreshold, onSlowBlock);
528
+ }
529
+ };
530
+ };
176
531
 
532
+ exports.createAnalyticsMiddleware = createAnalyticsMiddleware;
533
+ exports.createPerformanceMiddleware = createPerformanceMiddleware;
534
+ exports.loggerMiddleware = loggerMiddleware;
177
535
  exports.useAsyncAction = useAsyncAction;
178
536
  exports.useBlocker = useBlocker;
537
+ exports.useConditionalBlocker = useConditionalBlocker;
538
+ exports.useConfirmableBlocker = useConfirmableBlocker;
179
539
  exports.useIsBlocked = useIsBlocked;
540
+ exports.useScheduledBlocker = useScheduledBlocker;
180
541
  exports.useUIBlockingStore = useUIBlockingStore;
181
542
  //# sourceMappingURL=index.cjs.map
182
543
  //# sourceMappingURL=index.cjs.map