@ic-reactor/candid 3.0.11-beta.2 → 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.
Files changed (44) hide show
  1. package/dist/display-reactor.d.ts +1 -2
  2. package/dist/display-reactor.d.ts.map +1 -1
  3. package/dist/display-reactor.js +1 -1
  4. package/dist/display-reactor.js.map +1 -1
  5. package/dist/index.d.ts +0 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/metadata-display-reactor.d.ts +3 -3
  9. package/dist/metadata-display-reactor.d.ts.map +1 -1
  10. package/dist/metadata-display-reactor.js +2 -2
  11. package/dist/metadata-display-reactor.js.map +1 -1
  12. package/dist/visitor/arguments/helpers.d.ts +40 -0
  13. package/dist/visitor/arguments/helpers.d.ts.map +1 -0
  14. package/dist/visitor/arguments/helpers.js +81 -0
  15. package/dist/visitor/arguments/helpers.js.map +1 -0
  16. package/dist/visitor/arguments/index.d.ts +64 -42
  17. package/dist/visitor/arguments/index.d.ts.map +1 -1
  18. package/dist/visitor/arguments/index.js +472 -85
  19. package/dist/visitor/arguments/index.js.map +1 -1
  20. package/dist/visitor/arguments/types.d.ts +481 -46
  21. package/dist/visitor/arguments/types.d.ts.map +1 -1
  22. package/dist/visitor/helpers.d.ts +1 -1
  23. package/dist/visitor/helpers.d.ts.map +1 -1
  24. package/dist/visitor/returns/index.d.ts +1 -2
  25. package/dist/visitor/returns/index.d.ts.map +1 -1
  26. package/dist/visitor/returns/index.js +2 -3
  27. package/dist/visitor/returns/index.js.map +1 -1
  28. package/dist/visitor/types.d.ts +2 -3
  29. package/dist/visitor/types.d.ts.map +1 -1
  30. package/dist/visitor/types.js +1 -2
  31. package/dist/visitor/types.js.map +1 -1
  32. package/package.json +6 -3
  33. package/src/display-reactor.ts +4 -6
  34. package/src/index.ts +0 -1
  35. package/src/metadata-display-reactor.ts +8 -8
  36. package/src/visitor/arguments/helpers.ts +104 -0
  37. package/src/visitor/arguments/index.test.ts +544 -52
  38. package/src/visitor/arguments/index.ts +590 -151
  39. package/src/visitor/arguments/schema.test.ts +215 -0
  40. package/src/visitor/arguments/types.ts +554 -62
  41. package/src/visitor/helpers.ts +1 -1
  42. package/src/visitor/returns/index.test.ts +1 -1
  43. package/src/visitor/returns/index.ts +2 -3
  44. package/src/visitor/types.ts +2 -3
@@ -1,29 +1,130 @@
1
1
  import { isQuery } from "../helpers"
2
- import { IDL } from "../types"
3
2
  import type {
4
- ArgumentField,
5
- RecordArgumentField,
6
- VariantArgumentField,
7
- TupleArgumentField,
8
- OptionalArgumentField,
9
- VectorArgumentField,
10
- BlobArgumentField,
11
- RecursiveArgumentField,
12
- PrincipalArgumentField,
13
- NumberArgumentField,
14
- BooleanArgumentField,
15
- NullArgumentField,
16
- TextArgumentField,
17
- UnknownArgumentField,
18
- MethodArgumentsMeta,
19
- ServiceArgumentsMeta,
3
+ Field,
4
+ RecordField,
5
+ VariantField,
6
+ TupleField,
7
+ OptionalField,
8
+ VectorField,
9
+ BlobField,
10
+ RecursiveField,
11
+ PrincipalField,
12
+ NumberField,
13
+ BooleanField,
14
+ NullField,
15
+ TextField,
16
+ UnknownField,
17
+ ArgumentsMeta,
18
+ ArgumentsServiceMeta,
19
+ RenderHint,
20
+ PrimitiveInputProps,
21
+ BlobLimits,
22
+ BlobValidationResult,
20
23
  } from "./types"
24
+
25
+ import { IDL } from "@icp-sdk/core/candid"
26
+ import { Principal } from "@icp-sdk/core/principal"
21
27
  import { BaseActor, FunctionName } from "@ic-reactor/core"
28
+ import * as z from "zod"
29
+ import { formatLabel } from "./helpers"
22
30
 
23
31
  export * from "./types"
24
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
+
25
126
  /**
26
- * ArgumentFieldVisitor generates metadata for form input fields from Candid IDL types.
127
+ * FieldVisitor generates metadata for form input fields from Candid IDL types.
27
128
  *
28
129
  * ## Design Principles
29
130
  *
@@ -31,66 +132,90 @@ export * from "./types"
31
132
  * 2. **No value dependencies** - metadata is independent of actual values
32
133
  * 3. **Form-framework agnostic** - output can be used with TanStack, React Hook Form, etc.
33
134
  * 4. **Efficient** - single traversal, no runtime type checking
135
+ * 5. **TanStack Form optimized** - name paths compatible with TanStack Form patterns
34
136
  *
35
137
  * ## Output Structure
36
138
  *
37
139
  * Each field has:
38
140
  * - `type`: The field type (record, variant, text, number, etc.)
39
- * - `label`: Human-readable label from Candid
40
- * - `path`: Dot-notation path for form binding (e.g., "0.owner")
141
+ * - `label`: Raw label from Candid
142
+ * - `displayLabel`: Human-readable formatted label
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
41
146
  * - `defaultValue`: Initial value for the form
147
+ * - `schema`: Zod schema for validation
42
148
  * - Type-specific properties (options for variant, fields for record, etc.)
149
+ * - Helper methods for dynamic forms (getOptionDefault, getItemDefault, etc.)
150
+ *
151
+ * ## Usage with TanStack Form
43
152
  *
44
153
  * @example
45
154
  * ```typescript
46
- * const visitor = new ArgumentFieldVisitor()
47
- * const serviceMeta = service.accept(visitor, null)
155
+ * import { useForm } from '@tanstack/react-form'
156
+ * import { FieldVisitor } from '@ic-reactor/candid'
48
157
  *
49
- * // For a specific method
158
+ * const visitor = new FieldVisitor()
159
+ * const serviceMeta = service.accept(visitor, null)
50
160
  * const methodMeta = serviceMeta["icrc1_transfer"]
51
- * // methodMeta.fields = [{ type: "record", fields: [...] }]
52
- * // methodMeta.defaultValues = [{ to: "", amount: "" }]
161
+ *
162
+ * const form = useForm({
163
+ * defaultValues: methodMeta.defaultValue,
164
+ * validators: { onBlur: methodMeta.schema },
165
+ * onSubmit: async ({ value }) => {
166
+ * await actor.icrc1_transfer(...value)
167
+ * }
168
+ * })
169
+ *
170
+ * // Render fields dynamically
171
+ * methodMeta.fields.map((field, index) => (
172
+ * <form.Field key={index} name={field.name}>
173
+ * {(fieldApi) => <DynamicInput field={field} fieldApi={fieldApi} />}
174
+ * </form.Field>
175
+ * ))
53
176
  * ```
54
177
  */
55
- export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
178
+ export class FieldVisitor<A = BaseActor> extends IDL.Visitor<
56
179
  string,
57
- ArgumentField | MethodArgumentsMeta<A> | ServiceArgumentsMeta<A>
180
+ Field | ArgumentsMeta<A> | ArgumentsServiceMeta<A>
58
181
  > {
59
- private pathStack: string[] = []
182
+ public recursiveSchemas: Map<string, z.ZodTypeAny> = new Map()
60
183
 
61
- private withPath<T>(path: string, fn: () => T): T {
62
- this.pathStack.push(path)
184
+ private nameStack: string[] = []
185
+
186
+ /**
187
+ * Execute function with a name segment pushed onto the stack.
188
+ * Automatically manages stack cleanup.
189
+ */
190
+ private withName<T>(name: string, fn: () => T): T {
191
+ this.nameStack.push(name)
63
192
  try {
64
193
  return fn()
65
194
  } finally {
66
- this.pathStack.pop()
195
+ this.nameStack.pop()
67
196
  }
68
197
  }
69
198
 
70
- private currentPath(): string {
71
- return this.pathStack[this.pathStack.length - 1] ?? ""
72
- }
73
-
74
- private childPath(key: string | number): string {
75
- const parent = this.currentPath()
76
- if (typeof key === "number") {
77
- return parent ? `${parent}[${key}]` : String(key)
78
- }
79
- return parent ? `${parent}.${key}` : key
199
+ /**
200
+ * Get the current full name path for form binding.
201
+ * Returns empty string for root level.
202
+ */
203
+ private currentName(): string {
204
+ return this.nameStack.join("")
80
205
  }
81
206
 
82
207
  // ════════════════════════════════════════════════════════════════════════
83
208
  // Service & Function Level
84
209
  // ════════════════════════════════════════════════════════════════════════
85
210
 
86
- public visitService(t: IDL.ServiceClass): ServiceArgumentsMeta<A> {
87
- const result = {} as ServiceArgumentsMeta<A>
211
+ public visitService(t: IDL.ServiceClass): ArgumentsServiceMeta<A> {
212
+ const result = {} as ArgumentsServiceMeta<A>
88
213
 
89
214
  for (const [functionName, func] of t._fields) {
90
215
  result[functionName as FunctionName<A>] = func.accept(
91
216
  this,
92
217
  functionName
93
- ) as MethodArgumentsMeta<A>
218
+ ) as ArgumentsMeta<A>
94
219
  }
95
220
 
96
221
  return result
@@ -99,35 +224,44 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
99
224
  public visitFunc(
100
225
  t: IDL.FuncClass,
101
226
  functionName: FunctionName<A>
102
- ): MethodArgumentsMeta<A> {
227
+ ): ArgumentsMeta<A> {
103
228
  const functionType = isQuery(t) ? "query" : "update"
229
+ const argCount = t.argTypes.length
104
230
 
105
231
  const fields = t.argTypes.map((arg, index) => {
106
- return this.withPath(`[${index}]`, () =>
232
+ return this.withName(`[${index}]`, () =>
107
233
  arg.accept(this, `__arg${index}`)
108
- ) as ArgumentField
234
+ ) as Field
109
235
  })
110
236
 
111
- const defaultValues = fields.map((field) => this.extractDefaultValue(field))
237
+ const defaultValues = fields.map((field) => field.defaultValue)
238
+
239
+ // Handle empty args case for schema
240
+ // For no-arg functions, use an empty array schema
241
+ // For functions with args, use a proper tuple schema
242
+ const schema =
243
+ argCount === 0
244
+ ? (z.tuple([]) as unknown as z.ZodTuple<
245
+ [z.ZodTypeAny, ...z.ZodTypeAny[]]
246
+ >)
247
+ : z.tuple(
248
+ fields.map((field) => field.schema) as [
249
+ z.ZodTypeAny,
250
+ ...z.ZodTypeAny[],
251
+ ]
252
+ )
112
253
 
113
254
  return {
114
255
  functionType,
115
256
  functionName,
116
257
  fields,
117
- defaultValues,
258
+ defaultValues: defaultValues,
259
+ schema,
260
+ argCount,
261
+ isNoArgs: argCount === 0,
118
262
  }
119
263
  }
120
264
 
121
- private extractDefaultValue(field: ArgumentField): unknown {
122
- if ("defaultValue" in field) {
123
- return field.defaultValue
124
- }
125
- if ("defaultValues" in field) {
126
- return field.defaultValues
127
- }
128
- return undefined
129
- }
130
-
131
265
  // ════════════════════════════════════════════════════════════════════════
132
266
  // Compound Types
133
267
  // ════════════════════════════════════════════════════════════════════════
@@ -136,26 +270,38 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
136
270
  _t: IDL.RecordClass,
137
271
  fields_: Array<[string, IDL.Type]>,
138
272
  label: string
139
- ): RecordArgumentField {
140
- const path = this.currentPath()
141
- const fields: ArgumentField[] = []
142
- const defaultValues: Record<string, unknown> = {}
273
+ ): RecordField {
274
+ const name = this.currentName()
275
+ const fields: Field[] = []
276
+ const fieldMap = new Map<string, Field>()
277
+ const defaultValue: Record<string, unknown> = {}
278
+ const schemaShape: Record<string, z.ZodTypeAny> = {}
143
279
 
144
280
  for (const [key, type] of fields_) {
145
- const field = this.withPath(this.childPath(key), () =>
281
+ const field = this.withName(name ? `.${key}` : key, () =>
146
282
  type.accept(this, key)
147
- ) as ArgumentField
283
+ ) as Field
148
284
 
149
285
  fields.push(field)
150
- defaultValues[key] = this.extractDefaultValue(field)
286
+ fieldMap.set(key, field)
287
+ defaultValue[key] = field.defaultValue
288
+ schemaShape[key] = field.schema
151
289
  }
152
290
 
291
+ const schema = z.object(schemaShape)
292
+
153
293
  return {
154
294
  type: "record",
155
295
  label,
156
- path,
296
+ displayLabel: formatLabel(label),
297
+ name,
298
+ component: "record-container",
299
+ renderHint: COMPOUND_RENDER_HINT,
157
300
  fields,
158
- defaultValues,
301
+ fieldMap,
302
+ defaultValue,
303
+ schema,
304
+ candidType: "record",
159
305
  }
160
306
  }
161
307
 
@@ -163,33 +309,80 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
163
309
  _t: IDL.VariantClass,
164
310
  fields_: Array<[string, IDL.Type]>,
165
311
  label: string
166
- ): VariantArgumentField {
167
- const path = this.currentPath()
168
- const fields: ArgumentField[] = []
312
+ ): VariantField {
313
+ const name = this.currentName()
314
+ const fields: Field[] = []
169
315
  const options: string[] = []
316
+ const optionMap = new Map<string, Field>()
317
+ const variantSchemas: z.ZodTypeAny[] = []
170
318
 
171
319
  for (const [key, type] of fields_) {
172
- const field = this.withPath(this.childPath(key), () =>
320
+ const field = this.withName(`.${key}`, () =>
173
321
  type.accept(this, key)
174
- ) as ArgumentField
322
+ ) as Field
175
323
 
176
324
  fields.push(field)
177
325
  options.push(key)
326
+ optionMap.set(key, field)
327
+ variantSchemas.push(z.object({ [key]: field.schema }))
178
328
  }
179
329
 
180
330
  const defaultOption = options[0]
181
- const defaultValues = {
182
- [defaultOption]: this.extractDefaultValue(fields[0]),
331
+ const firstField = fields[0]
332
+ const defaultValue = {
333
+ [defaultOption]: firstField.defaultValue,
334
+ }
335
+
336
+ const schema = z.union(variantSchemas as [z.ZodTypeAny, ...z.ZodTypeAny[]])
337
+
338
+ // Helper to get default value for any option
339
+ const getOptionDefault = (option: string): Record<string, unknown> => {
340
+ const optField = optionMap.get(option)
341
+ if (!optField) {
342
+ throw new Error(`Unknown variant option: ${option}`)
343
+ }
344
+ return { [option]: optField.defaultValue }
345
+ }
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)
183
366
  }
184
367
 
185
368
  return {
186
369
  type: "variant",
187
370
  label,
188
- path,
371
+ displayLabel: formatLabel(label),
372
+ name,
373
+ component: "variant-select",
374
+ renderHint: COMPOUND_RENDER_HINT,
189
375
  fields,
190
376
  options,
191
377
  defaultOption,
192
- defaultValues,
378
+ optionMap,
379
+ defaultValue,
380
+ schema,
381
+ getOptionDefault,
382
+ getField,
383
+ getSelectedOption,
384
+ getSelectedField,
385
+ candidType: "variant",
193
386
  }
194
387
  }
195
388
 
@@ -197,27 +390,36 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
197
390
  _t: IDL.TupleClass<T>,
198
391
  components: IDL.Type[],
199
392
  label: string
200
- ): TupleArgumentField {
201
- const path = this.currentPath()
202
- const fields: ArgumentField[] = []
203
- const defaultValues: unknown[] = []
393
+ ): TupleField {
394
+ const name = this.currentName()
395
+ const fields: Field[] = []
396
+ const defaultValue: unknown[] = []
397
+ const schemas: z.ZodTypeAny[] = []
204
398
 
205
399
  for (let index = 0; index < components.length; index++) {
206
400
  const type = components[index]
207
- const field = this.withPath(this.childPath(index), () =>
401
+ const field = this.withName(`[${index}]`, () =>
208
402
  type.accept(this, `_${index}_`)
209
- ) as ArgumentField
403
+ ) as Field
210
404
 
211
405
  fields.push(field)
212
- defaultValues.push(this.extractDefaultValue(field))
406
+ defaultValue.push(field.defaultValue)
407
+ schemas.push(field.schema)
213
408
  }
214
409
 
410
+ const schema = z.tuple(schemas as [z.ZodTypeAny, ...z.ZodTypeAny[]])
411
+
215
412
  return {
216
413
  type: "tuple",
217
414
  label,
218
- path,
415
+ displayLabel: formatLabel(label),
416
+ name,
417
+ component: "tuple-container",
418
+ renderHint: COMPOUND_RENDER_HINT,
219
419
  fields,
220
- defaultValues,
420
+ defaultValue,
421
+ schema,
422
+ candidType: "tuple",
221
423
  }
222
424
  }
223
425
 
@@ -225,19 +427,41 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
225
427
  _t: IDL.OptClass<T>,
226
428
  ty: IDL.Type<T>,
227
429
  label: string
228
- ): OptionalArgumentField {
229
- const path = this.currentPath()
230
-
231
- const innerField = this.withPath(this.childPath(0), () =>
232
- ty.accept(this, label)
233
- ) as ArgumentField
430
+ ): OptionalField {
431
+ const name = this.currentName()
432
+
433
+ // For optional, the inner field keeps the same name path
434
+ // because the value replaces null directly (not nested)
435
+ const innerField = ty.accept(this, label) as Field
436
+
437
+ const schema = z.union([
438
+ innerField.schema,
439
+ z.null(),
440
+ z.undefined().transform(() => null),
441
+ z.literal("").transform(() => null),
442
+ ])
443
+
444
+ // Helper to get the inner default when enabling the optional
445
+ const getInnerDefault = (): unknown => innerField.defaultValue
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
+ }
234
451
 
235
452
  return {
236
453
  type: "optional",
237
454
  label,
238
- path,
455
+ displayLabel: formatLabel(label),
456
+ name,
457
+ component: "optional-toggle",
458
+ renderHint: COMPOUND_RENDER_HINT,
239
459
  innerField,
240
460
  defaultValue: null,
461
+ schema,
462
+ getInnerDefault,
463
+ isEnabled,
464
+ candidType: "opt",
241
465
  }
242
466
  }
243
467
 
@@ -245,32 +469,80 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
245
469
  _t: IDL.VecClass<T>,
246
470
  ty: IDL.Type<T>,
247
471
  label: string
248
- ): VectorArgumentField | BlobArgumentField {
249
- const path = this.currentPath()
472
+ ): VectorField | BlobField {
473
+ const name = this.currentName()
250
474
 
251
475
  // Check if it's blob (vec nat8)
252
476
  const isBlob = ty instanceof IDL.FixedNatClass && ty._bits === 8
253
477
 
254
- const itemField = this.withPath(this.childPath(0), () =>
255
- ty.accept(this, label)
256
- ) as ArgumentField
478
+ // Item field uses [0] as template path
479
+ const itemField = this.withName("[0]", () =>
480
+ ty.accept(this, `${label}_item`)
481
+ ) as Field
257
482
 
258
483
  if (isBlob) {
484
+ const schema = z.union([
485
+ z.string(),
486
+ z.array(z.number()),
487
+ z.instanceof(Uint8Array),
488
+ ])
489
+
490
+ const limits = { ...DEFAULT_BLOB_LIMITS }
491
+
259
492
  return {
260
493
  type: "blob",
261
494
  label,
262
- path,
495
+ displayLabel: formatLabel(label),
496
+ name,
497
+ component: "blob-upload",
498
+ renderHint: FILE_RENDER_HINT,
263
499
  itemField,
264
500
  defaultValue: "",
501
+ schema,
502
+ acceptedFormats: ["hex", "base64", "file"],
503
+ limits,
504
+ normalizeHex,
505
+ validateInput: (value: string | Uint8Array) =>
506
+ validateBlobInput(value, limits),
507
+ candidType: "blob",
508
+ }
509
+ }
510
+
511
+ const schema = z.array(itemField.schema)
512
+
513
+ // Helper to get a new item with default values
514
+ const getItemDefault = (): unknown => itemField.defaultValue
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),
265
530
  }
266
531
  }
267
532
 
268
533
  return {
269
534
  type: "vector",
270
535
  label,
271
- path,
536
+ displayLabel: formatLabel(label),
537
+ name,
538
+ component: "vector-list",
539
+ renderHint: COMPOUND_RENDER_HINT,
272
540
  itemField,
273
541
  defaultValue: [],
542
+ schema,
543
+ getItemDefault,
544
+ createItemField,
545
+ candidType: "vec",
274
546
  }
275
547
  }
276
548
 
@@ -278,16 +550,39 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
278
550
  _t: IDL.RecClass<T>,
279
551
  ty: IDL.ConstructType<T>,
280
552
  label: string
281
- ): RecursiveArgumentField {
282
- const path = this.currentPath()
553
+ ): RecursiveField {
554
+ const name = this.currentName()
555
+ const typeName = ty.name || "RecursiveType"
556
+
557
+ let schema: z.ZodTypeAny
558
+
559
+ if (this.recursiveSchemas.has(typeName)) {
560
+ schema = this.recursiveSchemas.get(typeName)!
561
+ } else {
562
+ schema = z.lazy(() => (ty.accept(this, label) as Field).schema)
563
+ this.recursiveSchemas.set(typeName, schema)
564
+ }
565
+
566
+ // Lazy extraction to prevent infinite loops
567
+ const extract = (): Field =>
568
+ this.withName(name, () => ty.accept(this, label)) as Field
569
+
570
+ // Helper to get inner default (evaluates lazily)
571
+ const getInnerDefault = (): unknown => extract().defaultValue
283
572
 
284
573
  return {
285
574
  type: "recursive",
286
575
  label,
287
- path,
288
- // Lazy extraction to prevent infinite loops
289
- extract: () =>
290
- this.withPath(path, () => ty.accept(this, label)) as ArgumentField,
576
+ displayLabel: formatLabel(label),
577
+ name,
578
+ component: "recursive-lazy",
579
+ renderHint: COMPOUND_RENDER_HINT,
580
+ typeName,
581
+ extract,
582
+ defaultValue: undefined,
583
+ schema,
584
+ getInnerDefault,
585
+ candidType: "rec",
291
586
  }
292
587
  }
293
588
 
@@ -295,111 +590,255 @@ export class ArgumentFieldVisitor<A = BaseActor> extends IDL.Visitor<
295
590
  // Primitive Types
296
591
  // ════════════════════════════════════════════════════════════════════════
297
592
 
298
- public visitPrincipal(
299
- _t: IDL.PrincipalClass,
300
- label: string
301
- ): PrincipalArgumentField {
593
+ public visitPrincipal(_t: IDL.PrincipalClass, label: string): PrincipalField {
594
+ const schema = z.custom<Principal>(
595
+ (val) => {
596
+ if (val instanceof Principal) return true
597
+ if (typeof val === "string") {
598
+ try {
599
+ Principal.fromText(val)
600
+ return true
601
+ } catch {
602
+ return false
603
+ }
604
+ }
605
+ return false
606
+ },
607
+ {
608
+ message: "Invalid Principal format",
609
+ }
610
+ )
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
+
302
621
  return {
303
622
  type: "principal",
304
623
  label,
305
- path: this.currentPath(),
624
+ displayLabel: formatLabel(label),
625
+ name: this.currentName(),
626
+ component: "principal-input",
627
+ renderHint: TEXT_RENDER_HINT,
306
628
  defaultValue: "",
307
629
  maxLength: 64,
308
630
  minLength: 7,
631
+ schema,
632
+ inputProps,
633
+ candidType: "principal",
309
634
  }
310
635
  }
311
636
 
312
- public visitText(_t: IDL.TextClass, label: string): TextArgumentField {
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
+
313
644
  return {
314
645
  type: "text",
315
646
  label,
316
- path: this.currentPath(),
647
+ displayLabel: formatLabel(label),
648
+ name: this.currentName(),
649
+ component: "text-input",
650
+ renderHint: TEXT_RENDER_HINT,
317
651
  defaultValue: "",
652
+ schema: z.string().min(1, "Required"),
653
+ inputProps,
654
+ candidType: "text",
318
655
  }
319
656
  }
320
657
 
321
- public visitBool(_t: IDL.BoolClass, label: string): BooleanArgumentField {
658
+ public visitBool(_t: IDL.BoolClass, label: string): BooleanField {
659
+ const inputProps: PrimitiveInputProps = {
660
+ type: "checkbox",
661
+ }
662
+
322
663
  return {
323
664
  type: "boolean",
324
665
  label,
325
- path: this.currentPath(),
666
+ displayLabel: formatLabel(label),
667
+ name: this.currentName(),
668
+ component: "boolean-checkbox",
669
+ renderHint: CHECKBOX_RENDER_HINT,
326
670
  defaultValue: false,
671
+ schema: z.boolean(),
672
+ inputProps,
673
+ candidType: "bool",
327
674
  }
328
675
  }
329
676
 
330
- public visitNull(_t: IDL.NullClass, label: string): NullArgumentField {
677
+ public visitNull(_t: IDL.NullClass, label: string): NullField {
331
678
  return {
332
679
  type: "null",
333
680
  label,
334
- path: this.currentPath(),
681
+ displayLabel: formatLabel(label),
682
+ name: this.currentName(),
683
+ component: "null-hidden",
684
+ renderHint: {
685
+ isCompound: false,
686
+ isPrimitive: true,
687
+ },
335
688
  defaultValue: null,
689
+ schema: z.null(),
690
+ candidType: "null",
336
691
  }
337
692
  }
338
693
 
339
- // Numbers - all use string for display format
694
+ // ════════════════════════════════════════════════════════════════════════
695
+ // Number Types with Constraints
696
+ // ════════════════════════════════════════════════════════════════════════
697
+
340
698
  private visitNumberType(
341
699
  label: string,
342
- candidType: string
343
- ): NumberArgumentField {
700
+ candidType: string,
701
+ options: {
702
+ unsigned: boolean
703
+ isFloat: boolean
704
+ bits?: number
705
+ min?: string
706
+ max?: string
707
+ }
708
+ ): NumberField | TextField {
709
+ let schema = z.string().min(1, "Required")
710
+
711
+ if (options.isFloat) {
712
+ schema = schema.refine((val) => !isNaN(Number(val)), "Must be a number")
713
+ } else if (options.unsigned) {
714
+ schema = schema.regex(/^\d+$/, "Must be a positive number")
715
+ } else {
716
+ schema = schema.regex(/^-?\d+$/, "Must be a number")
717
+ }
718
+
719
+ // Use "text" type for large numbers (BigInt) to ensure precision and better UI handling
720
+ // Standard number input has issues with large integers
721
+ const isBigInt = !options.isFloat && (!options.bits || options.bits > 32)
722
+ const type = isBigInt ? "text" : "number"
723
+
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
+
734
+ return {
735
+ type: "text",
736
+ label,
737
+ displayLabel: formatLabel(label),
738
+ name: this.currentName(),
739
+ component: "text-input",
740
+ renderHint: TEXT_RENDER_HINT,
741
+ defaultValue: "",
742
+ candidType,
743
+ schema,
744
+ inputProps,
745
+ }
746
+ }
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
+
344
757
  return {
345
758
  type: "number",
346
759
  label,
347
- path: this.currentPath(),
760
+ displayLabel: formatLabel(label),
761
+ name: this.currentName(),
762
+ component: "number-input",
763
+ renderHint: NUMBER_RENDER_HINT,
348
764
  defaultValue: "",
349
765
  candidType,
766
+ schema: schema,
767
+ inputProps,
768
+ ...options,
350
769
  }
351
770
  }
352
771
 
353
- public visitInt(_t: IDL.IntClass, label: string): NumberArgumentField {
354
- return this.visitNumberType(label, "int")
772
+ public visitInt(_t: IDL.IntClass, label: string): NumberField | TextField {
773
+ return this.visitNumberType(label, "int", {
774
+ unsigned: false,
775
+ isFloat: false,
776
+ })
355
777
  }
356
778
 
357
- public visitNat(_t: IDL.NatClass, label: string): NumberArgumentField {
358
- return this.visitNumberType(label, "nat")
779
+ public visitNat(_t: IDL.NatClass, label: string): NumberField | TextField {
780
+ return this.visitNumberType(label, "nat", {
781
+ unsigned: true,
782
+ isFloat: false,
783
+ })
359
784
  }
360
785
 
361
- public visitFloat(_t: IDL.FloatClass, label: string): NumberArgumentField {
362
- return this.visitNumberType(label, "float")
786
+ public visitFloat(t: IDL.FloatClass, label: string): NumberField {
787
+ return this.visitNumberType(label, `float${t._bits}`, {
788
+ unsigned: false,
789
+ isFloat: true,
790
+ bits: t._bits,
791
+ }) as NumberField
363
792
  }
364
793
 
365
794
  public visitFixedInt(
366
795
  t: IDL.FixedIntClass,
367
796
  label: string
368
- ): NumberArgumentField {
369
- return this.visitNumberType(label, `int${t._bits}`)
797
+ ): NumberField | TextField {
798
+ const bits = t._bits
799
+ // Calculate min/max for signed integers
800
+ const max = (BigInt(2) ** BigInt(bits - 1) - BigInt(1)).toString()
801
+ const min = (-(BigInt(2) ** BigInt(bits - 1))).toString()
802
+
803
+ return this.visitNumberType(label, `int${bits}`, {
804
+ unsigned: false,
805
+ isFloat: false,
806
+ bits,
807
+ min,
808
+ max,
809
+ })
370
810
  }
371
811
 
372
812
  public visitFixedNat(
373
813
  t: IDL.FixedNatClass,
374
814
  label: string
375
- ): NumberArgumentField {
376
- return this.visitNumberType(label, `nat${t._bits}`)
815
+ ): NumberField | TextField {
816
+ const bits = t._bits
817
+ // Calculate max for unsigned integers
818
+ const max = (BigInt(2) ** BigInt(bits) - BigInt(1)).toString()
819
+
820
+ return this.visitNumberType(label, `nat${bits}`, {
821
+ unsigned: true,
822
+ isFloat: false,
823
+ bits,
824
+ min: "0",
825
+ max,
826
+ })
377
827
  }
378
828
 
379
- public visitType<T>(_t: IDL.Type<T>, label: string): UnknownArgumentField {
829
+ public visitType<T>(_t: IDL.Type<T>, label: string): UnknownField {
380
830
  return {
381
831
  type: "unknown",
382
832
  label,
383
- path: this.currentPath(),
833
+ displayLabel: formatLabel(label),
834
+ name: this.currentName(),
835
+ component: "unknown-fallback",
836
+ renderHint: {
837
+ isCompound: false,
838
+ isPrimitive: false,
839
+ },
384
840
  defaultValue: undefined,
841
+ schema: z.any(),
385
842
  }
386
843
  }
387
844
  }
388
-
389
- // ════════════════════════════════════════════════════════════════════════════
390
- // Legacy Exports (for backward compatibility)
391
- // ════════════════════════════════════════════════════════════════════════════
392
-
393
- /**
394
- * @deprecated Use ArgumentFieldVisitor instead
395
- */
396
- export { ArgumentFieldVisitor as VisitTanstackField }
397
-
398
- /**
399
- * @deprecated Use ArgumentField instead
400
- */
401
- export type {
402
- ArgumentField as TanstackAllArgTypes,
403
- MethodArgumentsMeta as TanstackMethodField,
404
- ServiceArgumentsMeta as TanstackServiceField,
405
- }