@playfast/reform-forms 0.0.9 → 0.0.11

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@playfast/reform-forms",
3
3
  "playbook": "./playbook",
4
- "version": "0.0.9",
4
+ "version": "0.0.11",
5
5
  "type": "module",
6
6
  "description": "Headless, schema-driven form state for reform — values, validation, field limitations, list operations, and submit flow, rendering nothing.",
7
7
  "keywords": [
package/src/form.ts CHANGED
@@ -2,8 +2,10 @@ import {
2
2
  Context,
3
3
  Effect,
4
4
  Either,
5
+ Function as Fn,
5
6
  Layer,
6
7
  Option,
8
+ Record,
7
9
  Runtime,
8
10
  Schema as S,
9
11
  } from 'effect'
@@ -44,7 +46,10 @@ import {
44
46
 
45
47
  export { error }
46
48
 
47
- export interface FieldLimitations<A = unknown> {
49
+ // Plain-JSON UI-contract shape (flows into FieldBinding.limitations, serialized
50
+ // over the wire): optional knobs are part of the external contract, so the
51
+ // `ExternalApi` postfix opts this out of the Option-only field rule.
52
+ export interface FieldLimitationsExternalApi<A = unknown> {
48
53
  readonly required?: boolean
49
54
  readonly disabled?: boolean
50
55
  readonly readonly?: boolean
@@ -59,6 +64,8 @@ export interface FieldLimitations<A = unknown> {
59
64
  readonly meta?: Readonly<Record<string, unknown>>
60
65
  }
61
66
 
67
+ export type FieldLimitations<A = unknown> = FieldLimitationsExternalApi<A>
68
+
62
69
  export type Limitations = Readonly<Record<string, FieldLimitations>>
63
70
 
64
71
  export interface FormState<Values> {
@@ -144,6 +151,7 @@ type DecodedOfSchema<S extends S.Schema.Any> = S.Schema.Type<S>
144
151
 
145
152
  export interface RuntimeConfig<Values, Decoded, Inputs> {
146
153
  readonly initial: Values
154
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob (not a serializable shape); Option would break external form definitions
147
155
  readonly limit?: (ctx: {
148
156
  readonly values: Values
149
157
  readonly inputs: Inputs
@@ -162,10 +170,12 @@ export interface RuntimeConfig<Values, Decoded, Inputs> {
162
170
 
163
171
  export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
164
172
  readonly initial: Values
173
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
165
174
  readonly limit?: (ctx: {
166
175
  readonly values: Values
167
176
  readonly inputs: Inputs
168
177
  }) => Limitations
178
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
169
179
  readonly validate?: (ctx: {
170
180
  readonly values: Values
171
181
  readonly decoded: Decoded
@@ -175,6 +185,7 @@ export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
175
185
  | FormError
176
186
  | ReadonlyArray<FormError>
177
187
  | Effect.Effect<void | FormError | ReadonlyArray<FormError>, never, R>
188
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
178
189
  readonly submit?: (ctx: {
179
190
  readonly values: Values
180
191
  readonly decoded: Decoded
@@ -241,26 +252,26 @@ export type Inputs<F extends AnyForm> = F extends FormClass<string, S.Schema.Any
241
252
 
242
253
  export type View<F extends AnyForm> = FormView<Values<F>, Inputs<F>>
243
254
 
244
- const unknownSchema = S.Unknown as S.Schema<any, any>
255
+ const unknownSchema: S.Schema<any, any> = Fn.unsafeCoerce(S.Unknown)
245
256
 
246
- let nextArrayKey = 0
247
- const makeArrayKey = (): string => `form-item-${nextArrayKey++}`
257
+ const arrayKeyCounter = { current: 0 }
258
+ const makeArrayKey = (): string => `form-item-${arrayKeyCounter.current++}`
248
259
 
249
- export const arrayKeysFor = (value: unknown, path = ''): Record<string, ReadonlyArray<string>> => {
260
+ export const arrayKeysFor = (source: unknown, path = ''): Record<string, ReadonlyArray<string>> => {
250
261
  const out: Record<string, ReadonlyArray<string>> = {}
251
- const visit = (current: unknown, currentPath: string): void => {
252
- if (Array.isArray(current)) {
253
- out[currentPath] = current.map(() => makeArrayKey())
254
- current.forEach((item, index) => visit(item, `${currentPath}[${index}]`))
262
+ const visit = (node: KeyVisitNode): void => {
263
+ if (Array.isArray(node.current)) {
264
+ out[node.path] = node.current.map(() => makeArrayKey())
265
+ node.current.forEach((element, index) => visit({ current: element, path: `${node.path}[${index}]` }))
255
266
  return
256
267
  }
257
- if (current !== null && typeof current === 'object') {
258
- for (const [key, child] of Object.entries(current)) {
259
- visit(child, currentPath.length === 0 ? key : `${currentPath}.${key}`)
260
- }
268
+ if (node.current !== null && typeof node.current === 'object') {
269
+ Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(([key, child]) =>
270
+ visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
271
+ )
261
272
  }
262
273
  }
263
- visit(value, path)
274
+ visit({ current: source, path })
264
275
  return out
265
276
  }
266
277
 
@@ -279,106 +290,144 @@ const initialState = <Values>(initial: Values): FormState<Values> => ({
279
290
  const markTouched = (touched: Readonly<Record<string, boolean>>, path: string): Readonly<Record<string, boolean>> =>
280
291
  ({ ...touched, [path]: true })
281
292
 
282
- const withValues = <Values>(state: FormState<Values>, values: Values): FormState<Values> => ({
293
+ interface ArrayLookup {
294
+ readonly source: unknown
295
+ readonly path: string
296
+ }
297
+
298
+ interface KeyVisitNode {
299
+ readonly current: unknown
300
+ readonly path: string
301
+ }
302
+
303
+ interface PathInput<Values> {
304
+ readonly state: FormState<Values>
305
+ readonly path: string
306
+ }
307
+
308
+ interface SetPathInput<Values> extends PathInput<Values> {
309
+ readonly value: unknown
310
+ }
311
+
312
+ interface RemovePathInput<Values> extends PathInput<Values> {
313
+ readonly index: number
314
+ }
315
+
316
+ interface MovePathInput<Values> extends PathInput<Values> {
317
+ readonly from: number
318
+ readonly to: number
319
+ }
320
+
321
+ interface SwapPathInput<Values> extends PathInput<Values> {
322
+ readonly first: number
323
+ readonly second: number
324
+ }
325
+
326
+ const withValues = <Values>(state: FormState<Values>, nextValues: Values): FormState<Values> => ({
283
327
  ...state,
284
- values,
285
- dirtyPaths: recalculateDirtyPaths(state.initialValues, values),
328
+ values: nextValues,
329
+ dirtyPaths: recalculateDirtyPaths(state.initialValues, nextValues),
286
330
  })
287
331
 
288
- const currentArray = (values: unknown, path: string): ReadonlyArray<unknown> => {
289
- const current = getNestedValue(values, path)
332
+ const currentArray = (input: ArrayLookup): ReadonlyArray<unknown> => {
333
+ const current = getNestedValue(input.source, input.path)
290
334
  return Array.isArray(current) ? current : []
291
335
  }
292
336
 
293
337
  const updateKeys = (
294
338
  state: FormState<unknown>,
295
339
  path: string,
296
- f: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
340
+ transform: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
297
341
  ): Readonly<Record<string, ReadonlyArray<string>>> => {
298
- const existing = state.arrayKeys[path] ?? currentArray(state.values, path).map(() => makeArrayKey())
299
- return { ...state.arrayKeys, [path]: f(existing) }
342
+ const existing = state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
343
+ return { ...state.arrayKeys, [path]: transform(existing) }
300
344
  }
301
345
 
302
- const setAtPath = <Values>(state: FormState<Values>, path: string, value: unknown): FormState<Values> =>
303
- withValues(state, setNestedValue(state.values, path, value))
346
+ const setAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> =>
347
+ withValues(input.state, setNestedValue(input.state.values, input.path, input.value))
304
348
 
305
- const appendAtPath = <Values>(
306
- state: FormState<Values>,
307
- path: string,
308
- value: unknown,
309
- ): FormState<Values> => {
310
- const items = currentArray(state.values, path)
311
- const values = setNestedValue(state.values, path, [...items, value])
349
+ const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> => {
350
+ const elements = currentArray({ source: input.state.values, path: input.path })
351
+ const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
312
352
  return {
313
- ...withValues(state, values),
314
- arrayKeys: updateKeys(state as FormState<unknown>, path, (keys) => [...keys, makeArrayKey()]),
353
+ ...withValues(input.state, nextValues),
354
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [...keys, makeArrayKey()]),
315
355
  }
316
356
  }
317
357
 
318
- const removeAtPath = <Values>(
319
- state: FormState<Values>,
320
- path: string,
321
- index: number,
322
- ): FormState<Values> => {
323
- const items = currentArray(state.values, path)
324
- if (index < 0 || index >= items.length) return state
325
- const values = setNestedValue(state.values, path, items.filter((_, i) => i !== index))
358
+ const removeAtPath = <Values>(input: RemovePathInput<Values>): FormState<Values> => {
359
+ const elements = currentArray({ source: input.state.values, path: input.path })
360
+ if (input.index < 0 || input.index >= elements.length) {
361
+ return input.state
362
+ }
363
+ const nextValues = setNestedValue(
364
+ input.state.values,
365
+ input.path,
366
+ elements.filter((_, position) => position !== input.index),
367
+ )
326
368
  return {
327
- ...withValues(state, values),
328
- arrayKeys: updateKeys(state as FormState<unknown>, path, (keys) => keys.filter((_, i) => i !== index)),
369
+ ...withValues(input.state, nextValues),
370
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
371
+ keys.filter((_, position) => position !== input.index),
372
+ ),
329
373
  }
330
374
  }
331
375
 
332
- const moveAtPath = <Values>(
333
- state: FormState<Values>,
334
- path: string,
335
- from: number,
336
- to: number,
337
- ): FormState<Values> => {
338
- const items = currentArray(state.values, path)
339
- const next = moveAt(items, from, to)
340
- if (next === items) return state
341
- const values = setNestedValue(state.values, path, next)
376
+ const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Values> => {
377
+ const elements = currentArray({ source: input.state.values, path: input.path })
378
+ const next = moveAt(elements, input.from, input.to)
379
+ if (next === elements) {
380
+ return input.state
381
+ }
382
+ const nextValues = setNestedValue(input.state.values, input.path, next)
342
383
  return {
343
- ...withValues(state, values),
344
- arrayKeys: updateKeys(state as FormState<unknown>, path, (keys) => moveAt(keys, from, to)),
384
+ ...withValues(input.state, nextValues),
385
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => moveAt(keys, input.from, input.to)),
345
386
  }
346
387
  }
347
388
 
348
- const swapAtPath = <Values>(
349
- state: FormState<Values>,
350
- path: string,
351
- a: number,
352
- b: number,
353
- ): FormState<Values> => {
354
- const items = currentArray(state.values, path)
355
- if (a < 0 || b < 0 || a >= items.length || b >= items.length || a === b) return state
356
- const swapped = replaceAt(replaceAt(items, a, items[b]), b, items[a])
357
- const values = setNestedValue(state.values, path, swapped)
389
+ const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Values> => {
390
+ const elements = currentArray({ source: input.state.values, path: input.path })
391
+ if (
392
+ input.first < 0 ||
393
+ input.second < 0 ||
394
+ input.first >= elements.length ||
395
+ input.second >= elements.length ||
396
+ input.first === input.second
397
+ ) {
398
+ return input.state
399
+ }
400
+ const swapped = replaceAt(replaceAt(elements, input.first, elements[input.second]), input.second, elements[input.first])
401
+ const nextValues = setNestedValue(input.state.values, input.path, swapped)
358
402
  return {
359
- ...withValues(state, values),
360
- arrayKeys: updateKeys(
361
- state as FormState<unknown>,
362
- path,
363
- (keys) => replaceAt(replaceAt(keys, a, keys[b] ?? makeArrayKey()), b, keys[a] ?? makeArrayKey()),
403
+ ...withValues(input.state, nextValues),
404
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
405
+ replaceAt(
406
+ replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()),
407
+ input.second,
408
+ keys[input.first] ?? makeArrayKey(),
409
+ ),
364
410
  ),
365
411
  }
366
412
  }
367
413
 
414
+ interface MakeConfig<Schema extends S.Schema.Any, Inputs extends InputRecord> {
415
+ readonly schema: Schema
416
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional source map on the public make() config; Option would break callers passing only a schema
417
+ readonly inputs?: Inputs
418
+ }
419
+
368
420
  export const make = <
369
421
  const N extends string,
370
422
  Schema extends S.Schema.Any,
371
423
  const Inputs extends InputRecord = {},
372
424
  >(
373
425
  name: N,
374
- config: {
375
- readonly schema: Schema
376
- readonly inputs?: Inputs
377
- },
426
+ config: MakeConfig<Schema, Inputs>,
378
427
  ): FormClass<N, Schema, Inputs> => {
379
- const state = State.make(`${name}/state`, unknownSchema, {
380
- title: `${name} form state`,
381
- }) as State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
428
+ const state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>> = Fn.unsafeCoerce(
429
+ State.make(`${name}/state`, unknownSchema, { title: `${name} form state` }),
430
+ )
382
431
  const events: FormEvents = {
383
432
  set: Event.make(`${name}/SetField`, S.Struct({ path: S.String, value: S.Unknown })),
384
433
  blur: Event.make(`${name}/BlurField`, S.Struct({ path: S.String })),
@@ -401,19 +450,20 @@ export const make = <
401
450
  RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
402
451
  RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
403
452
  >(`reform/form/${name}/config`)
404
- const inputs = (config.inputs ?? {}) as Inputs
405
- return Object.assign(class {}, {
406
- manifest: {
453
+ const inputs: Inputs = Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(config.inputs), () => ({})))
454
+ class FormImpl {
455
+ static readonly manifest = {
407
456
  kind: 'Form' as const,
408
457
  name,
409
458
  schema: config.schema,
410
459
  inputs,
411
- },
412
- state,
413
- config: runtimeConfig,
414
- events,
415
- reducer,
416
- }) as unknown as FormClass<N, Schema, Inputs>
460
+ }
461
+ static readonly state = state
462
+ static readonly config = runtimeConfig
463
+ static readonly events = events
464
+ static readonly reducer = reducer
465
+ }
466
+ return Fn.unsafeCoerce(FormImpl)
417
467
  }
418
468
 
419
469
  export const live = <
@@ -427,27 +477,26 @@ export const live = <
427
477
  never,
428
478
  Bus | Reducers | R
429
479
  > => {
430
- const configTag = form.config as unknown as Context.Tag<
480
+ const configTag: Context.Tag<
431
481
  RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>,
432
482
  RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>
433
- >
483
+ > = Fn.unsafeCoerce(form.config)
434
484
  const configLayer = Layer.effect(
435
485
  configTag,
436
486
  Effect.map(Effect.context<R | Bus>(), (context) => {
437
487
  const runtimeConfig: RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>> = {
438
488
  initial: config.initial,
439
489
  validate: (ctx) => {
440
- const result = config.validate?.(ctx)
441
- const effect = Effect.isEffect(result) ? result : Effect.succeed(result)
442
- return Effect.map(
443
- Effect.provide(effect, context),
444
- (value) => normalizeErrors(value),
445
- ) as Effect.Effect<ReadonlyArray<FormError>, never, never>
490
+ const validationOutput = config.validate?.(ctx)
491
+ const effect = Effect.isEffect(validationOutput) ? validationOutput : Effect.succeed(validationOutput)
492
+ return Fn.unsafeCoerce(
493
+ Effect.map(Effect.provide(effect, context), (rawErrors) => normalizeErrors(rawErrors)),
494
+ )
446
495
  },
447
496
  submit: (ctx) => {
448
- const result = config.submit?.(ctx)
449
- const effect = Effect.isEffect(result) ? result : Effect.succeed(result)
450
- return Effect.asVoid(Effect.provide(effect, context)) as Effect.Effect<void, unknown, never>
497
+ const submitOutput = config.submit?.(ctx)
498
+ const effect = Effect.isEffect(submitOutput) ? submitOutput : Effect.succeed(submitOutput)
499
+ return Fn.unsafeCoerce(Effect.asVoid(Effect.provide(effect, context)))
451
500
  },
452
501
  }
453
502
  return config.limit === undefined
@@ -466,84 +515,115 @@ export const live = <
466
515
  | { readonly _tag: string; readonly path: string; readonly a: number; readonly b: number }
467
516
  | { readonly _tag: string }
468
517
 
469
- const reducerLayer = Reducer.live(form.reducer, (state: FormState<Values<F>>, event: InternalEvent): FormState<Values<F>> => {
470
- switch (event._tag) {
471
- case form.events.set.tag:
472
- return 'path' in event && 'value' in event ? setAtPath(state, event.path, event.value) : state
473
- case form.events.blur.tag:
518
+ const reducerLayer = Reducer.live(
519
+ form.reducer,
520
+ (state: FormState<Values<F>>, event: InternalEvent): FormState<Values<F>> => {
521
+ if (event._tag === form.events.set.tag) {
522
+ return 'path' in event && 'value' in event ? setAtPath({ state, path: event.path, value: event.value }) : state
523
+ }
524
+ if (event._tag === form.events.blur.tag) {
474
525
  return 'path' in event ? { ...state, touched: markTouched(state.touched, event.path) } : state
475
- case form.events.reset.tag:
526
+ }
527
+ if (event._tag === form.events.reset.tag) {
476
528
  return initialState(state.initialValues)
477
- case form.events.validationFinished.tag:
478
- return 'errors' in event ? { ...state, errors: event.errors, validationCount: state.validationCount + 1 } : state
479
- case form.events.submitAttempted.tag:
529
+ }
530
+ if (event._tag === form.events.validationFinished.tag) {
531
+ return 'errors' in event
532
+ ? { ...state, errors: event.errors, validationCount: state.validationCount + 1 }
533
+ : state
534
+ }
535
+ if (event._tag === form.events.submitAttempted.tag) {
480
536
  return { ...state, submitCount: state.submitCount + 1 }
481
- case form.events.submitSucceeded.tag:
482
- return 'values' in event ? { ...state, lastSubmittedValues: Option.some(event.values as Values<F>) } : state
483
- case form.events.append.tag:
484
- return 'path' in event && 'value' in event ? appendAtPath(state, event.path, event.value) : state
485
- case form.events.remove.tag:
486
- return 'path' in event && 'index' in event ? removeAtPath(state, event.path, event.index) : state
487
- case form.events.move.tag:
488
- return 'path' in event && 'from' in event && 'to' in event ? moveAtPath(state, event.path, event.from, event.to) : state
489
- case form.events.swap.tag:
490
- return 'path' in event && 'a' in event && 'b' in event ? swapAtPath(state, event.path, event.a, event.b) : state
491
- default:
492
- return state
493
- }
494
- })
537
+ }
538
+ if (event._tag === form.events.submitSucceeded.tag) {
539
+ return 'values' in event
540
+ ? { ...state, lastSubmittedValues: Option.some(Fn.unsafeCoerce<unknown, Values<F>>(event.values)) }
541
+ : state
542
+ }
543
+ if (event._tag === form.events.append.tag) {
544
+ return 'path' in event && 'value' in event
545
+ ? appendAtPath({ state, path: event.path, value: event.value })
546
+ : state
547
+ }
548
+ if (event._tag === form.events.remove.tag) {
549
+ return 'path' in event && 'index' in event
550
+ ? removeAtPath({ state, path: event.path, index: event.index })
551
+ : state
552
+ }
553
+ if (event._tag === form.events.move.tag) {
554
+ return 'path' in event && 'from' in event && 'to' in event
555
+ ? moveAtPath({ state, path: event.path, from: event.from, to: event.to })
556
+ : state
557
+ }
558
+ if (event._tag === form.events.swap.tag) {
559
+ return 'path' in event && 'a' in event && 'b' in event
560
+ ? swapAtPath({ state, path: event.path, first: event.a, second: event.b })
561
+ : state
562
+ }
563
+ return state
564
+ },
565
+ )
495
566
 
496
- return Layer.mergeAll(configLayer, reducerLayer).pipe(
497
- Layer.provideMerge(State.live(form.state, initialState(config.initial))),
498
- ) as Layer.Layer<
499
- Store<FormState<Values<F>>> | RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>,
500
- never,
501
- Bus | Reducers | R
502
- >
567
+ return Fn.unsafeCoerce(
568
+ Layer.mergeAll(configLayer, reducerLayer).pipe(
569
+ Layer.provideMerge(State.live(form.state, initialState(config.initial))),
570
+ ),
571
+ )
503
572
  }
504
573
 
505
574
  const readInput = <Source extends AnySource>(
506
575
  source: Source,
507
576
  ): Effect.Effect<SourceValue<Source>, never, Store<SourceValue<Source>>> =>
508
- Effect.gen(function* () {
509
- const store = yield* source.store
510
- const tracker = yield* Effect.serviceOption(CurrentTracker)
511
- if (Option.isSome(tracker)) tracker.value.add(store)
512
- return store.getSnapshot()
513
- }) as Effect.Effect<SourceValue<Source>, never, Store<SourceValue<Source>>>
577
+ Fn.unsafeCoerce(
578
+ Effect.gen(function* () {
579
+ const store = yield* source.store
580
+ const tracker = yield* Effect.serviceOption(CurrentTracker)
581
+ if (Option.isSome(tracker)) {
582
+ tracker.value.add(store)
583
+ }
584
+ return store.getSnapshot()
585
+ }),
586
+ )
514
587
 
515
588
  const readInputs = <Inputs extends InputRecord>(
516
589
  inputs: Inputs,
517
590
  ): Effect.Effect<InputsObject<Inputs>, never, InputStores<Inputs>> =>
518
- Effect.gen(function* () {
519
- const out: Record<string, unknown> = {}
520
- for (const source of Object.values(inputs)) {
521
- out[source.name] = yield* readInput(source)
522
- }
523
- return out as InputsObject<Inputs>
524
- }) as Effect.Effect<InputsObject<Inputs>, never, InputStores<Inputs>>
591
+ Fn.unsafeCoerce(
592
+ Effect.gen(function* () {
593
+ const pairs = yield* Effect.forEach(Object.values(inputs), (source) =>
594
+ Effect.map(readInput(source), (snapshot) => [source.name, snapshot] as const),
595
+ )
596
+ return Record.fromEntries(pairs)
597
+ }),
598
+ )
525
599
 
526
600
  const limitationsFor = <A>(
527
601
  limitations: Limitations,
528
602
  path: string,
529
603
  ): FieldLimitations<A> =>
530
- (limitations[path] ?? {}) as FieldLimitations<A>
604
+ Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(limitations[path]), () => ({})))
531
605
 
532
606
  const trigger = <P>(eventTrigger: Effect.Effect<Trigger<P>, never, Bus>): Effect.Effect<Trigger<P>, never, Bus> =>
533
607
  eventTrigger
534
608
 
609
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view: the explicit R (Store | RuntimeConfig | Bus | InputStores) must be preserved verbatim; Effect.fn would re-infer requirements off the erased AnyForm wildcard
535
610
  export const view = <F extends AnyForm>(
536
611
  form: F,
537
612
  ): Effect.Effect<View<F>, never, Store<FormState<Values<F>>> | RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>> | Bus | InputStores<F['manifest']['inputs']>> =>
538
613
  Effect.gen(function* () {
539
614
  const state = yield* form.state
540
615
  const runtimeConfig = yield* form.config
541
- // Erased boundary (same shape as the casts closing `live`/`readInputs`):
616
+ // Erased boundary (same shape as the coercions closing `live`/`readInputs`):
542
617
  // under the `AnyForm` constraint `form.manifest.inputs` is the wildcard
543
618
  // `any`, which would dissolve the generator's R to `unknown`; restate the
544
619
  // call at the concrete `F`'s types so R keeps the declared input stores.
545
- const inputs = (yield* readInputs(form.manifest.inputs as InputRecord)) as Inputs<F>
546
- const limitations = runtimeConfig.limit?.({ values: state.values, inputs }) ?? {}
620
+ const inputs: Inputs<F> = Fn.unsafeCoerce(
621
+ yield* readInputs(Fn.unsafeCoerce<typeof form.manifest.inputs, InputRecord>(form.manifest.inputs)),
622
+ )
623
+ const limitations = Option.getOrElse(
624
+ Option.fromNullable(runtimeConfig.limit?.({ values: state.values, inputs })),
625
+ () => ({}),
626
+ )
547
627
  const setField = yield* trigger(form.events.set.trigger)
548
628
  const blurField = yield* trigger(form.events.blur.trigger)
549
629
  const reset = yield* trigger(form.events.reset.trigger)
@@ -553,13 +633,16 @@ export const view = <F extends AnyForm>(
553
633
  const swap = yield* trigger(form.events.swap.trigger)
554
634
  const runtime = yield* Effect.runtime<Bus>()
555
635
 
556
- const schema = form.manifest.schema as S.Schema<Decoded<F>, Values<F>, never>
636
+ const schema: S.Schema<Decoded<F>, Values<F>, never> = Fn.unsafeCoerce(form.manifest.schema)
557
637
  const decodeEither = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
558
- const canSubmit = Either.isRight(decodeEither) && Object.keys(state.errors).length === 0
638
+ const canSubmit = Either.isRight(decodeEither) && Record.keys(state.errors).length === 0
559
639
 
640
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- local closure whose inferred R must flow into the parent view gen; Effect.fn would force an explicit Return annotation over the erased AnyForm requirements
560
641
  const runValidation = (publishSubmitAttempt: boolean) =>
561
642
  Effect.gen(function* () {
562
- if (publishSubmitAttempt) yield* Event.dispatch(form.events.submitAttempted, {})
643
+ if (publishSubmitAttempt) {
644
+ yield* Event.dispatch(form.events.submitAttempted, {})
645
+ }
563
646
  const decoded = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
564
647
  if (Either.isLeft(decoded)) {
565
648
  yield* Event.dispatch(form.events.validationFinished, { errors: routeParseError(decoded.left) })
@@ -568,14 +651,18 @@ export const view = <F extends AnyForm>(
568
651
  const custom = yield* runtimeConfig.validate({ values: state.values, decoded: decoded.right, inputs })
569
652
  const errors = errorsToRecord(custom)
570
653
  yield* Event.dispatch(form.events.validationFinished, { errors })
571
- return Object.keys(errors).length === 0 ? Option.some(decoded.right as Decoded<F>) : Option.none<Decoded<F>>()
654
+ return Record.keys(errors).length === 0
655
+ ? Option.some(Fn.unsafeCoerce<unknown, Decoded<F>>(decoded.right))
656
+ : Option.none<Decoded<F>>()
572
657
  })
573
658
 
574
659
  const submit = () => {
575
660
  Runtime.runFork(runtime)(
576
661
  Effect.gen(function* () {
577
662
  const decoded = yield* runValidation(true)
578
- if (Option.isNone(decoded)) return
663
+ if (Option.isNone(decoded)) {
664
+ return
665
+ }
579
666
  yield* runtimeConfig.submit({ values: state.values, decoded: decoded.value, inputs })
580
667
  yield* Event.dispatch(form.events.submitSucceeded, { values: state.values })
581
668
  }).pipe(Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause))),
@@ -587,15 +674,20 @@ export const view = <F extends AnyForm>(
587
674
  }
588
675
 
589
676
  const field = <P extends FieldPath<Values<F>>>(path: P): FieldBinding<PathValue<Values<F>, P>> => {
590
- const value = getNestedValue(state.values, path) as PathValue<Values<F>, P>
677
+ const fieldValue: PathValue<Values<F>, P> = Fn.unsafeCoerce(getNestedValue(state.values, path))
591
678
  return {
592
679
  path,
593
- value,
594
- set: (next) =>
595
- setField({
596
- path,
597
- value: typeof next === 'function' ? (next as (prev: PathValue<Values<F>, P>) => PathValue<Values<F>, P>)(value) : next,
598
- }),
680
+ value: fieldValue,
681
+ set: (next) => {
682
+ if (typeof next === 'function') {
683
+ const updater = Fn.unsafeCoerce<typeof next, (prev: PathValue<Values<F>, P>) => PathValue<Values<F>, P>>(
684
+ next,
685
+ )
686
+ setField({ path, value: updater(fieldValue) })
687
+ return
688
+ }
689
+ setField({ path, value: next })
690
+ },
599
691
  blur: () => blurField({ path }),
600
692
  error: firstError(state.errors, path),
601
693
  dirty: isPathOrParentDirty(state.dirtyPaths, path),
@@ -606,26 +698,28 @@ export const view = <F extends AnyForm>(
606
698
  }
607
699
 
608
700
  const array = <P extends ArrayPath<Values<F>>>(path: P): ArrayBinding<ArrayItem<Values<F>, P>> => {
609
- const values = currentArray(state.values, path) as ReadonlyArray<ArrayItem<Values<F>, P>>
610
- const keys = state.arrayKeys[path] ?? values.map((_, i) => `${path}-${i}`)
701
+ const arrayValues: ReadonlyArray<ArrayItem<Values<F>, P>> = Fn.unsafeCoerce(
702
+ currentArray({ source: state.values, path }),
703
+ )
704
+ const keys = state.arrayKeys[path] ?? arrayValues.map((_, index) => `${path}-${index}`)
611
705
  return {
612
706
  path,
613
- items: values.map((value, index) => ({
614
- value,
707
+ items: arrayValues.map((element, index) => ({
708
+ value: element,
615
709
  index,
616
710
  key: keys[index] ?? `${path}-${index}`,
617
711
  remove: () => remove({ path, index }),
618
712
  move: (to) => move({ path, from: index, to }),
619
713
  })),
620
- append: (value) => append({ path, value }),
714
+ append: (element) => append({ path, value: element }),
621
715
  remove: (index) => remove({ path, index }),
622
716
  move: (from, to) => move({ path, from, to }),
623
- swap: (a, b) => swap({ path, a, b }),
717
+ swap: (first, second) => swap({ path, a: first, b: second }),
624
718
  limitations: limitationsFor<ReadonlyArray<ArrayItem<Values<F>, P>>>(limitations, path),
625
719
  }
626
720
  }
627
721
 
628
- return {
722
+ const formView: View<F> = Fn.unsafeCoerce({
629
723
  values: state.values,
630
724
  inputs,
631
725
  errors: state.errors,
@@ -639,6 +733,7 @@ export const view = <F extends AnyForm>(
639
733
  validate,
640
734
  field,
641
735
  array,
642
- variantValue: (path) => getNestedValue(state.values, path) as VariantValue<Values<F>, typeof path>,
643
- } as View<F>
736
+ variantValue: (path: string) => Fn.unsafeCoerce(getNestedValue(state.values, path)),
737
+ })
738
+ return formView
644
739
  })
package/src/path.ts CHANGED
@@ -1,13 +1,19 @@
1
- import { Equal } from 'effect'
1
+ import { Equal, Function as Fn, Option, Order, Record, String as Str } from 'effect'
2
+ import { lastIndexOf as lastIndexOfOpt } from 'effect/String'
3
+ import { sort as sortArray } from 'effect/Array'
2
4
 
3
5
  const BRACKET_NOTATION_REGEX = /\[(\d+)\]/g
6
+ const NO_INDEX = -1
4
7
 
8
+ // `null` is a genuine leaf of arbitrary form values (JSON-shaped user data); it
9
+ // is matched here to stop path recursion at primitive leaves.
5
10
  export type Primitive =
6
11
  | string
7
12
  | number
8
13
  | boolean
9
14
  | bigint
10
15
  | symbol
16
+ // oxlint-disable-next-line reform-rules/no-null-literal -- arbitrary form values include JSON null leaves
11
17
  | null
12
18
  | undefined
13
19
  | Date
@@ -77,82 +83,113 @@ export type VariantCase<V, Tag extends string> = Extract<V, { readonly _tag: Tag
77
83
 
78
84
  export type VariantBody<V, Tag extends string> = Omit<VariantCase<V, Tag>, '_tag'>
79
85
 
80
- export const schemaPathToFieldPath = (path: ReadonlyArray<PropertyKey>): string => {
81
- if (path.length === 0) return ''
82
- let result = String(path[0])
83
- for (let i = 1; i < path.length; i++) {
84
- const segment = path[i]
85
- result += typeof segment === 'number' ? `[${segment}]` : `.${String(segment)}`
86
- }
87
- return result
88
- }
89
-
86
+ // Typed view over an arbitrary object for string-keyed traversal of unknown form
87
+ // values. `unsafeCoerce` is the Effect-blessed identity coercion (no `as`).
88
+ const asRecord = (source: unknown): Record<string, unknown> => Fn.unsafeCoerce(source)
89
+
90
+ export const schemaPathToFieldPath = (path: ReadonlyArray<PropertyKey>): string =>
91
+ path
92
+ .map((segment, index) =>
93
+ index === 0
94
+ ? String(segment)
95
+ : typeof segment === 'number'
96
+ ? `[${segment}]`
97
+ : `.${String(segment)}`,
98
+ )
99
+ .join('')
100
+
101
+ // oxlint-disable-next-line reform-rules/no-multiple-primitive-params -- public positional path API
90
102
  export const joinPath = (prefix: string, path: string): string =>
91
103
  prefix.length === 0 ? path : path.length === 0 ? prefix : `${prefix}.${path}`
92
104
 
105
+ // oxlint-disable-next-line reform-rules/no-multiple-primitive-params -- public positional path API
93
106
  export const isPathUnderRoot = (path: string, rootPath: string): boolean =>
94
107
  path === rootPath || path.startsWith(`${rootPath}.`) || path.startsWith(`${rootPath}[`)
95
108
 
96
109
  export const isPathOrParentDirty = (dirtyPaths: ReadonlyArray<string>, path: string): boolean => {
97
- if (dirtyPaths.includes(path)) return true
98
- let parent = path
99
- while (true) {
100
- const lastDot = parent.lastIndexOf('.')
101
- const lastBracket = parent.lastIndexOf('[')
110
+ const isDirty = (candidate: string): boolean => {
111
+ if (dirtyPaths.includes(candidate)) {
112
+ return true
113
+ }
114
+ const lastDot = Option.getOrElse(lastIndexOfOpt('.')(candidate), () => NO_INDEX)
115
+ const lastBracket = Option.getOrElse(lastIndexOfOpt('[')(candidate), () => NO_INDEX)
102
116
  const splitIndex = Math.max(lastDot, lastBracket)
103
- if (splitIndex === -1) return false
104
- parent = parent.substring(0, splitIndex)
105
- if (dirtyPaths.includes(parent)) return true
117
+ if (splitIndex === NO_INDEX) {
118
+ return false
119
+ }
120
+ return isDirty(candidate.substring(0, splitIndex))
106
121
  }
122
+ return isDirty(path)
107
123
  }
108
124
 
109
125
  const pathParts = (path: string): ReadonlyArray<string> =>
110
- path === '' ? [] : path.replace(BRACKET_NOTATION_REGEX, '.$1').split('.')
111
-
112
- export const getNestedValue = (obj: unknown, path: string): unknown => {
126
+ path === '' ? [] : Str.split('.')(path.replace(BRACKET_NOTATION_REGEX, '.$1'))
127
+
128
+ // oxlint-disable-next-line reform-rules/no-multiple-primitive-params -- public positional path accessor
129
+ export const getNestedValue = (source: unknown, path: string): unknown =>
130
+ pathParts(path).reduce<unknown>(
131
+ (current, part) =>
132
+ current === null || current === undefined || typeof current !== 'object'
133
+ ? undefined
134
+ : asRecord(current)[part],
135
+ source,
136
+ )
137
+
138
+ // oxlint-disable-next-line reform-rules/no-multiple-primitive-params -- public positional path setter
139
+ export const setNestedValue = <T>(target: T, path: string, nextValue: unknown): T => {
113
140
  const parts = pathParts(path)
114
- let current: unknown = obj
115
- for (const part of parts) {
116
- if (current === null || current === undefined || typeof current !== 'object') return undefined
117
- current = (current as Record<string, unknown>)[part]
141
+ if (parts.length === 0) {
142
+ return Fn.unsafeCoerce(nextValue)
118
143
  }
119
- return current
120
- }
121
-
122
- export const setNestedValue = <T>(obj: T, path: string, value: unknown): T => {
123
- const parts = pathParts(path)
124
- if (parts.length === 0) return value as T
125
- const result = Array.isArray(obj) ? [...obj] : { ...(obj as Record<string, unknown>) }
126
- let current = result as Record<string, unknown>
127
- for (let i = 0; i < parts.length - 1; i++) {
128
- const part = parts[i]
129
- if (part === undefined) return result as T
144
+ const root = Array.isArray(target) ? [...target] : { ...asRecord(target) }
145
+ const descend = (current: Record<string, unknown>, depth: number): void => {
146
+ const part = parts[depth]
147
+ if (part === undefined) {
148
+ return
149
+ }
150
+ if (depth === parts.length - 1) {
151
+ current[part] = nextValue
152
+ return
153
+ }
130
154
  const next = current[part]
131
- current[part] = Array.isArray(next) ? [...next] : { ...(next as Record<string, unknown>) }
132
- current = current[part] as Record<string, unknown>
155
+ const copy = Array.isArray(next) ? [...next] : { ...asRecord(next) }
156
+ current[part] = copy
157
+ descend(asRecord(copy), depth + 1)
133
158
  }
134
- const last = parts[parts.length - 1]
135
- if (last !== undefined) current[last] = value
136
- return result as T
159
+ descend(asRecord(root), 0)
160
+ return Fn.unsafeCoerce(root)
161
+ }
162
+
163
+ interface DiffNode {
164
+ readonly current: unknown
165
+ readonly original: unknown
166
+ readonly path: string
137
167
  }
138
168
 
169
+ // oxlint-disable-next-line reform-rules/no-multiple-primitive-params -- public positional diff over arbitrary form values
139
170
  export const recalculateDirtyPaths = (
140
171
  initial: unknown,
141
- values: unknown,
172
+ currentValues: unknown,
142
173
  rootPath = '',
143
174
  ): ReadonlyArray<string> => {
144
- const dirty = new Set<string>()
145
- const visit = (current: unknown, original: unknown, path: string): void => {
146
- if (Equal.equals(current, original)) return
175
+ const visit = (node: DiffNode): ReadonlyArray<string> => {
176
+ const { current, original, path } = node
177
+ if (Equal.equals(current, original)) {
178
+ return []
179
+ }
147
180
  if (Array.isArray(current) || Array.isArray(original)) {
148
181
  const currentItems = Array.isArray(current) ? current : []
149
182
  const originalItems = Array.isArray(original) ? original : []
150
- if (currentItems.length !== originalItems.length && path.length > 0) dirty.add(path)
183
+ const lengthChanged = currentItems.length !== originalItems.length && path.length > 0
151
184
  const length = Math.max(currentItems.length, originalItems.length)
152
- for (let i = 0; i < length; i++) {
153
- visit(currentItems[i], originalItems[i], path.length === 0 ? `[${i}]` : `${path}[${i}]`)
154
- }
155
- return
185
+ const childPaths = Array.from({ length }, (_, index) =>
186
+ visit({
187
+ current: currentItems[index],
188
+ original: originalItems[index],
189
+ path: path.length === 0 ? `[${index}]` : `${path}[${index}]`,
190
+ }),
191
+ ).flat()
192
+ return lengthChanged ? [path, ...childPaths] : childPaths
156
193
  }
157
194
  if (
158
195
  current !== null &&
@@ -160,35 +197,44 @@ export const recalculateDirtyPaths = (
160
197
  typeof current === 'object' &&
161
198
  typeof original === 'object'
162
199
  ) {
163
- const currentRecord = current as Record<string, unknown>
164
- const originalRecord = original as Record<string, unknown>
165
- const keys = new Set([...Object.keys(currentRecord), ...Object.keys(originalRecord)])
166
- for (const key of keys) {
167
- visit(currentRecord[key], originalRecord[key], path.length === 0 ? key : `${path}.${key}`)
168
- }
169
- return
200
+ const currentRecord = asRecord(current)
201
+ const originalRecord = asRecord(original)
202
+ const keys = Array.from(new Set([...Record.keys(currentRecord), ...Record.keys(originalRecord)]))
203
+ return keys.flatMap((key) =>
204
+ visit({
205
+ current: currentRecord[key],
206
+ original: originalRecord[key],
207
+ path: path.length === 0 ? key : `${path}.${key}`,
208
+ }),
209
+ )
170
210
  }
171
- if (path.length > 0) dirty.add(path)
211
+ return path.length > 0 ? [path] : []
172
212
  }
173
- visit(rootPath.length === 0 ? values : getNestedValue(values, rootPath), rootPath.length === 0 ? initial : getNestedValue(initial, rootPath), rootPath)
174
- return Array.from(dirty).sort()
213
+ const dirty = visit({
214
+ current: rootPath.length === 0 ? currentValues : getNestedValue(currentValues, rootPath),
215
+ original: rootPath.length === 0 ? initial : getNestedValue(initial, rootPath),
216
+ path: rootPath,
217
+ })
218
+ return sortArray(Order.string)(Array.from(new Set(dirty)))
175
219
  }
176
220
 
177
221
  export const replaceAt = <A>(
178
- values: ReadonlyArray<A>,
222
+ elements: ReadonlyArray<A>,
179
223
  index: number,
180
- value: A,
224
+ replacement: A,
181
225
  ): ReadonlyArray<A> =>
182
- values.map((item, i) => (i === index ? value : item))
226
+ elements.map((element, position) => (position === index ? replacement : element))
183
227
 
228
+ // oxlint-disable-next-line reform-rules/no-multiple-primitive-params -- public positional array move
184
229
  export const moveAt = <A>(
185
- values: ReadonlyArray<A>,
230
+ elements: ReadonlyArray<A>,
186
231
  from: number,
187
232
  to: number,
188
233
  ): ReadonlyArray<A> => {
189
- if (from < 0 || from >= values.length || to < 0 || to > values.length || from === to) return values
190
- const next = [...values]
191
- const item = next.splice(from, 1)[0]
192
- if (item !== undefined) next.splice(to, 0, item)
193
- return next
234
+ if (from < 0 || from >= elements.length || to < 0 || to > elements.length || from === to) {
235
+ return elements
236
+ }
237
+ const moved = elements[from]
238
+ const without = elements.filter((_, index) => index !== from)
239
+ return moved === undefined ? without : [...without.slice(0, to), moved, ...without.slice(to)]
194
240
  }
package/src/validation.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Option, ParseResult } from 'effect'
1
+ import { Array as Arr, Option, ParseResult } from 'effect'
2
2
  import { schemaPathToFieldPath } from './path'
3
3
 
4
4
  export interface FormError {
@@ -8,33 +8,33 @@ export interface FormError {
8
8
 
9
9
  export type FormErrors = Readonly<Record<string, string>>
10
10
 
11
+ // oxlint-disable-next-line reform-rules/no-multiple-primitive-params -- public positional FormError constructor; (path, message) is the documented API
11
12
  export const error = (path: string, message: string): FormError => ({ path, message })
12
13
 
13
14
  export const normalizeErrors = (
14
- value: void | FormError | ReadonlyArray<FormError>,
15
+ input: void | FormError | ReadonlyArray<FormError>,
15
16
  ): ReadonlyArray<FormError> => {
16
- if (value === undefined) return []
17
- if (Array.isArray(value)) return value as ReadonlyArray<FormError>
18
- return [value as FormError]
19
- }
20
-
21
- export const errorsToRecord = (errors: ReadonlyArray<FormError>): FormErrors => {
22
- const out: Record<string, string> = {}
23
- for (const entry of errors) {
24
- if (out[entry.path] === undefined) out[entry.path] = entry.message
17
+ if (input === undefined) {
18
+ return []
25
19
  }
26
- return out
20
+ return Arr.ensure(input)
27
21
  }
28
22
 
29
- export const routeParseError = (parseError: ParseResult.ParseError): FormErrors => {
30
- const formatted = ParseResult.ArrayFormatter.formatErrorSync(parseError)
31
- const out: Record<string, string> = {}
32
- for (const issue of formatted) {
33
- const path = schemaPathToFieldPath(issue.path)
34
- if (out[path] === undefined) out[path] = issue.message
35
- }
36
- return out
37
- }
23
+ export const errorsToRecord = (errors: ReadonlyArray<FormError>): FormErrors =>
24
+ errors.reduce<Record<string, string>>(
25
+ (accumulated, entry) =>
26
+ entry.path in accumulated ? accumulated : { ...accumulated, [entry.path]: entry.message },
27
+ {},
28
+ )
29
+
30
+ export const routeParseError = (parseError: ParseResult.ParseError): FormErrors =>
31
+ ParseResult.ArrayFormatter.formatErrorSync(parseError).reduce<Record<string, string>>(
32
+ (accumulated, issue) => {
33
+ const path = schemaPathToFieldPath(issue.path)
34
+ return path in accumulated ? accumulated : { ...accumulated, [path]: issue.message }
35
+ },
36
+ {},
37
+ )
38
38
 
39
39
  export const firstError = (errors: FormErrors, path: string): Option.Option<string> => {
40
40
  const message = errors[path]