cogsbox-state 0.5.471 → 0.5.473

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 +2 -5
  2. package/dist/CogsState.d.ts +105 -79
  3. package/dist/CogsState.d.ts.map +1 -1
  4. package/dist/CogsState.jsx +1082 -987
  5. package/dist/CogsState.jsx.map +1 -1
  6. package/dist/Components.d.ts.map +1 -1
  7. package/dist/Components.jsx +293 -243
  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 +128 -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 +43 -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 +1326 -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 +69 -26
  26. package/dist/store.d.ts.map +1 -1
  27. package/dist/store.js +436 -152
  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 +18 -13
  38. package/src/CogsState.tsx +719 -458
  39. package/src/Components.tsx +304 -180
  40. package/src/PluginRunner.tsx +208 -0
  41. package/src/index.ts +2 -0
  42. package/src/pluginStore.ts +159 -0
  43. package/src/plugins.ts +548 -0
  44. package/src/store.ts +881 -189
  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[];
@@ -222,16 +229,32 @@ export function FormElementWrapper({
222
229
  formOpts?: FormOptsType;
223
230
  setState: any;
224
231
  }) {
225
- const [componentId] = useState(() => uuidv4());
226
- const [, forceUpdate] = useState({});
232
+ const componentId = useRef(uuidv4()).current;
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);
238
+ // Get the shadow node to access typeInfo and schema
239
+ const shadowNode = getGlobalStore.getState().getShadowNode(stateKey, path);
240
+ const typeInfo = shadowNode?._meta?.typeInfo;
241
+
230
242
  const globalStateValue = getShadowValue(stateKey, path);
231
243
  const [localValue, setLocalValue] = useState<any>(globalStateValue);
232
244
  const isCurrentlyDebouncing = useRef(false);
233
245
  const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
234
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
+
235
258
  useEffect(() => {
236
259
  if (
237
260
  !isCurrentlyDebouncing.current &&
@@ -242,6 +265,42 @@ export function FormElementWrapper({
242
265
  }, [globalStateValue]);
243
266
 
244
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
245
304
  const unsubscribe = getGlobalStore
246
305
  .getState()
247
306
  .subscribeToPath(stateKeyPathKey, (newValue) => {
@@ -249,208 +308,192 @@ export function FormElementWrapper({
249
308
  forceUpdate({});
250
309
  }
251
310
  });
311
+
312
+ // Cleanup
252
313
  return () => {
253
314
  unsubscribe();
315
+
254
316
  if (debounceTimeoutRef.current) {
255
317
  clearTimeout(debounceTimeoutRef.current);
256
318
  isCurrentlyDebouncing.current = false;
257
319
  }
320
+
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);
326
+ }
258
327
  };
259
328
  }, []);
260
329
 
261
330
  const debouncedUpdate = useCallback(
262
331
  (newValue: any) => {
263
- const currentType = typeof globalStateValue;
264
- if (currentType === 'number' && typeof newValue === 'string') {
265
- newValue = newValue === '' ? 0 : Number(newValue);
332
+ // Type conversion logic (keep existing)
333
+ if (typeInfo) {
334
+ if (typeInfo.type === 'number' && typeof newValue === 'string') {
335
+ newValue =
336
+ newValue === ''
337
+ ? typeInfo.nullable
338
+ ? null
339
+ : (typeInfo.default ?? 0)
340
+ : Number(newValue);
341
+ } else if (
342
+ typeInfo.type === 'boolean' &&
343
+ typeof newValue === 'string'
344
+ ) {
345
+ newValue = newValue === 'true' || newValue === '1';
346
+ } else if (typeInfo.type === 'date' && typeof newValue === 'string') {
347
+ newValue = new Date(newValue);
348
+ }
349
+ } else {
350
+ const currentType = typeof globalStateValue;
351
+ if (currentType === 'number' && typeof newValue === 'string') {
352
+ newValue = newValue === '' ? 0 : Number(newValue);
353
+ }
266
354
  }
355
+
267
356
  setLocalValue(newValue);
268
- isCurrentlyDebouncing.current = true;
269
357
 
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
+
379
+ // Notify plugins
380
+ notifyFormUpdate({
381
+ stateKey,
382
+ type: 'input',
383
+ path,
384
+ value: newValue,
385
+ });
386
+
387
+ // Validation (keep existing)
388
+ const virtualOperation: UpdateTypeDetail = {
389
+ stateKey,
390
+ path,
391
+ newValue: newValue,
392
+ updateType: 'update',
393
+ timeStamp: Date.now(),
394
+ status: 'new',
395
+ oldValue: globalStateValue,
396
+ };
397
+ runValidation(virtualOperation, 'onChange');
398
+
399
+ // Debounce state update (keep existing)
400
+ isCurrentlyDebouncing.current = true;
270
401
  if (debounceTimeoutRef.current) {
271
402
  clearTimeout(debounceTimeoutRef.current);
272
403
  }
273
404
 
274
405
  const debounceTime = formOpts?.debounceTime ?? 200;
275
-
276
406
  debounceTimeoutRef.current = setTimeout(() => {
277
407
  isCurrentlyDebouncing.current = false;
278
- setState(newValue, path, { updateType: 'update' });
279
-
280
- // NEW: Check if validation is enabled via features
281
- const rootMeta = getGlobalStore
282
- .getState()
283
- .getShadowMetadata(stateKey, []);
284
- if (!rootMeta?.features?.validationEnabled) return;
285
-
286
- const validationOptions = getInitialOptions(stateKey)?.validation;
287
- const zodSchema =
288
- validationOptions?.zodSchemaV4 || validationOptions?.zodSchemaV3;
289
-
290
- if (zodSchema) {
291
- const fullState = getShadowValue(stateKey, []);
292
- const result = zodSchema.safeParse(fullState);
293
- const currentMeta = getShadowMetadata(stateKey, path) || {};
294
-
295
- if (!result.success) {
296
- const errors =
297
- 'issues' in result.error
298
- ? result.error.issues
299
- : (result.error as any).errors;
300
-
301
- const pathErrors = errors.filter(
302
- (error: any) =>
303
- JSON.stringify(error.path) === JSON.stringify(path)
304
- );
305
-
306
- if (pathErrors.length > 0) {
307
- setShadowMetadata(stateKey, path, {
308
- ...currentMeta,
309
- validation: {
310
- status: 'INVALID',
311
- errors: [
312
- {
313
- source: 'client',
314
- message: pathErrors[0]?.message,
315
- severity: 'warning', // Gentle error during typing
316
- },
317
- ],
318
- lastValidated: Date.now(),
319
- validatedValue: newValue,
320
- },
321
- });
322
- } else {
323
- setShadowMetadata(stateKey, path, {
324
- ...currentMeta,
325
- validation: {
326
- status: 'VALID',
327
- errors: [],
328
- lastValidated: Date.now(),
329
- validatedValue: newValue,
330
- },
331
- });
332
- }
333
- } else {
334
- setShadowMetadata(stateKey, path, {
335
- ...currentMeta,
336
- validation: {
337
- status: 'VALID',
338
- errors: [],
339
- lastValidated: Date.now(),
340
- validatedValue: newValue,
341
- },
342
- });
343
- }
344
- }
408
+ setState(newValue, path, {
409
+ updateType: 'update',
410
+ validationTrigger: 'onChange',
411
+ });
345
412
  }, debounceTime);
346
- forceUpdate({});
347
413
  },
348
- [setState, path, formOpts?.debounceTime, stateKey]
414
+ [
415
+ setState,
416
+ path,
417
+ formOpts?.debounceTime,
418
+ typeInfo,
419
+ globalStateValue,
420
+ stateKey,
421
+ componentId,
422
+ ]
349
423
  );
350
424
 
351
- const handleBlur = useCallback(async () => {
352
- console.log('handleBlur triggered');
425
+ const handleFocus = useCallback(() => {
426
+ const { getShadowMetadata, setShadowMetadata } = getGlobalStore.getState();
427
+
428
+ // Update element's current activity
429
+ const meta = getShadowMetadata(stateKey, path);
430
+ if (meta?.clientActivityState?.elements?.has(componentId)) {
431
+ const element = meta.clientActivityState.elements.get(componentId)!;
432
+ element.currentActivity = {
433
+ type: 'focus',
434
+ startTime: Date.now(),
435
+ details: {
436
+ value: localValue,
437
+ inputLength:
438
+ typeof localValue === 'string' ? localValue.length : undefined,
439
+ },
440
+ };
441
+ setShadowMetadata(stateKey, path, meta);
442
+ }
443
+
444
+ // Notify plugins
445
+ notifyFormUpdate({
446
+ stateKey,
447
+ type: 'focus',
448
+ path,
449
+ value: localValue,
450
+ });
451
+ }, [stateKey, path, componentId, localValue]);
452
+ const handleBlur = useCallback(() => {
453
+ const { getShadowMetadata, setShadowMetadata } = getGlobalStore.getState();
353
454
 
354
- // Commit any pending changes
455
+ // Clear debounce if active
355
456
  if (debounceTimeoutRef.current) {
356
457
  clearTimeout(debounceTimeoutRef.current);
357
458
  debounceTimeoutRef.current = null;
358
459
  isCurrentlyDebouncing.current = false;
359
- setState(localValue, path, { updateType: 'update' });
460
+ setState(localValue, path, {
461
+ updateType: 'update',
462
+ validationTrigger: 'onBlur',
463
+ });
360
464
  }
361
- const rootMeta = getShadowMetadata(stateKey, []);
362
- if (!rootMeta?.features?.validationEnabled) return;
363
- const { getInitialOptions } = getGlobalStore.getState();
364
- const validationOptions = getInitialOptions(stateKey)?.validation;
365
- const zodSchema =
366
- validationOptions?.zodSchemaV4 || validationOptions?.zodSchemaV3;
367
-
368
- if (!zodSchema) return;
369
465
 
370
- // Get the full path including stateKey
371
-
372
- // Update validation state to "validating"
373
- const currentMeta = getShadowMetadata(stateKey, path);
466
+ // Clear element's current activity
467
+ const meta = getShadowMetadata(stateKey, path);
468
+ if (meta?.clientActivityState?.elements?.has(componentId)) {
469
+ const element = meta.clientActivityState.elements.get(componentId)!;
470
+ element.currentActivity = undefined;
471
+ setShadowMetadata(stateKey, path, meta);
472
+ }
374
473
 
375
- setShadowMetadata(stateKey, path, {
376
- ...currentMeta,
377
- validation: {
378
- status: 'VALIDATING',
379
- errors: [],
380
- lastValidated: Date.now(),
381
- validatedValue: localValue,
382
- },
474
+ // Notify plugins
475
+ notifyFormUpdate({
476
+ stateKey,
477
+ type: 'blur',
478
+ path,
479
+ value: localValue,
383
480
  });
384
481
 
385
- // Validate full state
386
- const fullState = getShadowValue(stateKey, []);
387
- const result = zodSchema.safeParse(fullState);
388
-
389
- if (!result.success) {
390
- const errors =
391
- 'issues' in result.error
392
- ? result.error.issues
393
- : (result.error as any).errors;
394
-
395
- // Find errors for this specific path
396
- const pathErrors = errors.filter((error: any) => {
397
- // For array paths, we need to translate indices to ULIDs
398
- if (path.some((p) => p.startsWith('id:'))) {
399
- // This is an array item path like ["id:xyz", "name"]
400
- const parentPath = path[0]!.startsWith('id:')
401
- ? []
402
- : path.slice(0, -1);
403
-
404
- const arrayMeta = getGlobalStore
405
- .getState()
406
- .getShadowMetadata(stateKey, parentPath);
407
-
408
- if (arrayMeta?.arrayKeys) {
409
- const itemKey = [stateKey, ...path.slice(0, -1)].join('.');
410
- const itemIndex = arrayMeta.arrayKeys.indexOf(itemKey);
411
-
412
- // Compare with Zod path
413
- const zodPath = [...parentPath, itemIndex, ...path.slice(-1)];
414
- const match =
415
- JSON.stringify(error.path) === JSON.stringify(zodPath);
416
-
417
- return match;
418
- }
419
- }
420
-
421
- const directMatch = JSON.stringify(error.path) === JSON.stringify(path);
422
-
423
- return directMatch;
424
- });
425
-
426
- // Update shadow metadata with validation result
427
- setShadowMetadata(stateKey, path, {
428
- ...currentMeta,
429
- validation: {
430
- status: 'INVALID',
431
- errors: pathErrors.map((err: any) => ({
432
- source: 'client' as const,
433
- message: err.message,
434
- severity: 'error' as const, // Hard error on blur
435
- })),
436
- lastValidated: Date.now(),
437
- validatedValue: localValue,
438
- },
439
- });
440
- } else {
441
- // Validation passed
442
- setShadowMetadata(stateKey, path, {
443
- ...currentMeta,
444
- validation: {
445
- status: 'VALID',
446
- errors: [],
447
- lastValidated: Date.now(),
448
- validatedValue: localValue,
449
- },
450
- });
482
+ // Run validation if configured
483
+ const validationOptions = getInitialOptions(stateKey)?.validation;
484
+ if (validationOptions?.onBlur) {
485
+ const virtualOperation: UpdateTypeDetail = {
486
+ stateKey,
487
+ path,
488
+ newValue: localValue,
489
+ updateType: 'update',
490
+ timeStamp: Date.now(),
491
+ status: 'new',
492
+ oldValue: globalStateValue,
493
+ };
494
+ runValidation(virtualOperation, 'onBlur');
451
495
  }
452
- forceUpdate({});
453
- }, [stateKey, path, localValue, setState]);
496
+ }, [localValue, setState, path, stateKey, componentId, globalStateValue]);
454
497
 
455
498
  const baseState = rebuildStateShape({
456
499
  path: path,
@@ -466,11 +509,9 @@ export function FormElementWrapper({
466
509
  onChange: (e: any) => {
467
510
  debouncedUpdate(e.target.value);
468
511
  },
469
- // 5. Wire the new onBlur handler to the input props.
512
+ onFocus: handleFocus,
470
513
  onBlur: handleBlur,
471
- ref: formRefStore
472
- .getState()
473
- .getFormRef(stateKey + '.' + path.join('.')),
514
+ ref: formElementRef,
474
515
  };
475
516
  }
476
517
 
@@ -478,9 +519,25 @@ export function FormElementWrapper({
478
519
  },
479
520
  });
480
521
 
522
+ const initialElement = renderFn(stateWithInputProps);
523
+
524
+ const wrappedElement = activeFormWrappers.reduceRight(
525
+ (currentElement, config, index) => (
526
+ <PluginWrapper
527
+ stateKey={stateKey}
528
+ path={path}
529
+ pluginName={config.plugin.name}
530
+ wrapperDepth={activeFormWrappers.length - 1 - index}
531
+ >
532
+ {currentElement}
533
+ </PluginWrapper>
534
+ ),
535
+ initialElement
536
+ );
537
+
481
538
  return (
482
539
  <ValidationWrapper formOpts={formOpts} path={path} stateKey={stateKey}>
483
- {renderFn(stateWithInputProps)}
540
+ {wrappedElement}
484
541
  </ValidationWrapper>
485
542
  );
486
543
  }
@@ -591,3 +648,70 @@ export function IsolatedComponentWrapper({
591
648
 
592
649
  return <>{renderFn(baseState)}</>;
593
650
  }
651
+
652
+ // 1. Define the MINIMAL props needed.
653
+ type PluginWrapperProps = {
654
+ children: React.ReactNode;
655
+ stateKey: string;
656
+ path: string[];
657
+ pluginName: string;
658
+ wrapperDepth: number;
659
+ };
660
+
661
+ const PluginWrapper = memo(function PluginWrapper({
662
+ children,
663
+ stateKey,
664
+ path,
665
+ pluginName,
666
+ wrapperDepth,
667
+ }: PluginWrapperProps) {
668
+ const [, forceUpdate] = useState({});
669
+
670
+ useEffect(() => {
671
+ const fullPathKey = [stateKey, ...path].join('.');
672
+ const unsubscribe = getGlobalStore
673
+ .getState()
674
+ .subscribeToPath(fullPathKey, () => {
675
+ forceUpdate({});
676
+ });
677
+ return unsubscribe;
678
+ }, [stateKey, path]);
679
+
680
+ const plugin = pluginStore
681
+ .getState()
682
+ .registeredPlugins.find((p) => p.name === pluginName);
683
+
684
+ const stateHandler: StateObject<any> | undefined = pluginStore
685
+ .getState()
686
+ .stateHandlers.get(stateKey);
687
+
688
+ const typeInfo = getGlobalStore.getState().getShadowNode(stateKey, path)
689
+ ?._meta?.typeInfo;
690
+
691
+ const options = pluginStore
692
+ .getState()
693
+ .pluginOptions.get(stateKey)
694
+ ?.get(pluginName);
695
+
696
+ const hookData = pluginStore.getState().getHookResult(stateKey, pluginName);
697
+
698
+ if (!plugin?.formWrapper || !stateHandler) {
699
+ return <>{children}</>;
700
+ }
701
+
702
+ const metadataContext = createMetadataContext(stateKey, plugin.name);
703
+ const deconstructed = toDeconstructedMethods(stateHandler);
704
+
705
+ return plugin.formWrapper({
706
+ element: children,
707
+ path,
708
+ stateKey,
709
+ pluginName: plugin.name,
710
+ ...deconstructed,
711
+ ...metadataContext,
712
+ options,
713
+ hookData,
714
+ fieldType: typeInfo?.type,
715
+ wrapperDepth,
716
+ });
717
+ });