cogsbox-state 0.5.465 → 0.5.466

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,541 @@
1
+ import { FormElementParams, type FormOptsType } from './CogsState';
2
+ import React, {
3
+ memo,
4
+ RefObject,
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { formRefStore, getGlobalStore, ValidationError } from './store';
12
+ import { useInView } from 'react-intersection-observer';
13
+ import { v4 as uuidv4 } from 'uuid';
14
+ import { isDeepEqual } from './utility';
15
+ const {
16
+ getInitialOptions,
17
+
18
+ getShadowMetadata,
19
+ setShadowMetadata,
20
+ getShadowValue,
21
+
22
+ registerComponent,
23
+ unregisterComponent,
24
+
25
+ notifyPathSubscribers,
26
+ subscribeToPath,
27
+ } = getGlobalStore.getState();
28
+ export type ValidationWrapperProps = {
29
+ formOpts?: FormOptsType;
30
+ path: string[];
31
+ stateKey: string;
32
+ children: React.ReactNode;
33
+ };
34
+
35
+ export function ValidationWrapper({
36
+ formOpts,
37
+ path,
38
+ stateKey,
39
+ children,
40
+ }: ValidationWrapperProps) {
41
+ const { getInitialOptions, getShadowMetadata, getShadowValue } =
42
+ getGlobalStore.getState();
43
+ const thisStateOpts = getInitialOptions(stateKey!);
44
+
45
+ const shadowMeta = getShadowMetadata(stateKey!, path);
46
+ const validationState = shadowMeta?.validation;
47
+
48
+ const status = validationState?.status || 'NOT_VALIDATED';
49
+
50
+ const errors = (validationState?.errors || []).map((err) => ({
51
+ ...err,
52
+ path: path,
53
+ })) as ValidationError[];
54
+ const errorMessages = errors
55
+ .filter((err) => err.severity === 'error')
56
+ .map((err) => err.message);
57
+ const warningMessages = errors
58
+ .filter((err) => err.severity === 'warning')
59
+ .map((err) => err.message);
60
+
61
+ // Use first error, or first warning if no errors
62
+ const message = errorMessages[0] || warningMessages[0];
63
+
64
+ return (
65
+ <>
66
+ {thisStateOpts?.formElements?.validation &&
67
+ !formOpts?.validation?.disable ? (
68
+ thisStateOpts.formElements!.validation!({
69
+ children: (
70
+ <React.Fragment key={path.toString()}>{children}</React.Fragment>
71
+ ),
72
+ status, // Now passes the new ValidationStatus type
73
+ message: formOpts?.validation?.hideMessage
74
+ ? ''
75
+ : formOpts?.validation?.message || message || '',
76
+
77
+ hasErrors: errorMessages.length > 0,
78
+ hasWarnings: warningMessages.length > 0,
79
+ allErrors: errors,
80
+ path: path,
81
+ getData: () => getShadowValue(stateKey!, path),
82
+ })
83
+ ) : (
84
+ <React.Fragment key={path.toString()}>{children}</React.Fragment>
85
+ )}
86
+ </>
87
+ );
88
+ }
89
+ export const MemoizedCogsItemWrapper = memo(
90
+ ListItemWrapper,
91
+ (prevProps, nextProps) => {
92
+ // Re-render if any of these change:
93
+ return (
94
+ prevProps.itemPath.join('.') === nextProps.itemPath.join('.') &&
95
+ prevProps.stateKey === nextProps.stateKey &&
96
+ prevProps.itemComponentId === nextProps.itemComponentId &&
97
+ prevProps.localIndex === nextProps.localIndex
98
+ );
99
+ }
100
+ );
101
+ export function ListItemWrapper({
102
+ stateKey,
103
+ itemComponentId,
104
+ itemPath,
105
+ localIndex,
106
+ arraySetter,
107
+ rebuildStateShape,
108
+ renderFn,
109
+ }: {
110
+ stateKey: string;
111
+ itemComponentId: string;
112
+ itemPath: string[];
113
+ localIndex: number;
114
+ arraySetter: any;
115
+
116
+ rebuildStateShape: (options: {
117
+ currentState: any;
118
+ path: string[];
119
+ componentId: string;
120
+ meta?: any;
121
+ }) => any;
122
+ renderFn: (
123
+ setter: any,
124
+ index: number,
125
+
126
+ arraySetter: any
127
+ ) => React.ReactNode;
128
+ }) {
129
+ const [, forceUpdate] = useState({});
130
+ const { ref: inViewRef, inView } = useInView();
131
+ const elementRef = useRef<HTMLDivElement | null>(null);
132
+
133
+ const imagesLoaded = useImageLoaded(elementRef);
134
+ const hasReportedInitialHeight = useRef(false);
135
+ const fullKey = [stateKey, ...itemPath].join('.');
136
+ useRegisterComponent(stateKey, itemComponentId, forceUpdate);
137
+
138
+ const setRefs = useCallback(
139
+ (element: HTMLDivElement | null) => {
140
+ elementRef.current = element;
141
+ inViewRef(element); // This is the ref from useInView
142
+ },
143
+ [inViewRef]
144
+ );
145
+
146
+ useEffect(() => {
147
+ const unsubscribe = subscribeToPath(fullKey, (e) => {
148
+ forceUpdate({});
149
+ });
150
+ return () => unsubscribe();
151
+ }, [fullKey]);
152
+ useEffect(() => {
153
+ if (!inView || !imagesLoaded || hasReportedInitialHeight.current) {
154
+ return;
155
+ }
156
+
157
+ const element = elementRef.current;
158
+ if (element && element.offsetHeight > 0) {
159
+ hasReportedInitialHeight.current = true;
160
+ const newHeight = element.offsetHeight;
161
+
162
+ setShadowMetadata(stateKey, itemPath, {
163
+ virtualizer: {
164
+ itemHeight: newHeight,
165
+ domRef: element,
166
+ },
167
+ });
168
+
169
+ const arrayPath = itemPath.slice(0, -1);
170
+ const arrayPathKey = [stateKey, ...arrayPath].join('.');
171
+ notifyPathSubscribers(arrayPathKey, {
172
+ type: 'ITEMHEIGHT',
173
+ itemKey: itemPath.join('.'),
174
+
175
+ ref: elementRef.current,
176
+ });
177
+ }
178
+ }, [inView, imagesLoaded, stateKey, itemPath]);
179
+
180
+ const itemValue = getShadowValue(stateKey, itemPath);
181
+
182
+ if (itemValue === undefined) {
183
+ return null;
184
+ }
185
+
186
+ const itemSetter = rebuildStateShape({
187
+ currentState: itemValue,
188
+ path: itemPath,
189
+ componentId: itemComponentId,
190
+ });
191
+ const children = renderFn(itemSetter, localIndex, arraySetter);
192
+
193
+ return <div ref={setRefs}>{children}</div>;
194
+ }
195
+
196
+ export function FormElementWrapper({
197
+ stateKey,
198
+ path,
199
+ rebuildStateShape,
200
+ renderFn,
201
+ formOpts,
202
+ setState,
203
+ }: {
204
+ stateKey: string;
205
+ path: string[];
206
+ rebuildStateShape: (options: {
207
+ path: string[];
208
+ componentId: string;
209
+ meta?: any;
210
+ }) => any;
211
+ renderFn: (params: FormElementParams<any>) => React.ReactNode;
212
+ formOpts?: FormOptsType;
213
+ setState: any;
214
+ }) {
215
+ const [componentId] = useState(() => uuidv4());
216
+ const [, forceUpdate] = useState({});
217
+
218
+ const stateKeyPathKey = [stateKey, ...path].join('.');
219
+ useRegisterComponent(stateKey, componentId, forceUpdate);
220
+ const globalStateValue = getShadowValue(stateKey, path);
221
+ const [localValue, setLocalValue] = useState<any>(globalStateValue);
222
+ const isCurrentlyDebouncing = useRef(false);
223
+ const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
224
+
225
+ useEffect(() => {
226
+ if (
227
+ !isCurrentlyDebouncing.current &&
228
+ !isDeepEqual(globalStateValue, localValue)
229
+ ) {
230
+ setLocalValue(globalStateValue);
231
+ }
232
+ }, [globalStateValue]);
233
+
234
+ useEffect(() => {
235
+ const unsubscribe = getGlobalStore
236
+ .getState()
237
+ .subscribeToPath(stateKeyPathKey, (newValue) => {
238
+ if (!isCurrentlyDebouncing.current && localValue !== newValue) {
239
+ forceUpdate({});
240
+ }
241
+ });
242
+ return () => {
243
+ unsubscribe();
244
+ if (debounceTimeoutRef.current) {
245
+ clearTimeout(debounceTimeoutRef.current);
246
+ isCurrentlyDebouncing.current = false;
247
+ }
248
+ };
249
+ }, []);
250
+
251
+ const debouncedUpdate = useCallback(
252
+ (newValue: any) => {
253
+ const currentType = typeof globalStateValue;
254
+ if (currentType === 'number' && typeof newValue === 'string') {
255
+ newValue = newValue === '' ? 0 : Number(newValue);
256
+ }
257
+ setLocalValue(newValue);
258
+ isCurrentlyDebouncing.current = true;
259
+
260
+ if (debounceTimeoutRef.current) {
261
+ clearTimeout(debounceTimeoutRef.current);
262
+ }
263
+
264
+ const debounceTime = formOpts?.debounceTime ?? 200;
265
+
266
+ debounceTimeoutRef.current = setTimeout(() => {
267
+ isCurrentlyDebouncing.current = false;
268
+ setState(newValue, path, { updateType: 'update' });
269
+
270
+ // NEW: Check if validation is enabled via features
271
+ const rootMeta = getGlobalStore
272
+ .getState()
273
+ .getShadowMetadata(stateKey, []);
274
+ if (!rootMeta?.features?.validationEnabled) return;
275
+
276
+ const validationOptions = getInitialOptions(stateKey)?.validation;
277
+ const zodSchema =
278
+ validationOptions?.zodSchemaV4 || validationOptions?.zodSchemaV3;
279
+
280
+ if (zodSchema) {
281
+ const fullState = getShadowValue(stateKey, []);
282
+ const result = zodSchema.safeParse(fullState);
283
+ const currentMeta = getShadowMetadata(stateKey, path) || {};
284
+
285
+ if (!result.success) {
286
+ const errors =
287
+ 'issues' in result.error
288
+ ? result.error.issues
289
+ : (result.error as any).errors;
290
+
291
+ const pathErrors = errors.filter(
292
+ (error: any) =>
293
+ JSON.stringify(error.path) === JSON.stringify(path)
294
+ );
295
+
296
+ if (pathErrors.length > 0) {
297
+ setShadowMetadata(stateKey, path, {
298
+ ...currentMeta,
299
+ validation: {
300
+ status: 'INVALID',
301
+ errors: [
302
+ {
303
+ source: 'client',
304
+ message: pathErrors[0]?.message,
305
+ severity: 'warning', // Gentle error during typing
306
+ },
307
+ ],
308
+ lastValidated: Date.now(),
309
+ validatedValue: newValue,
310
+ },
311
+ });
312
+ } else {
313
+ setShadowMetadata(stateKey, path, {
314
+ ...currentMeta,
315
+ validation: {
316
+ status: 'VALID',
317
+ errors: [],
318
+ lastValidated: Date.now(),
319
+ validatedValue: newValue,
320
+ },
321
+ });
322
+ }
323
+ } else {
324
+ setShadowMetadata(stateKey, path, {
325
+ ...currentMeta,
326
+ validation: {
327
+ status: 'VALID',
328
+ errors: [],
329
+ lastValidated: Date.now(),
330
+ validatedValue: newValue,
331
+ },
332
+ });
333
+ }
334
+ }
335
+ }, debounceTime);
336
+ forceUpdate({});
337
+ },
338
+ [setState, path, formOpts?.debounceTime, stateKey]
339
+ );
340
+
341
+ const handleBlur = useCallback(async () => {
342
+ console.log('handleBlur triggered');
343
+
344
+ // Commit any pending changes
345
+ if (debounceTimeoutRef.current) {
346
+ clearTimeout(debounceTimeoutRef.current);
347
+ debounceTimeoutRef.current = null;
348
+ isCurrentlyDebouncing.current = false;
349
+ setState(localValue, path, { updateType: 'update' });
350
+ }
351
+ const rootMeta = getShadowMetadata(stateKey, []);
352
+ if (!rootMeta?.features?.validationEnabled) return;
353
+ const { getInitialOptions } = getGlobalStore.getState();
354
+ const validationOptions = getInitialOptions(stateKey)?.validation;
355
+ const zodSchema =
356
+ validationOptions?.zodSchemaV4 || validationOptions?.zodSchemaV3;
357
+
358
+ if (!zodSchema) return;
359
+
360
+ // Get the full path including stateKey
361
+
362
+ // Update validation state to "validating"
363
+ const currentMeta = getShadowMetadata(stateKey, path);
364
+
365
+ setShadowMetadata(stateKey, path, {
366
+ ...currentMeta,
367
+ validation: {
368
+ status: 'VALIDATING',
369
+ errors: [],
370
+ lastValidated: Date.now(),
371
+ validatedValue: localValue,
372
+ },
373
+ });
374
+
375
+ // Validate full state
376
+ const fullState = getShadowValue(stateKey, []);
377
+ const result = zodSchema.safeParse(fullState);
378
+
379
+ if (!result.success) {
380
+ const errors =
381
+ 'issues' in result.error
382
+ ? result.error.issues
383
+ : (result.error as any).errors;
384
+
385
+ // Find errors for this specific path
386
+ const pathErrors = errors.filter((error: any) => {
387
+ // For array paths, we need to translate indices to ULIDs
388
+ if (path.some((p) => p.startsWith('id:'))) {
389
+ // This is an array item path like ["id:xyz", "name"]
390
+ const parentPath = path[0]!.startsWith('id:')
391
+ ? []
392
+ : path.slice(0, -1);
393
+
394
+ const arrayMeta = getGlobalStore
395
+ .getState()
396
+ .getShadowMetadata(stateKey, parentPath);
397
+
398
+ if (arrayMeta?.arrayKeys) {
399
+ const itemKey = [stateKey, ...path.slice(0, -1)].join('.');
400
+ const itemIndex = arrayMeta.arrayKeys.indexOf(itemKey);
401
+
402
+ // Compare with Zod path
403
+ const zodPath = [...parentPath, itemIndex, ...path.slice(-1)];
404
+ const match =
405
+ JSON.stringify(error.path) === JSON.stringify(zodPath);
406
+
407
+ return match;
408
+ }
409
+ }
410
+
411
+ const directMatch = JSON.stringify(error.path) === JSON.stringify(path);
412
+
413
+ return directMatch;
414
+ });
415
+
416
+ // Update shadow metadata with validation result
417
+ setShadowMetadata(stateKey, path, {
418
+ ...currentMeta,
419
+ validation: {
420
+ status: 'INVALID',
421
+ errors: pathErrors.map((err: any) => ({
422
+ source: 'client' as const,
423
+ message: err.message,
424
+ severity: 'error' as const, // Hard error on blur
425
+ })),
426
+ lastValidated: Date.now(),
427
+ validatedValue: localValue,
428
+ },
429
+ });
430
+ } else {
431
+ // Validation passed
432
+ setShadowMetadata(stateKey, path, {
433
+ ...currentMeta,
434
+ validation: {
435
+ status: 'VALID',
436
+ errors: [],
437
+ lastValidated: Date.now(),
438
+ validatedValue: localValue,
439
+ },
440
+ });
441
+ }
442
+ forceUpdate({});
443
+ }, [stateKey, path, localValue, setState]);
444
+
445
+ const baseState = rebuildStateShape({
446
+ path: path,
447
+ componentId: componentId,
448
+ });
449
+
450
+ const stateWithInputProps = new Proxy(baseState, {
451
+ get(target, prop) {
452
+ if (prop === 'inputProps') {
453
+ return {
454
+ value: localValue ?? '',
455
+ onChange: (e: any) => {
456
+ debouncedUpdate(e.target.value);
457
+ },
458
+ // 5. Wire the new onBlur handler to the input props.
459
+ onBlur: handleBlur,
460
+ ref: formRefStore
461
+ .getState()
462
+ .getFormRef(stateKey + '.' + path.join('.')),
463
+ };
464
+ }
465
+
466
+ return target[prop];
467
+ },
468
+ });
469
+
470
+ return (
471
+ <ValidationWrapper formOpts={formOpts} path={path} stateKey={stateKey}>
472
+ {renderFn(stateWithInputProps)}
473
+ </ValidationWrapper>
474
+ );
475
+ }
476
+ export function useRegisterComponent(
477
+ stateKey: string,
478
+ componentId: string,
479
+ forceUpdate: (o: object) => void
480
+ ) {
481
+ const fullComponentId = `${stateKey}////${componentId}`;
482
+
483
+ useLayoutEffect(() => {
484
+ // Call the safe, centralized function to register
485
+ registerComponent(stateKey, fullComponentId, {
486
+ forceUpdate: () => forceUpdate({}),
487
+ paths: new Set(),
488
+ reactiveType: ['component'],
489
+ });
490
+
491
+ // The cleanup now calls the safe, centralized unregister function
492
+ return () => {
493
+ unregisterComponent(stateKey, fullComponentId);
494
+ };
495
+ }, [stateKey, fullComponentId]); // Dependencies are stable and correct
496
+ }
497
+
498
+ const useImageLoaded = (ref: RefObject<HTMLElement>): boolean => {
499
+ const [loaded, setLoaded] = useState(false);
500
+
501
+ useLayoutEffect(() => {
502
+ if (!ref.current) {
503
+ setLoaded(true);
504
+ return;
505
+ }
506
+
507
+ const images = Array.from(ref.current.querySelectorAll('img'));
508
+
509
+ // If there are no images, we are "loaded" immediately.
510
+ if (images.length === 0) {
511
+ setLoaded(true);
512
+ return;
513
+ }
514
+
515
+ let loadedCount = 0;
516
+ const handleImageLoad = () => {
517
+ loadedCount++;
518
+ if (loadedCount === images.length) {
519
+ setLoaded(true);
520
+ }
521
+ };
522
+
523
+ images.forEach((image) => {
524
+ if (image.complete) {
525
+ handleImageLoad();
526
+ } else {
527
+ image.addEventListener('load', handleImageLoad);
528
+ image.addEventListener('error', handleImageLoad);
529
+ }
530
+ });
531
+
532
+ return () => {
533
+ images.forEach((image) => {
534
+ image.removeEventListener('load', handleImageLoad);
535
+ image.removeEventListener('error', handleImageLoad);
536
+ });
537
+ };
538
+ }, [ref.current]);
539
+
540
+ return loaded;
541
+ };