cogsbox-state 0.5.472 → 0.5.474

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.
Files changed (46) hide show
  1. package/README.md +48 -18
  2. package/dist/CogsState.d.ts +98 -82
  3. package/dist/CogsState.d.ts.map +1 -1
  4. package/dist/CogsState.jsx +1030 -960
  5. package/dist/CogsState.jsx.map +1 -1
  6. package/dist/Components.d.ts.map +1 -1
  7. package/dist/Components.jsx +299 -219
  8. package/dist/Components.jsx.map +1 -1
  9. package/dist/PluginRunner.d.ts +10 -0
  10. package/dist/PluginRunner.d.ts.map +1 -0
  11. package/dist/PluginRunner.jsx +122 -0
  12. package/dist/PluginRunner.jsx.map +1 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +33 -26
  16. package/dist/index.js.map +1 -1
  17. package/dist/pluginStore.d.ts +81 -0
  18. package/dist/pluginStore.d.ts.map +1 -0
  19. package/dist/pluginStore.js +52 -0
  20. package/dist/pluginStore.js.map +1 -0
  21. package/dist/plugins.d.ts +1323 -0
  22. package/dist/plugins.d.ts.map +1 -0
  23. package/dist/plugins.js +76 -0
  24. package/dist/plugins.js.map +1 -0
  25. package/dist/store.d.ts +50 -15
  26. package/dist/store.d.ts.map +1 -1
  27. package/dist/store.js +509 -470
  28. package/dist/store.js.map +1 -1
  29. package/dist/utility.d.ts +1 -1
  30. package/dist/utility.d.ts.map +1 -1
  31. package/dist/utility.js +12 -12
  32. package/dist/utility.js.map +1 -1
  33. package/dist/validation.d.ts +7 -0
  34. package/dist/validation.d.ts.map +1 -0
  35. package/dist/validation.js +39 -0
  36. package/dist/validation.js.map +1 -0
  37. package/package.json +13 -3
  38. package/src/CogsState.tsx +657 -457
  39. package/src/Components.tsx +291 -194
  40. package/src/PluginRunner.tsx +203 -0
  41. package/src/index.ts +2 -0
  42. package/src/pluginStore.ts +176 -0
  43. package/src/plugins.ts +544 -0
  44. package/src/store.ts +748 -493
  45. package/src/utility.ts +31 -31
  46. package/src/validation.ts +84 -0
@@ -1,4 +1,11 @@
1
- import { FormElementParams, type FormOptsType } from './CogsState';
1
+ import {
2
+ FormElementParams,
3
+ StateObject,
4
+ UpdateTypeDetail,
5
+ type FormOptsType,
6
+ } from './CogsState';
7
+ import { pluginStore } from './pluginStore';
8
+ import { createMetadataContext, toDeconstructedMethods } from './plugins';
2
9
  import React, {
3
10
  memo,
4
11
  RefObject,
@@ -7,16 +14,14 @@ import React, {
7
14
  useLayoutEffect,
8
15
  useRef,
9
16
  useState,
17
+ useMemo,
10
18
  } from 'react';
11
- import {
12
- formRefStore,
13
- getGlobalStore,
14
- ValidationError,
15
- ValidationSeverity,
16
- } from './store';
19
+ import { getGlobalStore, ValidationError, ValidationSeverity } from './store';
17
20
  import { useInView } from 'react-intersection-observer';
18
21
  import { v4 as uuidv4 } from 'uuid';
19
22
  import { isDeepEqual } from './utility';
23
+ import { runValidation } from './validation';
24
+
20
25
  const {
21
26
  getInitialOptions,
22
27
 
@@ -30,6 +35,8 @@ const {
30
35
  notifyPathSubscribers,
31
36
  subscribeToPath,
32
37
  } = getGlobalStore.getState();
38
+ const { stateHandlers, notifyFormUpdate } = pluginStore.getState();
39
+
33
40
  export type ValidationWrapperProps = {
34
41
  formOpts?: FormOptsType;
35
42
  path: string[];
@@ -223,21 +230,31 @@ export function FormElementWrapper({
223
230
  setState: any;
224
231
  }) {
225
232
  const componentId = useRef(uuidv4()).current;
226
- const [, forceUpdate] = useState({});
227
233
 
234
+ const [, forceUpdate] = useState({});
235
+ const formElementRef = useRef<any>(null);
228
236
  const stateKeyPathKey = [stateKey, ...path].join('.');
229
237
  useRegisterComponent(stateKey, componentId, forceUpdate);
230
-
231
238
  // Get the shadow node to access typeInfo and schema
232
239
  const shadowNode = getGlobalStore.getState().getShadowNode(stateKey, path);
233
240
  const typeInfo = shadowNode?._meta?.typeInfo;
234
- const fieldSchema = typeInfo?.schema; // The actual Zod schema for this field
235
241
 
236
242
  const globalStateValue = getShadowValue(stateKey, path);
237
243
  const [localValue, setLocalValue] = useState<any>(globalStateValue);
238
244
  const isCurrentlyDebouncing = useRef(false);
239
245
  const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
240
246
 
247
+ // 2. Memoize the list of active form wrappers to avoid re-calculating on every render.
248
+ const activeFormWrappers = useMemo(() => {
249
+ return (
250
+ pluginStore
251
+ .getState()
252
+ .getPluginConfigsForState(stateKey)
253
+ // We only care about plugins that have defined a formWrapper
254
+ .filter((config) => typeof config.plugin.formWrapper === 'function')
255
+ );
256
+ }, [stateKey]);
257
+
241
258
  useEffect(() => {
242
259
  if (
243
260
  !isCurrentlyDebouncing.current &&
@@ -248,6 +265,42 @@ export function FormElementWrapper({
248
265
  }, [globalStateValue]);
249
266
 
250
267
  useEffect(() => {
268
+ const { getShadowMetadata, setShadowMetadata } = getGlobalStore.getState();
269
+
270
+ // Initialize clientActivityState if needed
271
+ const currentMeta = getShadowMetadata(stateKey, path) || {};
272
+ if (!currentMeta.clientActivityState) {
273
+ currentMeta.clientActivityState = { elements: new Map() };
274
+ }
275
+
276
+ // Detect element type from the ref
277
+ const detectElementType = () => {
278
+ const el = formElementRef.current;
279
+ if (!el) return 'input';
280
+ const tagName = el.tagName.toLowerCase();
281
+ if (tagName === 'textarea') return 'textarea';
282
+ if (tagName === 'select') return 'select';
283
+ if (tagName === 'input') {
284
+ const type = (el as HTMLInputElement).type;
285
+ if (type === 'checkbox') return 'checkbox';
286
+ if (type === 'radio') return 'radio';
287
+ if (type === 'range') return 'range';
288
+ if (type === 'file') return 'file';
289
+ }
290
+ return 'input';
291
+ };
292
+
293
+ // Add this element to the Map
294
+ currentMeta.clientActivityState.elements.set(componentId, {
295
+ domRef: formElementRef,
296
+ elementType: detectElementType(),
297
+ inputType: formElementRef.current?.type,
298
+ mountedAt: Date.now(),
299
+ });
300
+
301
+ setShadowMetadata(stateKey, path, currentMeta);
302
+
303
+ // Subscribe to path updates
251
304
  const unsubscribe = getGlobalStore
252
305
  .getState()
253
306
  .subscribeToPath(stateKeyPathKey, (newValue) => {
@@ -255,182 +308,28 @@ export function FormElementWrapper({
255
308
  forceUpdate({});
256
309
  }
257
310
  });
311
+
312
+ // Cleanup
258
313
  return () => {
259
314
  unsubscribe();
315
+
260
316
  if (debounceTimeoutRef.current) {
261
317
  clearTimeout(debounceTimeoutRef.current);
262
318
  isCurrentlyDebouncing.current = false;
263
319
  }
264
- };
265
- }, []);
266
320
 
267
- // Separate validation function that uses the field's schema
268
- const validateField = useCallback(
269
- (value: any, trigger: 'onChange' | 'onBlur') => {
270
- const rootMeta = getGlobalStore
271
- .getState()
272
- .getShadowMetadata(stateKey, []);
273
- if (!rootMeta?.features?.validationEnabled) return;
274
-
275
- const validationOptions = getInitialOptions(stateKey)?.validation;
276
-
277
- if (!validationOptions) return;
278
-
279
- const currentMeta = getShadowMetadata(stateKey, path) || {};
280
- const currentStatus = currentMeta?.validation?.status;
281
-
282
- let shouldValidate = false;
283
- let severity: 'error' | 'warning' | undefined;
284
- console.log('trigger', trigger, validationOptions);
285
- if (trigger === 'onBlur' && validationOptions.onBlur) {
286
- shouldValidate = true;
287
- severity = validationOptions.onBlur ?? 'error';
288
- } else if (trigger === 'onChange') {
289
- if (validationOptions.onChange) {
290
- shouldValidate = true;
291
- severity = validationOptions.onChange;
292
- } else if (currentStatus === 'INVALID') {
293
- shouldValidate = true;
294
- severity = 'warning';
295
- }
296
- }
297
-
298
- if (!shouldValidate) return;
299
-
300
- let validationResult: { success: boolean; message?: string } | null =
301
- null;
302
- console.log(
303
- 'shouldValidate 33',
304
- path,
305
- fieldSchema,
306
- shouldValidate,
307
- value,
308
- typeof value
309
- );
310
- if (fieldSchema && shouldValidate) {
311
- // Direct field validation using its own schema
312
- const result = fieldSchema.safeParse(value);
313
-
314
- if (!result.success) {
315
- const errors =
316
- 'issues' in result.error
317
- ? result.error.issues
318
- : (result.error as any).errors;
319
-
320
- validationResult = {
321
- success: false,
322
- message: errors[0]?.message || 'Invalid value',
323
- };
324
- } else {
325
- validationResult = { success: true };
326
- }
327
- } else {
328
- // Fallback: validate using the entire schema
329
- const zodSchema =
330
- validationOptions.zodSchemaV4 || validationOptions.zodSchemaV3;
331
- if (!zodSchema) return;
332
-
333
- // Create a test state with the new value at the correct path
334
- const fullState = getShadowValue(stateKey, []);
335
- const testState = JSON.parse(JSON.stringify(fullState)); // Deep clone
336
-
337
- // Set the value at the correct path
338
- let current = testState;
339
- for (let i = 0; i < path.length - 1; i++) {
340
- if (!current[path[i]!]) current[path[i]!] = {};
341
- current = current[path[i]!];
342
- }
343
- if (path.length > 0) {
344
- current[path[path.length - 1]!] = value;
345
- } else {
346
- // Root level update
347
- Object.assign(testState, value);
348
- }
349
-
350
- const result = zodSchema.safeParse(testState);
351
-
352
- if (!result.success) {
353
- const errors =
354
- 'issues' in result.error
355
- ? result.error.issues
356
- : (result.error as any).errors;
357
-
358
- // Find errors for this specific path
359
- const pathErrors = errors.filter((error: any) => {
360
- // Handle array paths with id: prefixes
361
- if (path.some((p) => p.startsWith('id:'))) {
362
- const parentPath = path[0]!.startsWith('id:')
363
- ? []
364
- : path.slice(0, -1);
365
- const arrayMeta = getGlobalStore
366
- .getState()
367
- .getShadowMetadata(stateKey, parentPath);
368
-
369
- if (arrayMeta?.arrayKeys) {
370
- const itemKey = path.slice(0, -1).join('.');
371
- const itemIndex = arrayMeta.arrayKeys.findIndex(
372
- (k) => k === path[path.length - 2]
373
- );
374
- const zodPath = [...parentPath, itemIndex, ...path.slice(-1)];
375
- return JSON.stringify(error.path) === JSON.stringify(zodPath);
376
- }
377
- }
378
-
379
- return JSON.stringify(error.path) === JSON.stringify(path);
380
- });
381
-
382
- if (pathErrors.length > 0) {
383
- validationResult = {
384
- success: false,
385
- message: pathErrors[0]?.message,
386
- };
387
- } else {
388
- validationResult = { success: true };
389
- }
390
- } else {
391
- validationResult = { success: true };
392
- }
321
+ // Remove element from Map
322
+ const meta = getGlobalStore.getState().getShadowMetadata(stateKey, path);
323
+ if (meta?.clientActivityState?.elements) {
324
+ meta.clientActivityState.elements.delete(componentId);
325
+ setShadowMetadata(stateKey, path, meta);
393
326
  }
394
-
395
- // Update validation state based on result
396
- if (validationResult) {
397
- if (!validationResult.success) {
398
- setShadowMetadata(stateKey, path, {
399
- ...currentMeta,
400
- validation: {
401
- status: 'INVALID',
402
- errors: [
403
- {
404
- source: 'client' as const,
405
- message: validationResult.message!,
406
- severity: severity!,
407
- },
408
- ],
409
- lastValidated: Date.now(),
410
- validatedValue: value,
411
- },
412
- });
413
- } else {
414
- setShadowMetadata(stateKey, path, {
415
- ...currentMeta,
416
- validation: {
417
- status: 'VALID',
418
- errors: [],
419
- lastValidated: Date.now(),
420
- validatedValue: value,
421
- },
422
- });
423
- }
424
- }
425
- forceUpdate({});
426
- },
427
- [stateKey, path, fieldSchema]
428
- );
327
+ };
328
+ }, []);
429
329
 
430
330
  const debouncedUpdate = useCallback(
431
331
  (newValue: any) => {
432
- // Use typeInfo to properly convert the value
433
-
332
+ // Type conversion logic (keep existing)
434
333
  if (typeInfo) {
435
334
  if (typeInfo.type === 'number' && typeof newValue === 'string') {
436
335
  newValue =
@@ -448,10 +347,7 @@ export function FormElementWrapper({
448
347
  newValue = new Date(newValue);
449
348
  }
450
349
  } else {
451
- // Fallback to old behavior if no typeInfo
452
-
453
350
  const currentType = typeof globalStateValue;
454
-
455
351
  if (currentType === 'number' && typeof newValue === 'string') {
456
352
  newValue = newValue === '' ? 0 : Number(newValue);
457
353
  }
@@ -459,45 +355,164 @@ export function FormElementWrapper({
459
355
 
460
356
  setLocalValue(newValue);
461
357
 
462
- // Validate immediately on change (will only run if configured or clearing errors)
463
- validateField(newValue, 'onChange');
358
+ // Update input activity details
359
+ const { getShadowMetadata, setShadowMetadata } =
360
+ getGlobalStore.getState();
361
+ const meta = getShadowMetadata(stateKey, path);
362
+ if (meta?.clientActivityState?.elements?.has(componentId)) {
363
+ const element = meta.clientActivityState.elements.get(componentId);
364
+ if (element && element.currentActivity?.type === 'focus') {
365
+ element!.currentActivity.details = {
366
+ ...element!.currentActivity.details,
367
+ value: newValue,
368
+ previousValue:
369
+ element!.currentActivity.details?.value || globalStateValue,
370
+ inputLength:
371
+ typeof newValue === 'string' ? newValue.length : undefined,
372
+ keystrokeCount:
373
+ (element!.currentActivity.details?.keystrokeCount || 0) + 1,
374
+ };
375
+ setShadowMetadata(stateKey, path, meta);
376
+ }
377
+ }
378
+ const element = meta?.clientActivityState?.elements?.get(componentId);
464
379
 
380
+ // Notify plugins
381
+ notifyFormUpdate({
382
+ stateKey,
383
+ activityType: 'input', // Changed from 'type'
384
+ path,
385
+ timestamp: Date.now(),
386
+ details: {
387
+ value: newValue,
388
+ inputLength:
389
+ typeof newValue === 'string' ? newValue.length : undefined,
390
+ isComposing: false, // You'd need to track this from the actual input event
391
+ isPasting: false, // You'd need to track this from paste events
392
+ keystrokeCount:
393
+ (element?.currentActivity?.details?.keystrokeCount || 0) + 1,
394
+ },
395
+ });
396
+ // Validation (keep existing)
397
+ const virtualOperation: UpdateTypeDetail = {
398
+ stateKey,
399
+ path,
400
+ newValue: newValue,
401
+ updateType: 'update',
402
+ timeStamp: Date.now(),
403
+ status: 'new',
404
+ oldValue: globalStateValue,
405
+ };
406
+ runValidation(virtualOperation, 'onChange');
407
+
408
+ // Debounce state update (keep existing)
465
409
  isCurrentlyDebouncing.current = true;
466
-
467
410
  if (debounceTimeoutRef.current) {
468
411
  clearTimeout(debounceTimeoutRef.current);
469
412
  }
470
413
 
471
414
  const debounceTime = formOpts?.debounceTime ?? 200;
472
-
473
- // Debounce only the state update, not the validation
474
415
  debounceTimeoutRef.current = setTimeout(() => {
475
416
  isCurrentlyDebouncing.current = false;
476
- setState(newValue, path, { updateType: 'update' });
417
+ setState(newValue, path, {
418
+ updateType: 'update',
419
+ validationTrigger: 'onChange',
420
+ });
477
421
  }, debounceTime);
478
422
  },
479
423
  [
480
424
  setState,
481
425
  path,
482
426
  formOpts?.debounceTime,
483
- validateField,
484
427
  typeInfo,
485
428
  globalStateValue,
429
+ stateKey,
430
+ componentId,
486
431
  ]
487
432
  );
488
433
 
434
+ const handleFocus = useCallback(() => {
435
+ const { getShadowMetadata, setShadowMetadata } = getGlobalStore.getState();
436
+
437
+ // Update element's current activity
438
+ const meta = getShadowMetadata(stateKey, path);
439
+ if (meta?.clientActivityState?.elements?.has(componentId)) {
440
+ const element = meta.clientActivityState.elements.get(componentId)!;
441
+ element.currentActivity = {
442
+ type: 'focus',
443
+ startTime: Date.now(),
444
+ details: {
445
+ value: localValue,
446
+ inputLength:
447
+ typeof localValue === 'string' ? localValue.length : undefined,
448
+ },
449
+ };
450
+ setShadowMetadata(stateKey, path, meta);
451
+ }
452
+
453
+ // Notify plugins
454
+ notifyFormUpdate({
455
+ stateKey,
456
+ activityType: 'focus', // Changed from 'type'
457
+ path,
458
+ timestamp: Date.now(),
459
+ details: {
460
+ cursorPosition: formElementRef.current?.selectionStart,
461
+ },
462
+ });
463
+ }, [stateKey, path, componentId, localValue]);
489
464
  const handleBlur = useCallback(() => {
490
- // Commit any pending changes immediately
465
+ const { getShadowMetadata, setShadowMetadata } = getGlobalStore.getState();
466
+
467
+ // Clear debounce if active
491
468
  if (debounceTimeoutRef.current) {
492
469
  clearTimeout(debounceTimeoutRef.current);
493
470
  debounceTimeoutRef.current = null;
494
471
  isCurrentlyDebouncing.current = false;
495
- setState(localValue, path, { updateType: 'update' });
472
+ setState(localValue, path, {
473
+ updateType: 'update',
474
+ validationTrigger: 'onBlur',
475
+ });
496
476
  }
497
477
 
498
- // Validate on blur
499
- validateField(localValue, 'onBlur');
500
- }, [localValue, setState, path, validateField]);
478
+ // Clear element's current activity
479
+ const meta = getShadowMetadata(stateKey, path);
480
+ if (meta?.clientActivityState?.elements?.has(componentId)) {
481
+ const element = meta.clientActivityState.elements.get(componentId)!;
482
+ element.currentActivity = undefined;
483
+ setShadowMetadata(stateKey, path, meta);
484
+ }
485
+ const focusStartTime =
486
+ meta?.clientActivityState?.elements?.get(componentId)?.currentActivity
487
+ ?.startTime;
488
+
489
+ // Notify plugins
490
+ notifyFormUpdate({
491
+ stateKey,
492
+ activityType: 'blur', // Changed from 'type'
493
+ path,
494
+ timestamp: Date.now(),
495
+ duration: focusStartTime ? Date.now() - focusStartTime : undefined,
496
+ details: {
497
+ duration: focusStartTime ? Date.now() - focusStartTime : 0,
498
+ },
499
+ });
500
+
501
+ // Run validation if configured
502
+ const validationOptions = getInitialOptions(stateKey)?.validation;
503
+ if (validationOptions?.onBlur) {
504
+ const virtualOperation: UpdateTypeDetail = {
505
+ stateKey,
506
+ path,
507
+ newValue: localValue,
508
+ updateType: 'update',
509
+ timeStamp: Date.now(),
510
+ status: 'new',
511
+ oldValue: globalStateValue,
512
+ };
513
+ runValidation(virtualOperation, 'onBlur');
514
+ }
515
+ }, [localValue, setState, path, stateKey, componentId, globalStateValue]);
501
516
 
502
517
  const baseState = rebuildStateShape({
503
518
  path: path,
@@ -513,10 +528,9 @@ export function FormElementWrapper({
513
528
  onChange: (e: any) => {
514
529
  debouncedUpdate(e.target.value);
515
530
  },
531
+ onFocus: handleFocus,
516
532
  onBlur: handleBlur,
517
- ref: formRefStore
518
- .getState()
519
- .getFormRef(stateKey + '.' + path.join('.')),
533
+ ref: formElementRef,
520
534
  };
521
535
  }
522
536
 
@@ -524,9 +538,25 @@ export function FormElementWrapper({
524
538
  },
525
539
  });
526
540
 
541
+ const initialElement = renderFn(stateWithInputProps);
542
+
543
+ const wrappedElement = activeFormWrappers.reduceRight(
544
+ (currentElement, config, index) => (
545
+ <PluginWrapper
546
+ stateKey={stateKey}
547
+ path={path}
548
+ pluginName={config.plugin.name}
549
+ wrapperDepth={activeFormWrappers.length - 1 - index}
550
+ >
551
+ {currentElement}
552
+ </PluginWrapper>
553
+ ),
554
+ initialElement
555
+ );
556
+
527
557
  return (
528
558
  <ValidationWrapper formOpts={formOpts} path={path} stateKey={stateKey}>
529
- {renderFn(stateWithInputProps)}
559
+ {wrappedElement}
530
560
  </ValidationWrapper>
531
561
  );
532
562
  }
@@ -637,3 +667,70 @@ export function IsolatedComponentWrapper({
637
667
 
638
668
  return <>{renderFn(baseState)}</>;
639
669
  }
670
+
671
+ // 1. Define the MINIMAL props needed.
672
+ type PluginWrapperProps = {
673
+ children: React.ReactNode;
674
+ stateKey: string;
675
+ path: string[];
676
+ pluginName: string;
677
+ wrapperDepth: number;
678
+ };
679
+
680
+ const PluginWrapper = memo(function PluginWrapper({
681
+ children,
682
+ stateKey,
683
+ path,
684
+ pluginName,
685
+ wrapperDepth,
686
+ }: PluginWrapperProps) {
687
+ const [, forceUpdate] = useState({});
688
+
689
+ useEffect(() => {
690
+ const fullPathKey = [stateKey, ...path].join('.');
691
+ const unsubscribe = getGlobalStore
692
+ .getState()
693
+ .subscribeToPath(fullPathKey, () => {
694
+ forceUpdate({});
695
+ });
696
+ return unsubscribe;
697
+ }, [stateKey, path]);
698
+
699
+ const plugin = pluginStore
700
+ .getState()
701
+ .registeredPlugins.find((p) => p.name === pluginName);
702
+
703
+ const stateHandler: StateObject<any> | undefined = pluginStore
704
+ .getState()
705
+ .stateHandlers.get(stateKey);
706
+
707
+ const typeInfo = getGlobalStore.getState().getShadowNode(stateKey, path)
708
+ ?._meta?.typeInfo;
709
+
710
+ const options = pluginStore
711
+ .getState()
712
+ .pluginOptions.get(stateKey)
713
+ ?.get(pluginName);
714
+
715
+ const hookData = pluginStore.getState().getHookResult(stateKey, pluginName);
716
+
717
+ if (!plugin?.formWrapper || !stateHandler) {
718
+ return <>{children}</>;
719
+ }
720
+
721
+ const metadataContext = createMetadataContext(stateKey, plugin.name);
722
+ const deconstructed = toDeconstructedMethods(stateHandler);
723
+
724
+ return plugin.formWrapper({
725
+ element: children,
726
+ path,
727
+ stateKey,
728
+ pluginName: plugin.name,
729
+ ...deconstructed,
730
+ ...metadataContext,
731
+ options,
732
+ hookData,
733
+ fieldType: typeInfo?.type,
734
+ wrapperDepth,
735
+ });
736
+ });