@ic-reactor/candid 3.0.13-beta.0 → 3.0.14-beta.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.
@@ -1,6 +1,7 @@
1
1
  import { isQuery } from "../helpers"
2
+ import { checkTextFormat, checkNumberFormat } from "../constants"
2
3
  import type {
3
- Field,
4
+ FieldNode,
4
5
  RecordField,
5
6
  VariantField,
6
7
  TupleField,
@@ -20,6 +21,8 @@ import type {
20
21
  PrimitiveInputProps,
21
22
  BlobLimits,
22
23
  BlobValidationResult,
24
+ TextFormat,
25
+ NumberFormat,
23
26
  } from "./types"
24
27
 
25
28
  import { IDL } from "@icp-sdk/core/candid"
@@ -29,6 +32,8 @@ import * as z from "zod"
29
32
  import { formatLabel } from "./helpers"
30
33
 
31
34
  export * from "./types"
35
+ export * from "./helpers"
36
+ export { checkTextFormat, checkNumberFormat } from "../constants"
32
37
 
33
38
  // ════════════════════════════════════════════════════════════════════════════
34
39
  // Render Hint Helpers
@@ -177,7 +182,7 @@ function validateBlobInput(
177
182
  */
178
183
  export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
179
184
  string,
180
- Field | ArgumentsMeta<A> | ArgumentsServiceMeta<A>
185
+ FieldNode | ArgumentsMeta<A> | ArgumentsServiceMeta<A>
181
186
  > {
182
187
  public recursiveSchemas: Map<string, z.ZodTypeAny> = new Map()
183
188
 
@@ -231,7 +236,7 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
231
236
  const fields = t.argTypes.map((arg, index) => {
232
237
  return this.withName(`[${index}]`, () =>
233
238
  arg.accept(this, `__arg${index}`)
234
- ) as Field
239
+ ) as FieldNode
235
240
  })
236
241
 
237
242
  const defaultValues = fields.map((field) => field.defaultValue)
@@ -272,18 +277,16 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
272
277
  label: string
273
278
  ): RecordField {
274
279
  const name = this.currentName()
275
- const fields: Field[] = []
276
- const fieldMap = new Map<string, Field>()
280
+ const fields: FieldNode[] = []
277
281
  const defaultValue: Record<string, unknown> = {}
278
282
  const schemaShape: Record<string, z.ZodTypeAny> = {}
279
283
 
280
284
  for (const [key, type] of fields_) {
281
285
  const field = this.withName(name ? `.${key}` : key, () =>
282
286
  type.accept(this, key)
283
- ) as Field
287
+ ) as FieldNode
284
288
 
285
289
  fields.push(field)
286
- fieldMap.set(key, field)
287
290
  defaultValue[key] = field.defaultValue
288
291
  schemaShape[key] = field.schema
289
292
  }
@@ -298,7 +301,6 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
298
301
  component: "record-container",
299
302
  renderHint: COMPOUND_RENDER_HINT,
300
303
  fields,
301
- fieldMap,
302
304
  defaultValue,
303
305
  schema,
304
306
  candidType: "record",
@@ -311,42 +313,55 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
311
313
  label: string
312
314
  ): VariantField {
313
315
  const name = this.currentName()
314
- const fields: Field[] = []
315
- const options: string[] = []
316
- const optionMap = new Map<string, Field>()
316
+ const fields: FieldNode[] = []
317
317
  const variantSchemas: z.ZodTypeAny[] = []
318
318
 
319
319
  for (const [key, type] of fields_) {
320
320
  const field = this.withName(`.${key}`, () =>
321
321
  type.accept(this, key)
322
- ) as Field
322
+ ) as FieldNode
323
323
 
324
324
  fields.push(field)
325
- options.push(key)
326
- optionMap.set(key, field)
327
- variantSchemas.push(z.object({ [key]: field.schema }))
325
+
326
+ if (field.type === "null") {
327
+ variantSchemas.push(z.object({ _type: z.literal(key) }))
328
+ } else {
329
+ variantSchemas.push(
330
+ z.object({
331
+ _type: z.literal(key),
332
+ [key]: field.schema,
333
+ })
334
+ )
335
+ }
328
336
  }
329
337
 
330
- const defaultOption = options[0]
331
338
  const firstField = fields[0]
332
- const defaultValue = {
333
- [defaultOption]: firstField.defaultValue,
334
- }
339
+ const defaultOption = firstField.label
340
+
341
+ const defaultValue =
342
+ firstField.type === "null"
343
+ ? { _type: defaultOption }
344
+ : {
345
+ _type: defaultOption,
346
+ [defaultOption]: firstField.defaultValue,
347
+ }
335
348
 
336
349
  const schema = z.union(variantSchemas as [z.ZodTypeAny, ...z.ZodTypeAny[]])
337
350
 
338
351
  // Helper to get default value for any option
339
352
  const getOptionDefault = (option: string): Record<string, unknown> => {
340
- const optField = optionMap.get(option)
353
+ const optField = fields.find((f) => f.label === option)
341
354
  if (!optField) {
342
355
  throw new Error(`Unknown variant option: ${option}`)
343
356
  }
344
- return { [option]: optField.defaultValue }
357
+ return optField.type === "null"
358
+ ? { _type: option }
359
+ : { _type: option, [option]: optField.defaultValue }
345
360
  }
346
361
 
347
362
  // Helper to get field for a specific option
348
- const getField = (option: string): Field => {
349
- const optField = optionMap.get(option)
363
+ const getField = (option: string): FieldNode => {
364
+ const optField = fields.find((f) => f.label === option)
350
365
  if (!optField) {
351
366
  throw new Error(`Unknown variant option: ${option}`)
352
367
  }
@@ -355,12 +370,17 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
355
370
 
356
371
  // Helper to get currently selected option from a value
357
372
  const getSelectedOption = (value: Record<string, unknown>): string => {
358
- const validKeys = Object.keys(value).filter((k) => options.includes(k))
373
+ if (value._type && typeof value._type === "string") {
374
+ return value._type
375
+ }
376
+ const validKeys = Object.keys(value).filter((k) =>
377
+ fields.some((f) => f.label === k)
378
+ )
359
379
  return validKeys[0] ?? defaultOption
360
380
  }
361
381
 
362
382
  // Helper to get selected field from a value
363
- const getSelectedField = (value: Record<string, unknown>): Field => {
383
+ const getSelectedField = (value: Record<string, unknown>): FieldNode => {
364
384
  const selectedOption = getSelectedOption(value)
365
385
  return getField(selectedOption)
366
386
  }
@@ -373,9 +393,7 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
373
393
  component: "variant-select",
374
394
  renderHint: COMPOUND_RENDER_HINT,
375
395
  fields,
376
- options,
377
396
  defaultOption,
378
- optionMap,
379
397
  defaultValue,
380
398
  schema,
381
399
  getOptionDefault,
@@ -392,7 +410,7 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
392
410
  label: string
393
411
  ): TupleField {
394
412
  const name = this.currentName()
395
- const fields: Field[] = []
413
+ const fields: FieldNode[] = []
396
414
  const defaultValue: unknown[] = []
397
415
  const schemas: z.ZodTypeAny[] = []
398
416
 
@@ -400,7 +418,7 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
400
418
  const type = components[index]
401
419
  const field = this.withName(`[${index}]`, () =>
402
420
  type.accept(this, `_${index}_`)
403
- ) as Field
421
+ ) as FieldNode
404
422
 
405
423
  fields.push(field)
406
424
  defaultValue.push(field.defaultValue)
@@ -432,7 +450,7 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
432
450
 
433
451
  // For optional, the inner field keeps the same name path
434
452
  // because the value replaces null directly (not nested)
435
- const innerField = ty.accept(this, label) as Field
453
+ const innerField = ty.accept(this, label) as FieldNode
436
454
 
437
455
  const schema = z.union([
438
456
  innerField.schema,
@@ -478,7 +496,7 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
478
496
  // Item field uses [0] as template path
479
497
  const itemField = this.withName("[0]", () =>
480
498
  ty.accept(this, `${label}_item`)
481
- ) as Field
499
+ ) as FieldNode
482
500
 
483
501
  if (isBlob) {
484
502
  const schema = z.union([
@@ -517,7 +535,7 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
517
535
  const createItemField = (
518
536
  index: number,
519
537
  overrides?: { label?: string }
520
- ): Field => {
538
+ ): FieldNode => {
521
539
  // Replace [0] in template with actual index
522
540
  const itemName = name ? `${name}[${index}]` : `[${index}]`
523
541
  const itemLabel = overrides?.label ?? `Item ${index}`
@@ -559,13 +577,13 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
559
577
  if (this.recursiveSchemas.has(typeName)) {
560
578
  schema = this.recursiveSchemas.get(typeName)!
561
579
  } else {
562
- schema = z.lazy(() => (ty.accept(this, label) as Field).schema)
580
+ schema = z.lazy(() => (ty.accept(this, label) as FieldNode).schema)
563
581
  this.recursiveSchemas.set(typeName, schema)
564
582
  }
565
583
 
566
584
  // Lazy extraction to prevent infinite loops
567
- const extract = (): Field =>
568
- this.withName(name, () => ty.accept(this, label)) as Field
585
+ const extract = (): FieldNode =>
586
+ this.withName(name, () => ty.accept(this, label)) as FieldNode
569
587
 
570
588
  // Helper to get inner default (evaluates lazily)
571
589
  const getInnerDefault = (): unknown => extract().defaultValue
@@ -628,6 +646,7 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
628
646
  defaultValue: "",
629
647
  maxLength: 64,
630
648
  minLength: 7,
649
+ format: checkTextFormat(label) as TextFormat,
631
650
  schema,
632
651
  inputProps,
633
652
  candidType: "principal",
@@ -635,11 +654,13 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
635
654
  }
636
655
 
637
656
  public visitText(_t: IDL.TextClass, label: string): TextField {
638
- const inputProps: PrimitiveInputProps = {
639
- type: "text",
640
- placeholder: "Enter text...",
641
- spellCheck: true,
642
- }
657
+ const format = checkTextFormat(label) as TextFormat
658
+
659
+ // Generate format-specific inputProps
660
+ const inputProps = this.getTextInputProps(format)
661
+
662
+ // Generate format-specific schema
663
+ const schema = this.getTextSchema(format)
643
664
 
644
665
  return {
645
666
  type: "text",
@@ -649,12 +670,109 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
649
670
  component: "text-input",
650
671
  renderHint: TEXT_RENDER_HINT,
651
672
  defaultValue: "",
652
- schema: z.string().min(1, "Required"),
673
+ format,
674
+ schema,
653
675
  inputProps,
654
676
  candidType: "text",
655
677
  }
656
678
  }
657
679
 
680
+ /**
681
+ * Generate format-specific input props for text fields.
682
+ */
683
+ private getTextInputProps(format: TextFormat): PrimitiveInputProps {
684
+ switch (format) {
685
+ case "email":
686
+ return {
687
+ type: "email",
688
+ placeholder: "email@example.com",
689
+ inputMode: "email",
690
+ autoComplete: "email",
691
+ spellCheck: false,
692
+ }
693
+ case "url":
694
+ return {
695
+ type: "url",
696
+ placeholder: "https://example.com",
697
+ inputMode: "url",
698
+ autoComplete: "url",
699
+ spellCheck: false,
700
+ }
701
+ case "phone":
702
+ return {
703
+ type: "tel",
704
+ placeholder: "+1 (555) 123-4567",
705
+ inputMode: "tel",
706
+ autoComplete: "tel",
707
+ spellCheck: false,
708
+ }
709
+ case "uuid":
710
+ return {
711
+ type: "text",
712
+ placeholder: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
713
+ pattern:
714
+ "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
715
+ spellCheck: false,
716
+ autoComplete: "off",
717
+ }
718
+ case "btc":
719
+ return {
720
+ type: "text",
721
+ placeholder: "bc1... or 1... or 3...",
722
+ spellCheck: false,
723
+ autoComplete: "off",
724
+ }
725
+ case "eth":
726
+ return {
727
+ type: "text",
728
+ placeholder: "0x...",
729
+ pattern: "0x[0-9a-fA-F]{40}",
730
+ spellCheck: false,
731
+ autoComplete: "off",
732
+ }
733
+ case "account-id":
734
+ return {
735
+ type: "text",
736
+ placeholder: "64-character hex string",
737
+ pattern: "[0-9a-fA-F]{64}",
738
+ maxLength: 64,
739
+ spellCheck: false,
740
+ autoComplete: "off",
741
+ }
742
+ case "principal":
743
+ return {
744
+ type: "text",
745
+ placeholder: "aaaaa-aa or full principal ID",
746
+ minLength: 7,
747
+ maxLength: 64,
748
+ spellCheck: false,
749
+ autoComplete: "off",
750
+ }
751
+ default:
752
+ return {
753
+ type: "text",
754
+ placeholder: "Enter text...",
755
+ spellCheck: true,
756
+ }
757
+ }
758
+ }
759
+
760
+ /**
761
+ * Generate format-specific zod schema for text fields.
762
+ */
763
+ private getTextSchema(format: TextFormat): z.ZodTypeAny {
764
+ switch (format) {
765
+ case "email":
766
+ return z.email("Invalid email address")
767
+ case "url":
768
+ return z.url("Invalid URL")
769
+ case "uuid":
770
+ return z.uuid("Invalid UUID")
771
+ default:
772
+ return z.string().min(1, "Required")
773
+ }
774
+ }
775
+
658
776
  public visitBool(_t: IDL.BoolClass, label: string): BooleanField {
659
777
  const inputProps: PrimitiveInputProps = {
660
778
  type: "checkbox",
@@ -706,6 +824,8 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
706
824
  max?: string
707
825
  }
708
826
  ): NumberField | TextField {
827
+ const format = checkNumberFormat(label) as NumberFormat
828
+
709
829
  let schema = z.string().min(1, "Required")
710
830
 
711
831
  if (options.isFloat) {
@@ -722,6 +842,11 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
722
842
  const type = isBigInt ? "text" : "number"
723
843
 
724
844
  if (type === "text") {
845
+ // Propagate timestamp/cycle format if detected, otherwise default to plain
846
+ let textFormat: TextFormat = "plain"
847
+ if (format === "timestamp") textFormat = "timestamp"
848
+ if (format === "cycle") textFormat = "cycle"
849
+
725
850
  const inputProps: PrimitiveInputProps = {
726
851
  type: "text",
727
852
  placeholder: options.unsigned ? "e.g. 100000" : "e.g. -100000",
@@ -739,6 +864,7 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
739
864
  component: "text-input",
740
865
  renderHint: TEXT_RENDER_HINT,
741
866
  defaultValue: "",
867
+ format: textFormat,
742
868
  candidType,
743
869
  schema,
744
870
  inputProps,
@@ -763,7 +889,8 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
763
889
  renderHint: NUMBER_RENDER_HINT,
764
890
  defaultValue: "",
765
891
  candidType,
766
- schema: schema,
892
+ format,
893
+ schema,
767
894
  inputProps,
768
895
  ...options,
769
896
  }
@@ -838,6 +965,7 @@ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
838
965
  isPrimitive: false,
839
966
  },
840
967
  defaultValue: undefined,
968
+ candidType: "unknown",
841
969
  schema: z.any(),
842
970
  }
843
971
  }
@@ -62,6 +62,108 @@ describe("FieldVisitor Schema Generation", () => {
62
62
  })
63
63
  })
64
64
 
65
+ // ════════════════════════════════════════════════════════════════════════
66
+ // Format Detection & Validation
67
+ // ════════════════════════════════════════════════════════════════════════
68
+
69
+ describe("Format Detection & Validation", () => {
70
+ describe("Text Formats", () => {
71
+ it("should detect email format", () => {
72
+ const field = visitor.visitText(IDL.Text, "user_email")
73
+
74
+ expect(field.format).toBe("email")
75
+ expect(field.inputProps).toMatchObject({
76
+ type: "email",
77
+ inputMode: "email",
78
+ })
79
+
80
+ // Valid email
81
+ expect(field.schema.parse("test@example.com")).toBe("test@example.com")
82
+ // Invalid email
83
+ expect(() => field.schema.parse("invalid-email")).toThrow()
84
+ })
85
+
86
+ it("should detect url format", () => {
87
+ const field = visitor.visitText(IDL.Text, "website_link")
88
+
89
+ expect(field.format).toBe("url")
90
+ expect(field.inputProps).toMatchObject({
91
+ type: "url",
92
+ inputMode: "url",
93
+ })
94
+
95
+ // Valid URL
96
+ expect(field.schema.parse("https://example.com")).toBe(
97
+ "https://example.com"
98
+ )
99
+ // Invalid URL
100
+ expect(() => field.schema.parse("not-a-url")).toThrow()
101
+ })
102
+
103
+ it("should detect uuid format", () => {
104
+ const field = visitor.visitText(IDL.Text, "transaction_uuid")
105
+
106
+ expect(field.format).toBe("uuid")
107
+
108
+ const validUuid = "123e4567-e89b-12d3-a456-426614174000"
109
+ expect(field.schema.parse(validUuid)).toBe(validUuid)
110
+
111
+ expect(() => field.schema.parse("invalid-uuid")).toThrow()
112
+ })
113
+
114
+ it("should detect ethereum address format", () => {
115
+ const field = visitor.visitText(IDL.Text, "eth_address")
116
+
117
+ expect(field.format).toBe("eth")
118
+ expect(field.inputProps.pattern).toContain("0x")
119
+ })
120
+
121
+ it("should fallback to plain text for unknown formats", () => {
122
+ const field = visitor.visitText(IDL.Text, "some_random_field")
123
+
124
+ expect(field.format).toBe("plain")
125
+ expect(field.inputProps).toMatchObject({
126
+ type: "text",
127
+ })
128
+ })
129
+ })
130
+
131
+ describe("Number Formats", () => {
132
+ it("should detect timestamp format", () => {
133
+ const field = visitor.visitInt(IDL.Int, "created_at")
134
+
135
+ expect(field.format).toBe("timestamp")
136
+ })
137
+
138
+ it("should detect cycle format", () => {
139
+ const field = visitor.visitNat(IDL.Nat, "cycles_balance")
140
+
141
+ expect(field.format).toBe("cycle")
142
+ })
143
+
144
+ it("should fallback to normal format", () => {
145
+ const field = visitor.visitNat(IDL.Nat, "quantity")
146
+
147
+ expect(field.format).toBe("plain")
148
+ })
149
+ })
150
+
151
+ describe("Principal Format", () => {
152
+ it("should detect principal format and set properties", () => {
153
+ const field = visitor.visitPrincipal(
154
+ IDL.Principal,
155
+ "controller_principal"
156
+ )
157
+
158
+ expect(field.format).toBe("principal")
159
+ expect(field.inputProps).toMatchObject({
160
+ minLength: 7,
161
+ maxLength: 64,
162
+ })
163
+ })
164
+ })
165
+ })
166
+
65
167
  // ════════════════════════════════════════════════════════════════════════
66
168
  // Compound Types
67
169
  // ════════════════════════════════════════════════════════════════════════
@@ -106,10 +208,16 @@ describe("FieldVisitor Schema Generation", () => {
106
208
  )
107
209
  const schema = field.schema as z.ZodUnion<any>
108
210
 
109
- expect(schema.parse({ Ok: "Success" })).toEqual({ Ok: "Success" })
110
- expect(schema.parse({ Err: "Error" })).toEqual({ Err: "Error" })
211
+ expect(schema.parse({ _type: "Ok", Ok: "Success" })).toEqual({
212
+ _type: "Ok",
213
+ Ok: "Success",
214
+ })
215
+ expect(schema.parse({ _type: "Err", Err: "Error" })).toEqual({
216
+ _type: "Err",
217
+ Err: "Error",
218
+ })
111
219
 
112
- expect(() => schema.parse({ Other: "value" })).toThrow()
220
+ expect(() => schema.parse({ _type: "Other", Other: "value" })).toThrow()
113
221
  })
114
222
  })
115
223
 
@@ -179,12 +287,14 @@ describe("FieldVisitor Schema Generation", () => {
179
287
  const schema = field.schema
180
288
 
181
289
  const validList = {
290
+ _type: "Cons",
182
291
  Cons: {
183
292
  head: "1",
184
293
  tail: {
294
+ _type: "Cons",
185
295
  Cons: {
186
296
  head: "2",
187
- tail: { Nil: null },
297
+ tail: { _type: "Nil" },
188
298
  },
189
299
  },
190
300
  },