@ic-reactor/candid 3.0.12-beta.0 → 3.0.13-beta.0

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.
@@ -16,17 +16,115 @@ import type {
16
16
  UnknownField,
17
17
  ArgumentsMeta,
18
18
  ArgumentsServiceMeta,
19
+ RenderHint,
20
+ PrimitiveInputProps,
21
+ BlobLimits,
22
+ BlobValidationResult,
19
23
  } from "./types"
20
24
 
21
25
  import { IDL } from "@icp-sdk/core/candid"
22
26
  import { Principal } from "@icp-sdk/core/principal"
23
27
  import { BaseActor, FunctionName } from "@ic-reactor/core"
24
28
  import * as z from "zod"
29
+ import { formatLabel } from "./helpers"
25
30
 
26
31
  export * from "./types"
27
32
 
33
+ // ════════════════════════════════════════════════════════════════════════════
34
+ // Render Hint Helpers
35
+ // ════════════════════════════════════════════════════════════════════════════
36
+
37
+ const COMPOUND_RENDER_HINT: RenderHint = {
38
+ isCompound: true,
39
+ isPrimitive: false,
40
+ }
41
+
42
+ const TEXT_RENDER_HINT: RenderHint = {
43
+ isCompound: false,
44
+ isPrimitive: true,
45
+ inputType: "text",
46
+ }
47
+
48
+ const NUMBER_RENDER_HINT: RenderHint = {
49
+ isCompound: false,
50
+ isPrimitive: true,
51
+ inputType: "number",
52
+ }
53
+
54
+ const CHECKBOX_RENDER_HINT: RenderHint = {
55
+ isCompound: false,
56
+ isPrimitive: true,
57
+ inputType: "checkbox",
58
+ }
59
+
60
+ const FILE_RENDER_HINT: RenderHint = {
61
+ isCompound: false,
62
+ isPrimitive: true,
63
+ inputType: "file",
64
+ }
65
+
66
+ // ════════════════════════════════════════════════════════════════════════════
67
+ // Blob Field Helpers
68
+ // ════════════════════════════════════════════════════════════════════════════
69
+
70
+ const DEFAULT_BLOB_LIMITS: BlobLimits = {
71
+ maxHexBytes: 512,
72
+ maxFileBytes: 2 * 1024 * 1024, // 2MB
73
+ maxHexDisplayLength: 128,
74
+ }
75
+
76
+ function normalizeHex(input: string): string {
77
+ // Remove 0x prefix and convert to lowercase
78
+ let hex = input.toLowerCase()
79
+ if (hex.startsWith("0x")) {
80
+ hex = hex.slice(2)
81
+ }
82
+ // Remove any whitespace
83
+ hex = hex.replace(/\s/g, "")
84
+ return hex
85
+ }
86
+
87
+ function validateBlobInput(
88
+ value: string | Uint8Array,
89
+ limits: BlobLimits
90
+ ): BlobValidationResult {
91
+ if (value instanceof Uint8Array) {
92
+ if (value.length > limits.maxFileBytes) {
93
+ return {
94
+ valid: false,
95
+ error: `File size exceeds maximum of ${limits.maxFileBytes} bytes`,
96
+ }
97
+ }
98
+ return { valid: true }
99
+ }
100
+
101
+ // String input (hex)
102
+ const normalized = normalizeHex(value)
103
+ if (normalized.length === 0) {
104
+ return { valid: true } // Empty is valid
105
+ }
106
+
107
+ if (!/^[0-9a-f]*$/.test(normalized)) {
108
+ return { valid: false, error: "Invalid hex characters" }
109
+ }
110
+
111
+ if (normalized.length % 2 !== 0) {
112
+ return { valid: false, error: "Hex string must have even length" }
113
+ }
114
+
115
+ const byteLength = normalized.length / 2
116
+ if (byteLength > limits.maxHexBytes) {
117
+ return {
118
+ valid: false,
119
+ error: `Hex input exceeds maximum of ${limits.maxHexBytes} bytes`,
120
+ }
121
+ }
122
+
123
+ return { valid: true }
124
+ }
125
+
28
126
  /**
29
- * ArgumentFieldVisitor generates metadata for form input fields from Candid IDL types.
127
+ * FieldVisitor generates metadata for form input fields from Candid IDL types.
30
128
  *
31
129
  * ## Design Principles
32
130
  *
@@ -40,8 +138,11 @@ export * from "./types"
40
138
  *
41
139
  * Each field has:
42
140
  * - `type`: The field type (record, variant, text, number, etc.)
43
- * - `label`: Human-readable label from Candid
141
+ * - `label`: Raw label from Candid
142
+ * - `displayLabel`: Human-readable formatted label
44
143
  * - `name`: TanStack Form compatible path (e.g., "[0]", "[0].owner", "tags[1]")
144
+ * - `component`: Suggested component type for rendering
145
+ * - `renderHint`: Hints for UI rendering strategy
45
146
  * - `defaultValue`: Initial value for the form
46
147
  * - `schema`: Zod schema for validation
47
148
  * - Type-specific properties (options for variant, fields for record, etc.)
@@ -52,9 +153,9 @@ export * from "./types"
52
153
  * @example
53
154
  * ```typescript
54
155
  * import { useForm } from '@tanstack/react-form'
55
- * import { ArgumentFieldVisitor } from '@ic-reactor/candid'
156
+ * import { FieldVisitor } from '@ic-reactor/candid'
56
157
  *
57
- * const visitor = new ArgumentFieldVisitor()
158
+ * const visitor = new FieldVisitor()
58
159
  * const serviceMeta = service.accept(visitor, null)
59
160
  * const methodMeta = serviceMeta["icrc1_transfer"]
60
161
  *
@@ -74,7 +175,7 @@ export * from "./types"
74
175
  * ))
75
176
  * ```
76
177
  */
77
- export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
178
+ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
78
179
  string,
79
180
  Field | ArgumentsMeta<A> | ArgumentsServiceMeta<A>
80
181
  > {
@@ -192,7 +293,10 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
192
293
  return {
193
294
  type: "record",
194
295
  label,
296
+ displayLabel: formatLabel(label),
195
297
  name,
298
+ component: "record-container",
299
+ renderHint: COMPOUND_RENDER_HINT,
196
300
  fields,
197
301
  fieldMap,
198
302
  defaultValue,
@@ -240,10 +344,34 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
240
344
  return { [option]: optField.defaultValue }
241
345
  }
242
346
 
347
+ // Helper to get field for a specific option
348
+ const getField = (option: string): Field => {
349
+ const optField = optionMap.get(option)
350
+ if (!optField) {
351
+ throw new Error(`Unknown variant option: ${option}`)
352
+ }
353
+ return optField
354
+ }
355
+
356
+ // Helper to get currently selected option from a value
357
+ const getSelectedOption = (value: Record<string, unknown>): string => {
358
+ const validKeys = Object.keys(value).filter((k) => options.includes(k))
359
+ return validKeys[0] ?? defaultOption
360
+ }
361
+
362
+ // Helper to get selected field from a value
363
+ const getSelectedField = (value: Record<string, unknown>): Field => {
364
+ const selectedOption = getSelectedOption(value)
365
+ return getField(selectedOption)
366
+ }
367
+
243
368
  return {
244
369
  type: "variant",
245
370
  label,
371
+ displayLabel: formatLabel(label),
246
372
  name,
373
+ component: "variant-select",
374
+ renderHint: COMPOUND_RENDER_HINT,
247
375
  fields,
248
376
  options,
249
377
  defaultOption,
@@ -251,6 +379,9 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
251
379
  defaultValue,
252
380
  schema,
253
381
  getOptionDefault,
382
+ getField,
383
+ getSelectedOption,
384
+ getSelectedField,
254
385
  candidType: "variant",
255
386
  }
256
387
  }
@@ -281,7 +412,10 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
281
412
  return {
282
413
  type: "tuple",
283
414
  label,
415
+ displayLabel: formatLabel(label),
284
416
  name,
417
+ component: "tuple-container",
418
+ renderHint: COMPOUND_RENDER_HINT,
285
419
  fields,
286
420
  defaultValue,
287
421
  schema,
@@ -310,14 +444,23 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
310
444
  // Helper to get the inner default when enabling the optional
311
445
  const getInnerDefault = (): unknown => innerField.defaultValue
312
446
 
447
+ // Helper to check if a value represents an enabled optional
448
+ const isEnabled = (value: unknown): boolean => {
449
+ return value !== null && typeof value !== "undefined"
450
+ }
451
+
313
452
  return {
314
453
  type: "optional",
315
454
  label,
455
+ displayLabel: formatLabel(label),
316
456
  name,
457
+ component: "optional-toggle",
458
+ renderHint: COMPOUND_RENDER_HINT,
317
459
  innerField,
318
460
  defaultValue: null,
319
461
  schema,
320
462
  getInnerDefault,
463
+ isEnabled,
321
464
  candidType: "opt",
322
465
  }
323
466
  }
@@ -343,14 +486,24 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
343
486
  z.array(z.number()),
344
487
  z.instanceof(Uint8Array),
345
488
  ])
489
+
490
+ const limits = { ...DEFAULT_BLOB_LIMITS }
491
+
346
492
  return {
347
493
  type: "blob",
348
494
  label,
495
+ displayLabel: formatLabel(label),
349
496
  name,
497
+ component: "blob-upload",
498
+ renderHint: FILE_RENDER_HINT,
350
499
  itemField,
351
500
  defaultValue: "",
352
501
  schema,
353
502
  acceptedFormats: ["hex", "base64", "file"],
503
+ limits,
504
+ normalizeHex,
505
+ validateInput: (value: string | Uint8Array) =>
506
+ validateBlobInput(value, limits),
354
507
  candidType: "blob",
355
508
  }
356
509
  }
@@ -360,14 +513,35 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
360
513
  // Helper to get a new item with default values
361
514
  const getItemDefault = (): unknown => itemField.defaultValue
362
515
 
516
+ // Helper to create an item field for a specific index
517
+ const createItemField = (
518
+ index: number,
519
+ overrides?: { label?: string }
520
+ ): Field => {
521
+ // Replace [0] in template with actual index
522
+ const itemName = name ? `${name}[${index}]` : `[${index}]`
523
+ const itemLabel = overrides?.label ?? `Item ${index}`
524
+
525
+ return {
526
+ ...itemField,
527
+ name: itemName,
528
+ label: itemLabel,
529
+ displayLabel: formatLabel(itemLabel),
530
+ }
531
+ }
532
+
363
533
  return {
364
534
  type: "vector",
365
535
  label,
536
+ displayLabel: formatLabel(label),
366
537
  name,
538
+ component: "vector-list",
539
+ renderHint: COMPOUND_RENDER_HINT,
367
540
  itemField,
368
541
  defaultValue: [],
369
542
  schema,
370
543
  getItemDefault,
544
+ createItemField,
371
545
  candidType: "vec",
372
546
  }
373
547
  }
@@ -399,7 +573,10 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
399
573
  return {
400
574
  type: "recursive",
401
575
  label,
576
+ displayLabel: formatLabel(label),
402
577
  name,
578
+ component: "recursive-lazy",
579
+ renderHint: COMPOUND_RENDER_HINT,
403
580
  typeName,
404
581
  extract,
405
582
  defaultValue: undefined,
@@ -432,42 +609,67 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
432
609
  }
433
610
  )
434
611
 
612
+ const inputProps: PrimitiveInputProps = {
613
+ type: "text",
614
+ placeholder: "aaaaa-aa or full principal ID",
615
+ minLength: 7,
616
+ maxLength: 64,
617
+ spellCheck: false,
618
+ autoComplete: "off",
619
+ }
620
+
435
621
  return {
436
622
  type: "principal",
437
623
  label,
624
+ displayLabel: formatLabel(label),
438
625
  name: this.currentName(),
626
+ component: "principal-input",
627
+ renderHint: TEXT_RENDER_HINT,
439
628
  defaultValue: "",
440
629
  maxLength: 64,
441
630
  minLength: 7,
442
631
  schema,
632
+ inputProps,
443
633
  candidType: "principal",
444
- ui: {
445
- placeholder: "aaaaa-aa or full principal ID",
446
- },
447
634
  }
448
635
  }
449
636
 
450
637
  public visitText(_t: IDL.TextClass, label: string): TextField {
638
+ const inputProps: PrimitiveInputProps = {
639
+ type: "text",
640
+ placeholder: "Enter text...",
641
+ spellCheck: true,
642
+ }
643
+
451
644
  return {
452
645
  type: "text",
453
646
  label,
647
+ displayLabel: formatLabel(label),
454
648
  name: this.currentName(),
649
+ component: "text-input",
650
+ renderHint: TEXT_RENDER_HINT,
455
651
  defaultValue: "",
456
652
  schema: z.string().min(1, "Required"),
653
+ inputProps,
457
654
  candidType: "text",
458
- ui: {
459
- placeholder: "Enter text...",
460
- },
461
655
  }
462
656
  }
463
657
 
464
658
  public visitBool(_t: IDL.BoolClass, label: string): BooleanField {
659
+ const inputProps: PrimitiveInputProps = {
660
+ type: "checkbox",
661
+ }
662
+
465
663
  return {
466
664
  type: "boolean",
467
665
  label,
666
+ displayLabel: formatLabel(label),
468
667
  name: this.currentName(),
668
+ component: "boolean-checkbox",
669
+ renderHint: CHECKBOX_RENDER_HINT,
469
670
  defaultValue: false,
470
671
  schema: z.boolean(),
672
+ inputProps,
471
673
  candidType: "bool",
472
674
  }
473
675
  }
@@ -476,7 +678,13 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
476
678
  return {
477
679
  type: "null",
478
680
  label,
681
+ displayLabel: formatLabel(label),
479
682
  name: this.currentName(),
683
+ component: "null-hidden",
684
+ renderHint: {
685
+ isCompound: false,
686
+ isPrimitive: true,
687
+ },
480
688
  defaultValue: null,
481
689
  schema: z.null(),
482
690
  candidType: "null",
@@ -514,30 +722,50 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
514
722
  const type = isBigInt ? "text" : "number"
515
723
 
516
724
  if (type === "text") {
725
+ const inputProps: PrimitiveInputProps = {
726
+ type: "text",
727
+ placeholder: options.unsigned ? "e.g. 100000" : "e.g. -100000",
728
+ inputMode: "numeric",
729
+ pattern: options.unsigned ? "\\d+" : "-?\\d+",
730
+ spellCheck: false,
731
+ autoComplete: "off",
732
+ }
733
+
517
734
  return {
518
735
  type: "text",
519
736
  label,
737
+ displayLabel: formatLabel(label),
520
738
  name: this.currentName(),
739
+ component: "text-input",
740
+ renderHint: TEXT_RENDER_HINT,
521
741
  defaultValue: "",
522
742
  candidType,
523
743
  schema,
524
- ui: {
525
- placeholder: options.unsigned ? "e.g. 100000" : "e.g. -100000",
526
- },
744
+ inputProps,
527
745
  }
528
746
  }
529
747
 
748
+ const inputProps: PrimitiveInputProps = {
749
+ type: "number",
750
+ placeholder: options.isFloat ? "0.0" : "0",
751
+ inputMode: options.isFloat ? "decimal" : "numeric",
752
+ min: options.min,
753
+ max: options.max,
754
+ step: options.isFloat ? "any" : "1",
755
+ }
756
+
530
757
  return {
531
758
  type: "number",
532
759
  label,
760
+ displayLabel: formatLabel(label),
533
761
  name: this.currentName(),
762
+ component: "number-input",
763
+ renderHint: NUMBER_RENDER_HINT,
534
764
  defaultValue: "",
535
765
  candidType,
536
766
  schema: schema,
767
+ inputProps,
537
768
  ...options,
538
- ui: {
539
- placeholder: options.isFloat ? "0.0" : "0",
540
- },
541
769
  }
542
770
  }
543
771
 
@@ -602,7 +830,13 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
602
830
  return {
603
831
  type: "unknown",
604
832
  label,
833
+ displayLabel: formatLabel(label),
605
834
  name: this.currentName(),
835
+ component: "unknown-fallback",
836
+ renderHint: {
837
+ isCompound: false,
838
+ isPrimitive: false,
839
+ },
606
840
  defaultValue: undefined,
607
841
  schema: z.any(),
608
842
  }
@@ -1,11 +1,11 @@
1
1
  import { describe, it, expect } from "vitest"
2
2
  import { IDL } from "@icp-sdk/core/candid"
3
3
  import { Principal } from "@icp-sdk/core/principal"
4
- import { ArgumentFieldVisitor } from "./index"
4
+ import { FieldVisitor } from "./index"
5
5
  import * as z from "zod"
6
6
 
7
- describe("ArgumentFieldVisitor Schema Generation", () => {
8
- const visitor = new ArgumentFieldVisitor()
7
+ describe("FieldVisitor Schema Generation", () => {
8
+ const visitor = new FieldVisitor()
9
9
 
10
10
  // ════════════════════════════════════════════════════════════════════════
11
11
  // Primitive Types