@playfast/reform-forms 0.0.7 → 0.0.8

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/src/form.ts ADDED
@@ -0,0 +1,644 @@
1
+ import {
2
+ Context,
3
+ Effect,
4
+ Either,
5
+ Layer,
6
+ Option,
7
+ Runtime,
8
+ Schema as S,
9
+ } from 'effect'
10
+ import {
11
+ Bus,
12
+ CurrentTracker,
13
+ Event,
14
+ Reducer,
15
+ Reducers,
16
+ State,
17
+ type Store,
18
+ type Trigger,
19
+ } from '@playfast/reform'
20
+ import type { AnySource, Source } from '@playfast/reform'
21
+ import {
22
+ getNestedValue,
23
+ isPathOrParentDirty,
24
+ moveAt,
25
+ recalculateDirtyPaths,
26
+ replaceAt,
27
+ setNestedValue,
28
+ type ArrayItem,
29
+ type ArrayPath,
30
+ type FieldPath,
31
+ type PathValue,
32
+ type VariantPath,
33
+ type VariantValue,
34
+ } from './path'
35
+ import {
36
+ error,
37
+ errorsToRecord,
38
+ firstError,
39
+ normalizeErrors,
40
+ routeParseError,
41
+ type FormError,
42
+ type FormErrors,
43
+ } from './validation'
44
+
45
+ export { error }
46
+
47
+ export interface FieldLimitations<A = unknown> {
48
+ readonly required?: boolean
49
+ readonly disabled?: boolean
50
+ readonly readonly?: boolean
51
+ readonly visible?: boolean
52
+ readonly min?: number
53
+ readonly max?: number
54
+ readonly minLength?: number
55
+ readonly maxLength?: number
56
+ readonly minItems?: number
57
+ readonly maxItems?: number
58
+ readonly options?: ReadonlyArray<A>
59
+ readonly meta?: Readonly<Record<string, unknown>>
60
+ }
61
+
62
+ export type Limitations = Readonly<Record<string, FieldLimitations>>
63
+
64
+ export interface FormState<Values> {
65
+ readonly values: Values
66
+ readonly initialValues: Values
67
+ readonly touched: Readonly<Record<string, boolean>>
68
+ readonly errors: FormErrors
69
+ readonly dirtyPaths: ReadonlyArray<string>
70
+ readonly submitCount: number
71
+ readonly validationCount: number
72
+ readonly lastSubmittedValues: Option.Option<Values>
73
+ readonly arrayKeys: Readonly<Record<string, ReadonlyArray<string>>>
74
+ }
75
+
76
+ export interface FieldBinding<A> {
77
+ readonly path: string
78
+ readonly value: A
79
+ readonly set: (value: A | ((prev: A) => A)) => void
80
+ readonly blur: () => void
81
+ readonly error: Option.Option<string>
82
+ readonly dirty: boolean
83
+ readonly touched: boolean
84
+ readonly validating: boolean
85
+ readonly limitations: FieldLimitations<A>
86
+ }
87
+
88
+ export interface ArrayItemView<Item> {
89
+ readonly key: string
90
+ readonly index: number
91
+ readonly value: Item
92
+ readonly remove: () => void
93
+ readonly move: (to: number) => void
94
+ }
95
+
96
+ export interface ArrayBinding<Item> {
97
+ readonly path: string
98
+ readonly items: ReadonlyArray<ArrayItemView<Item>>
99
+ readonly append: (value?: Item) => void
100
+ readonly remove: (index: number) => void
101
+ readonly move: (from: number, to: number) => void
102
+ readonly swap: (a: number, b: number) => void
103
+ readonly limitations: FieldLimitations<ReadonlyArray<Item>>
104
+ }
105
+
106
+ export interface FormView<Values, Inputs = {}> {
107
+ readonly values: Values
108
+ readonly inputs: Inputs
109
+ readonly errors: FormErrors
110
+ readonly dirty: boolean
111
+ readonly canSubmit: boolean
112
+ readonly submitCount: number
113
+ readonly validationCount: number
114
+ readonly lastSubmittedValues: Option.Option<Values>
115
+ readonly submit: () => void
116
+ readonly reset: () => void
117
+ readonly validate: () => void
118
+ readonly field: <P extends FieldPath<Values>>(path: P) => FieldBinding<PathValue<Values, P>>
119
+ readonly array: <P extends ArrayPath<Values>>(path: P) => ArrayBinding<ArrayItem<Values, P>>
120
+ readonly variantValue: <P extends VariantPath<Values>>(path: P) => VariantValue<Values, P>
121
+ }
122
+
123
+ type InputRecord = Readonly<Record<string, AnySource>>
124
+
125
+ // Extracted structurally (`S['name']`) rather than through a `Source<infer N,
126
+ // unknown>` conditional: `Source`'s value parameter is invariant (`in out A`),
127
+ // so a concrete `StateToken<'x', V>` never matches `Source<_, unknown>` and the
128
+ // conditional would erase every input's key to `never`.
129
+ type SourceName<S extends AnySource> = S['name']
130
+ type SourceValue<S> = S extends Source<string, infer A> ? A : never
131
+
132
+ type InputsObject<Inputs extends InputRecord> = {
133
+ readonly [K in keyof Inputs as SourceName<Inputs[K]>]: SourceValue<Inputs[K]>
134
+ }
135
+
136
+ type InputStores<Inputs extends InputRecord> = {
137
+ [K in keyof Inputs]: Inputs[K] extends { readonly store: Context.Tag<infer Service, any> }
138
+ ? Service
139
+ : never
140
+ }[keyof Inputs]
141
+
142
+ type ValuesOfSchema<S extends S.Schema.Any> = S.Schema.Encoded<S>
143
+ type DecodedOfSchema<S extends S.Schema.Any> = S.Schema.Type<S>
144
+
145
+ export interface RuntimeConfig<Values, Decoded, Inputs> {
146
+ readonly initial: Values
147
+ readonly limit?: (ctx: {
148
+ readonly values: Values
149
+ readonly inputs: Inputs
150
+ }) => Limitations
151
+ readonly validate: (ctx: {
152
+ readonly values: Values
153
+ readonly decoded: Decoded
154
+ readonly inputs: Inputs
155
+ }) => Effect.Effect<ReadonlyArray<FormError>, never, never>
156
+ readonly submit: (ctx: {
157
+ readonly values: Values
158
+ readonly decoded: Decoded
159
+ readonly inputs: Inputs
160
+ }) => Effect.Effect<void, unknown, never>
161
+ }
162
+
163
+ export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
164
+ readonly initial: Values
165
+ readonly limit?: (ctx: {
166
+ readonly values: Values
167
+ readonly inputs: Inputs
168
+ }) => Limitations
169
+ readonly validate?: (ctx: {
170
+ readonly values: Values
171
+ readonly decoded: Decoded
172
+ readonly inputs: Inputs
173
+ }) =>
174
+ | void
175
+ | FormError
176
+ | ReadonlyArray<FormError>
177
+ | Effect.Effect<void | FormError | ReadonlyArray<FormError>, never, R>
178
+ readonly submit?: (ctx: {
179
+ readonly values: Values
180
+ readonly decoded: Decoded
181
+ readonly inputs: Inputs
182
+ }) => void | Effect.Effect<unknown, unknown, R>
183
+ }
184
+
185
+ export interface FormManifest<N extends string, Schema extends S.Schema.Any, Inputs extends InputRecord> {
186
+ readonly kind: 'Form'
187
+ readonly name: N
188
+ readonly schema: Schema
189
+ readonly inputs: Inputs
190
+ }
191
+
192
+ interface FormEvents {
193
+ readonly set: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
194
+ readonly blur: Event.EventClass<string, { readonly path: string }>
195
+ readonly reset: Event.EventClass<string, {}>
196
+ readonly validationFinished: Event.EventClass<string, { readonly errors: FormErrors }>
197
+ readonly submitAttempted: Event.EventClass<string, {}>
198
+ readonly submitSucceeded: Event.EventClass<string, { readonly values: unknown }>
199
+ readonly append: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
200
+ readonly remove: Event.EventClass<string, { readonly path: string; readonly index: number }>
201
+ readonly move: Event.EventClass<string, { readonly path: string; readonly from: number; readonly to: number }>
202
+ readonly swap: Event.EventClass<string, { readonly path: string; readonly a: number; readonly b: number }>
203
+ }
204
+
205
+ export interface FormClass<
206
+ N extends string,
207
+ Schema extends S.Schema.Any,
208
+ Inputs extends InputRecord,
209
+ > {
210
+ new (): {}
211
+ readonly manifest: FormManifest<N, Schema, Inputs>
212
+ readonly state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
213
+ readonly config: Context.Tag<
214
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
215
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
216
+ >
217
+ readonly events: FormEvents
218
+ readonly reducer: Reducer.StateReducerClass<
219
+ State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>,
220
+ ReadonlyArray<Event.AnyEvent>
221
+ >
222
+ }
223
+
224
+ // `any` (not `InputRecord`) in the inputs slot: the inputs type flows into the
225
+ // invariant `config` tag (via `InputsObject`), so a wildcard there must be `any`
226
+ // for a concrete form — whose inputs object has real keys — to remain assignable
227
+ // to `AnyForm`. Same Schema-variance escape the schema slot already uses.
228
+ export type AnyForm = FormClass<string, S.Schema.Any, any>
229
+
230
+ export type Values<F extends AnyForm> = F extends FormClass<string, infer Schema, any>
231
+ ? ValuesOfSchema<Schema>
232
+ : never
233
+
234
+ export type Decoded<F extends AnyForm> = F extends FormClass<string, infer Schema, any>
235
+ ? DecodedOfSchema<Schema>
236
+ : never
237
+
238
+ export type Inputs<F extends AnyForm> = F extends FormClass<string, S.Schema.Any, infer Inputs>
239
+ ? InputsObject<Inputs>
240
+ : never
241
+
242
+ export type View<F extends AnyForm> = FormView<Values<F>, Inputs<F>>
243
+
244
+ const unknownSchema = S.Unknown as S.Schema<any, any>
245
+
246
+ let nextArrayKey = 0
247
+ const makeArrayKey = (): string => `form-item-${nextArrayKey++}`
248
+
249
+ export const arrayKeysFor = (value: unknown, path = ''): Record<string, ReadonlyArray<string>> => {
250
+ 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}]`))
255
+ return
256
+ }
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
+ }
261
+ }
262
+ }
263
+ visit(value, path)
264
+ return out
265
+ }
266
+
267
+ const initialState = <Values>(initial: Values): FormState<Values> => ({
268
+ values: initial,
269
+ initialValues: initial,
270
+ touched: {},
271
+ errors: {},
272
+ dirtyPaths: [],
273
+ submitCount: 0,
274
+ validationCount: 0,
275
+ lastSubmittedValues: Option.none(),
276
+ arrayKeys: arrayKeysFor(initial),
277
+ })
278
+
279
+ const markTouched = (touched: Readonly<Record<string, boolean>>, path: string): Readonly<Record<string, boolean>> =>
280
+ ({ ...touched, [path]: true })
281
+
282
+ const withValues = <Values>(state: FormState<Values>, values: Values): FormState<Values> => ({
283
+ ...state,
284
+ values,
285
+ dirtyPaths: recalculateDirtyPaths(state.initialValues, values),
286
+ })
287
+
288
+ const currentArray = (values: unknown, path: string): ReadonlyArray<unknown> => {
289
+ const current = getNestedValue(values, path)
290
+ return Array.isArray(current) ? current : []
291
+ }
292
+
293
+ const updateKeys = (
294
+ state: FormState<unknown>,
295
+ path: string,
296
+ f: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
297
+ ): Readonly<Record<string, ReadonlyArray<string>>> => {
298
+ const existing = state.arrayKeys[path] ?? currentArray(state.values, path).map(() => makeArrayKey())
299
+ return { ...state.arrayKeys, [path]: f(existing) }
300
+ }
301
+
302
+ const setAtPath = <Values>(state: FormState<Values>, path: string, value: unknown): FormState<Values> =>
303
+ withValues(state, setNestedValue(state.values, path, value))
304
+
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])
312
+ return {
313
+ ...withValues(state, values),
314
+ arrayKeys: updateKeys(state as FormState<unknown>, path, (keys) => [...keys, makeArrayKey()]),
315
+ }
316
+ }
317
+
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))
326
+ return {
327
+ ...withValues(state, values),
328
+ arrayKeys: updateKeys(state as FormState<unknown>, path, (keys) => keys.filter((_, i) => i !== index)),
329
+ }
330
+ }
331
+
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)
342
+ return {
343
+ ...withValues(state, values),
344
+ arrayKeys: updateKeys(state as FormState<unknown>, path, (keys) => moveAt(keys, from, to)),
345
+ }
346
+ }
347
+
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)
358
+ 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()),
364
+ ),
365
+ }
366
+ }
367
+
368
+ export const make = <
369
+ const N extends string,
370
+ Schema extends S.Schema.Any,
371
+ const Inputs extends InputRecord = {},
372
+ >(
373
+ name: N,
374
+ config: {
375
+ readonly schema: Schema
376
+ readonly inputs?: Inputs
377
+ },
378
+ ): 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>>>
382
+ const events: FormEvents = {
383
+ set: Event.make(`${name}/SetField`, S.Struct({ path: S.String, value: S.Unknown })),
384
+ blur: Event.make(`${name}/BlurField`, S.Struct({ path: S.String })),
385
+ reset: Event.make(`${name}/Reset`, S.Struct({})),
386
+ validationFinished: Event.make(`${name}/ValidationFinished`, S.Struct({
387
+ errors: S.Record({ key: S.String, value: S.String }),
388
+ })),
389
+ submitAttempted: Event.make(`${name}/SubmitAttempted`, S.Struct({})),
390
+ submitSucceeded: Event.make(`${name}/SubmitSucceeded`, S.Struct({ values: S.Unknown })),
391
+ append: Event.make(`${name}/AppendItem`, S.Struct({ path: S.String, value: S.Unknown })),
392
+ remove: Event.make(`${name}/RemoveItem`, S.Struct({ path: S.String, index: S.Number })),
393
+ move: Event.make(`${name}/MoveItem`, S.Struct({ path: S.String, from: S.Number, to: S.Number })),
394
+ swap: Event.make(`${name}/SwapItems`, S.Struct({ path: S.String, a: S.Number, b: S.Number })),
395
+ }
396
+ const reducer = Reducer.make(`${name}/FormReducer`, {
397
+ states: [state],
398
+ events: Object.values(events),
399
+ })
400
+ const runtimeConfig = Context.GenericTag<
401
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
402
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
403
+ >(`reform/form/${name}/config`)
404
+ const inputs = (config.inputs ?? {}) as Inputs
405
+ return Object.assign(class {}, {
406
+ manifest: {
407
+ kind: 'Form' as const,
408
+ name,
409
+ schema: config.schema,
410
+ inputs,
411
+ },
412
+ state,
413
+ config: runtimeConfig,
414
+ events,
415
+ reducer,
416
+ }) as unknown as FormClass<N, Schema, Inputs>
417
+ }
418
+
419
+ export const live = <
420
+ F extends AnyForm,
421
+ R = never,
422
+ >(
423
+ form: F,
424
+ config: FormLiveConfig<Values<F>, Decoded<F>, Inputs<F>, R>,
425
+ ): Layer.Layer<
426
+ Store<FormState<Values<F>>> | RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>,
427
+ never,
428
+ Bus | Reducers | R
429
+ > => {
430
+ const configTag = form.config as unknown as Context.Tag<
431
+ RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>,
432
+ RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>>
433
+ >
434
+ const configLayer = Layer.effect(
435
+ configTag,
436
+ Effect.map(Effect.context<R | Bus>(), (context) => {
437
+ const runtimeConfig: RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>> = {
438
+ initial: config.initial,
439
+ 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>
446
+ },
447
+ 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>
451
+ },
452
+ }
453
+ return config.limit === undefined
454
+ ? runtimeConfig
455
+ : { ...runtimeConfig, limit: config.limit }
456
+ }),
457
+ )
458
+
459
+ type InternalEvent =
460
+ | { readonly _tag: string; readonly path: string; readonly value: unknown }
461
+ | { readonly _tag: string; readonly path: string }
462
+ | { readonly _tag: string; readonly errors: FormErrors }
463
+ | { readonly _tag: string; readonly values: unknown }
464
+ | { readonly _tag: string; readonly path: string; readonly index: number }
465
+ | { readonly _tag: string; readonly path: string; readonly from: number; readonly to: number }
466
+ | { readonly _tag: string; readonly path: string; readonly a: number; readonly b: number }
467
+ | { readonly _tag: string }
468
+
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:
474
+ return 'path' in event ? { ...state, touched: markTouched(state.touched, event.path) } : state
475
+ case form.events.reset.tag:
476
+ 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:
480
+ 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
+ })
495
+
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
+ >
503
+ }
504
+
505
+ const readInput = <Source extends AnySource>(
506
+ source: Source,
507
+ ): 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>>>
514
+
515
+ const readInputs = <Inputs extends InputRecord>(
516
+ inputs: Inputs,
517
+ ): 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>>
525
+
526
+ const limitationsFor = <A>(
527
+ limitations: Limitations,
528
+ path: string,
529
+ ): FieldLimitations<A> =>
530
+ (limitations[path] ?? {}) as FieldLimitations<A>
531
+
532
+ const trigger = <P>(eventTrigger: Effect.Effect<Trigger<P>, never, Bus>): Effect.Effect<Trigger<P>, never, Bus> =>
533
+ eventTrigger
534
+
535
+ export const view = <F extends AnyForm>(
536
+ form: F,
537
+ ): Effect.Effect<View<F>, never, Store<FormState<Values<F>>> | RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>> | Bus | InputStores<F['manifest']['inputs']>> =>
538
+ Effect.gen(function* () {
539
+ const state = yield* form.state
540
+ const runtimeConfig = yield* form.config
541
+ // Erased boundary (same shape as the casts closing `live`/`readInputs`):
542
+ // under the `AnyForm` constraint `form.manifest.inputs` is the wildcard
543
+ // `any`, which would dissolve the generator's R to `unknown`; restate the
544
+ // 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 }) ?? {}
547
+ const setField = yield* trigger(form.events.set.trigger)
548
+ const blurField = yield* trigger(form.events.blur.trigger)
549
+ const reset = yield* trigger(form.events.reset.trigger)
550
+ const append = yield* trigger(form.events.append.trigger)
551
+ const remove = yield* trigger(form.events.remove.trigger)
552
+ const move = yield* trigger(form.events.move.trigger)
553
+ const swap = yield* trigger(form.events.swap.trigger)
554
+ const runtime = yield* Effect.runtime<Bus>()
555
+
556
+ const schema = form.manifest.schema as S.Schema<Decoded<F>, Values<F>, never>
557
+ const decodeEither = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
558
+ const canSubmit = Either.isRight(decodeEither) && Object.keys(state.errors).length === 0
559
+
560
+ const runValidation = (publishSubmitAttempt: boolean) =>
561
+ Effect.gen(function* () {
562
+ if (publishSubmitAttempt) yield* Event.dispatch(form.events.submitAttempted, {})
563
+ const decoded = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
564
+ if (Either.isLeft(decoded)) {
565
+ yield* Event.dispatch(form.events.validationFinished, { errors: routeParseError(decoded.left) })
566
+ return Option.none<Decoded<F>>()
567
+ }
568
+ const custom = yield* runtimeConfig.validate({ values: state.values, decoded: decoded.right, inputs })
569
+ const errors = errorsToRecord(custom)
570
+ 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>>()
572
+ })
573
+
574
+ const submit = () => {
575
+ Runtime.runFork(runtime)(
576
+ Effect.gen(function* () {
577
+ const decoded = yield* runValidation(true)
578
+ if (Option.isNone(decoded)) return
579
+ yield* runtimeConfig.submit({ values: state.values, decoded: decoded.value, inputs })
580
+ yield* Event.dispatch(form.events.submitSucceeded, { values: state.values })
581
+ }).pipe(Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause))),
582
+ )
583
+ }
584
+
585
+ const validate = () => {
586
+ Runtime.runFork(runtime)(runValidation(false))
587
+ }
588
+
589
+ 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>
591
+ return {
592
+ 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
+ }),
599
+ blur: () => blurField({ path }),
600
+ error: firstError(state.errors, path),
601
+ dirty: isPathOrParentDirty(state.dirtyPaths, path),
602
+ touched: state.touched[path] === true,
603
+ validating: false,
604
+ limitations: limitationsFor<PathValue<Values<F>, P>>(limitations, path),
605
+ }
606
+ }
607
+
608
+ 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}`)
611
+ return {
612
+ path,
613
+ items: values.map((value, index) => ({
614
+ value,
615
+ index,
616
+ key: keys[index] ?? `${path}-${index}`,
617
+ remove: () => remove({ path, index }),
618
+ move: (to) => move({ path, from: index, to }),
619
+ })),
620
+ append: (value) => append({ path, value }),
621
+ remove: (index) => remove({ path, index }),
622
+ move: (from, to) => move({ path, from, to }),
623
+ swap: (a, b) => swap({ path, a, b }),
624
+ limitations: limitationsFor<ReadonlyArray<ArrayItem<Values<F>, P>>>(limitations, path),
625
+ }
626
+ }
627
+
628
+ return {
629
+ values: state.values,
630
+ inputs,
631
+ errors: state.errors,
632
+ dirty: state.dirtyPaths.length > 0,
633
+ canSubmit,
634
+ submitCount: state.submitCount,
635
+ validationCount: state.validationCount,
636
+ lastSubmittedValues: state.lastSubmittedValues,
637
+ submit,
638
+ reset: () => reset({}),
639
+ validate,
640
+ field,
641
+ array,
642
+ variantValue: (path) => getNestedValue(state.values, path) as VariantValue<Values<F>, typeof path>,
643
+ } as View<F>
644
+ })
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ export * as Form from './form'
2
+ export type {
3
+ ArrayBinding,
4
+ ArrayItemView,
5
+ AnyForm,
6
+ FieldBinding,
7
+ FieldLimitations,
8
+ FormClass,
9
+ FormLiveConfig,
10
+ FormManifest,
11
+ RuntimeConfig,
12
+ FormState,
13
+ FormView,
14
+ Inputs,
15
+ Limitations,
16
+ Values,
17
+ View,
18
+ } from './form'
19
+ export {
20
+ arrayKeysFor as unsafeArrayKeysFor,
21
+ error,
22
+ live,
23
+ make,
24
+ view,
25
+ } from './form'
26
+ export type {
27
+ ArrayItem,
28
+ ArrayPath,
29
+ FieldPath,
30
+ PathValue,
31
+ VariantBody,
32
+ VariantCase,
33
+ VariantPath,
34
+ VariantTag,
35
+ VariantValue,
36
+ } from './path'
37
+ export {
38
+ getNestedValue,
39
+ isPathOrParentDirty,
40
+ joinPath,
41
+ moveAt,
42
+ recalculateDirtyPaths,
43
+ schemaPathToFieldPath,
44
+ setNestedValue,
45
+ } from './path'
46
+ export type { FormError, FormErrors } from './validation'
47
+ export { errorsToRecord, firstError, normalizeErrors, routeParseError } from './validation'