@playfast/reform-forms 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/form.ts CHANGED
@@ -1,522 +1,53 @@
1
+ import { type Context, Effect, Function as Fn, Layer, Option, Schema as S } from 'effect'
2
+ import { Reducer, State } from '@playfast/reform'
3
+ import { Bus, Reducers } from '@playfast/reform/internal'
4
+ import { error, normalizeErrors, type FormErrors } from './validation'
1
5
  import {
2
- Context,
3
- Effect,
4
- Either,
5
- Function as Fn,
6
- Layer,
7
- Option,
8
- Record,
9
- Runtime,
10
- Schema as S,
11
- } from 'effect'
12
- import { Event, Reducer, State, type Trigger } from '@playfast/reform'
13
- import {
14
- type AnySource,
15
- Bus,
16
- CurrentTracker,
17
- Reducers,
18
- type SourceIdentifier,
19
- type SourceName,
20
- type SourceValue as ReformSourceValue,
21
- type Store,
22
- } from '@playfast/reform/internal'
23
- import {
24
- getNestedValue,
25
- isPathOrParentDirty,
26
- moveAt,
27
- recalculateDirtyPaths,
28
- replaceAt,
29
- setNestedValue,
30
- type ArrayItem,
31
- type ArrayPath,
32
- type FieldPath,
33
- type PathValue,
34
- type VariantPath,
35
- type VariantValue,
36
- } from './path'
37
- import {
38
- error,
39
- errorsToRecord,
40
- firstError,
41
- normalizeErrors,
42
- routeParseError,
43
- type FormError,
44
- type FormErrors,
45
- } from './validation'
6
+ appendAtPath,
7
+ initialState,
8
+ markTouched,
9
+ moveAtPath,
10
+ removeAtPath,
11
+ setAtPath,
12
+ swapAtPath,
13
+ } from './formState'
14
+ import type {
15
+ DecodedOfSchema,
16
+ FormClass,
17
+ FormLiveConfig,
18
+ FormState,
19
+ InputRecord,
20
+ InputsObject,
21
+ RuntimeConfig,
22
+ ValuesOfSchema,
23
+ } from './formTypes'
46
24
 
47
25
  export { error }
48
-
49
- export interface FieldLimitationsExternalApi<A = unknown> {
50
- readonly required?: boolean
51
- readonly disabled?: boolean
52
- readonly readonly?: boolean
53
- readonly visible?: boolean
54
- readonly min?: number
55
- readonly max?: number
56
- readonly minLength?: number
57
- readonly maxLength?: number
58
- readonly minItems?: number
59
- readonly maxItems?: number
60
- readonly options?: ReadonlyArray<A>
61
- readonly meta?: Readonly<Record<string, unknown>>
62
- }
63
-
64
- export type FieldLimitations<A = unknown> = FieldLimitationsExternalApi<A>
65
-
66
- export type Limitations = Readonly<Record<string, FieldLimitations>>
67
-
68
- export interface FormState<Values> {
69
- readonly values: Values
70
- readonly initialValues: Values
71
- readonly touched: Readonly<Record<string, boolean>>
72
- readonly errors: FormErrors
73
- readonly dirtyPaths: ReadonlyArray<string>
74
- readonly submitCount: number
75
- readonly validationCount: number
76
- readonly lastSubmittedValues: Option.Option<Values>
77
- readonly arrayKeys: Readonly<Record<string, ReadonlyArray<string>>>
78
- }
79
-
80
- export interface FieldBinding<A> {
81
- readonly path: string
82
- readonly value: A
83
- readonly set: (value: A | ((prev: A) => A)) => void
84
- readonly blur: () => void
85
- readonly error: Option.Option<string>
86
- readonly dirty: boolean
87
- readonly touched: boolean
88
- readonly validating: boolean
89
- readonly limitations: FieldLimitations<A>
90
- }
91
-
92
- export interface ArrayItemView<Item> {
93
- readonly key: string
94
- readonly index: number
95
- readonly value: Item
96
- readonly remove: () => void
97
- readonly move: (to: number) => void
98
- }
99
-
100
- export interface ArrayBinding<Item> {
101
- readonly path: string
102
- readonly items: ReadonlyArray<ArrayItemView<Item>>
103
- readonly append: (value?: Item) => void
104
- readonly remove: (index: number) => void
105
- readonly move: (from: number, to: number) => void
106
- readonly swap: (a: number, b: number) => void
107
- readonly limitations: FieldLimitations<ReadonlyArray<Item>>
108
- }
109
-
110
- export interface FormView<Values, Inputs = {}> {
111
- readonly values: Values
112
- readonly inputs: Inputs
113
- readonly errors: FormErrors
114
- readonly dirty: boolean
115
- readonly canSubmit: boolean
116
- readonly submitCount: number
117
- readonly validationCount: number
118
- readonly lastSubmittedValues: Option.Option<Values>
119
- readonly submit: () => void
120
- readonly reset: () => void
121
- readonly validate: () => void
122
- readonly field: <P extends FieldPath<Values>>(path: P) => FieldBinding<PathValue<Values, P>>
123
- readonly array: <P extends ArrayPath<Values>>(path: P) => ArrayBinding<ArrayItem<Values, P>>
124
- readonly variantValue: <P extends VariantPath<Values>>(path: P) => VariantValue<Values, P>
125
- }
126
-
127
- type InputRecord = Readonly<Record<string, AnySource>>
128
-
129
- type InputsObject<Inputs extends InputRecord> = {
130
- readonly [K in keyof Inputs as SourceName<Inputs[K]>]: ReformSourceValue<Inputs[K]>
131
- }
132
-
133
- type InputStores<Inputs extends InputRecord> = {
134
- [K in keyof Inputs]: Inputs[K] extends {
135
- readonly store: Context.Tag<infer Service, infer _Store>
136
- }
137
- ? Service
138
- : never
139
- }[keyof Inputs]
140
-
141
- type ValuesOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Encoded<Schema>
142
- type DecodedOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Type<Schema>
143
-
144
- export interface RuntimeConfig<Values, Decoded, Inputs> {
145
- readonly initial: Values
146
- // 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
- readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
148
- readonly validate: (ctx: {
149
- readonly values: Values
150
- readonly decoded: Decoded
151
- readonly inputs: Inputs
152
- }) => Effect.Effect<ReadonlyArray<FormError>, never, never>
153
- readonly submit: (ctx: {
154
- readonly values: Values
155
- readonly decoded: Decoded
156
- readonly inputs: Inputs
157
- }) => Effect.Effect<void, unknown, never>
158
- }
159
-
160
- export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
161
- readonly initial: Values
162
- // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
163
- readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
164
- // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
165
- readonly validate?: (ctx: {
166
- readonly values: Values
167
- readonly decoded: Decoded
168
- readonly inputs: Inputs
169
- }) =>
170
- | void
171
- | FormError
172
- | ReadonlyArray<FormError>
173
- | Effect.Effect<void | FormError | ReadonlyArray<FormError>, never, R>
174
- // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
175
- readonly submit?: (ctx: {
176
- readonly values: Values
177
- readonly decoded: Decoded
178
- readonly inputs: Inputs
179
- }) => void | Effect.Effect<unknown, unknown, R>
180
- }
181
-
182
- export interface FormSchemaReflection {
183
- readonly ast: S.Schema<unknown, unknown, unknown>['ast']
184
- }
185
-
186
- export interface FormManifestReflection<N extends string> {
187
- readonly kind: 'Form'
188
- readonly name: N
189
- readonly schema: FormSchemaReflection
190
- readonly inputs: InputRecord
191
- }
192
-
193
- export interface FormManifest<
194
- N extends string,
195
- Schema extends S.Schema.AnyNoContext,
196
- Inputs extends InputRecord,
197
- > extends FormManifestReflection<N> {
198
- readonly schema: Schema
199
- readonly inputs: Inputs
200
- }
201
-
202
- interface FormEvents {
203
- readonly set: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
204
- readonly blur: Event.EventClass<string, { readonly path: string }>
205
- readonly reset: Event.EventClass<string, {}>
206
- readonly validationFinished: Event.EventClass<string, { readonly errors: FormErrors }>
207
- readonly submitAttempted: Event.EventClass<string, {}>
208
- readonly submitSucceeded: Event.EventClass<string, { readonly values: unknown }>
209
- readonly append: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
210
- readonly remove: Event.EventClass<string, { readonly path: string; readonly index: number }>
211
- readonly move: Event.EventClass<
212
- string,
213
- { readonly path: string; readonly from: number; readonly to: number }
214
- >
215
- readonly swap: Event.EventClass<
216
- string,
217
- { readonly path: string; readonly a: number; readonly b: number }
218
- >
219
- }
220
-
221
- export interface FormVisitor<Result> {
222
- readonly visit: <
223
- N extends string,
224
- Schema extends S.Schema.AnyNoContext,
225
- Inputs extends InputRecord,
226
- >(
227
- form: FormClass<N, Schema, Inputs>,
228
- ) => Result
229
- }
230
-
231
- export interface AnyForm {
232
- new (): {}
233
- readonly manifest: FormManifestReflection<string>
234
- readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
235
- }
236
-
237
- export interface FormClass<
238
- N extends string,
239
- Schema extends S.Schema.AnyNoContext,
240
- Inputs extends InputRecord,
241
- > {
242
- new (): {}
243
- readonly manifest: FormManifest<N, Schema, Inputs>
244
- readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
245
- readonly state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
246
- readonly config: Context.Tag<
247
- RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
248
- RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
249
- >
250
- readonly events: FormEvents
251
- readonly reducer: Reducer.StateReducerClass<
252
- State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>,
253
- ReadonlyArray<Event.AnyEvent>
254
- >
255
- }
256
-
257
- export type Values<F> =
258
- F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? ValuesOfSchema<Schema> : never
259
-
260
- export type Decoded<F> =
261
- F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? DecodedOfSchema<Schema> : never
262
-
263
- export type Inputs<F> =
264
- F extends FormClass<infer _Name, infer _Schema, infer Inputs> ? InputsObject<Inputs> : never
265
-
266
- export type View<F> = FormView<Values<F>, Inputs<F>>
267
-
268
- const arrayKeyCounter = { current: 0 }
269
- const makeArrayKey = (): string => `form-item-${arrayKeyCounter.current++}`
270
-
271
- export const arrayKeysFor = (source: unknown, path = ''): Record<string, ReadonlyArray<string>> => {
272
- const out: Record<string, ReadonlyArray<string>> = {}
273
- const visit = (node: KeyVisitNode): void => {
274
- if (Array.isArray(node.current)) {
275
- out[node.path] = node.current.map(() => makeArrayKey())
276
- node.current.forEach((element, index) =>
277
- visit({ current: element, path: `${node.path}[${index}]` }),
278
- )
279
- return
280
- }
281
- if (node.current !== null && typeof node.current === 'object') {
282
- Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(
283
- ([key, child]) =>
284
- visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
285
- )
286
- }
287
- }
288
- visit({ current: source, path })
289
- return out
290
- }
291
-
292
- const initialState = <Values>(initial: Values): FormState<Values> => ({
293
- values: initial,
294
- initialValues: initial,
295
- touched: {},
296
- errors: {},
297
- dirtyPaths: [],
298
- submitCount: 0,
299
- validationCount: 0,
300
- lastSubmittedValues: Option.none(),
301
- arrayKeys: arrayKeysFor(initial),
302
- })
303
-
304
- const markTouched = (
305
- touched: Readonly<Record<string, boolean>>,
306
- path: string,
307
- ): Readonly<Record<string, boolean>> => ({ ...touched, [path]: true })
308
-
309
- interface ArrayLookup {
310
- readonly source: unknown
311
- readonly path: string
312
- }
313
-
314
- interface KeyVisitNode {
315
- readonly current: unknown
316
- readonly path: string
317
- }
318
-
319
- interface PathInput<Values> {
320
- readonly state: FormState<Values>
321
- readonly path: string
322
- }
323
-
324
- interface SetPathInput<Values> extends PathInput<Values> {
325
- readonly value: unknown
326
- }
327
-
328
- interface RemovePathInput<Values> extends PathInput<Values> {
329
- readonly index: number
330
- }
331
-
332
- interface MovePathInput<Values> extends PathInput<Values> {
333
- readonly from: number
334
- readonly to: number
335
- }
336
-
337
- interface SwapPathInput<Values> extends PathInput<Values> {
338
- readonly first: number
339
- readonly second: number
340
- }
341
-
342
- const withValues = <Values>(state: FormState<Values>, nextValues: Values): FormState<Values> => ({
343
- ...state,
344
- values: nextValues,
345
- dirtyPaths: recalculateDirtyPaths(state.initialValues, nextValues),
346
- })
347
-
348
- const currentArray = (input: ArrayLookup): ReadonlyArray<unknown> => {
349
- const current = getNestedValue(input.source, input.path)
350
- return Array.isArray(current) ? current : []
351
- }
352
-
353
- const updateKeys = (
354
- state: FormState<unknown>,
355
- path: string,
356
- transform: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
357
- ): Readonly<Record<string, ReadonlyArray<string>>> => {
358
- const existing =
359
- state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
360
- return { ...state.arrayKeys, [path]: transform(existing) }
361
- }
362
-
363
- const setAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> =>
364
- withValues(input.state, setNestedValue(input.state.values, input.path, input.value))
365
-
366
- const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> => {
367
- const elements = currentArray({ source: input.state.values, path: input.path })
368
- const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
369
- return {
370
- ...withValues(input.state, nextValues),
371
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [
372
- ...keys,
373
- makeArrayKey(),
374
- ]),
375
- }
376
- }
377
-
378
- const removeAtPath = <Values>(input: RemovePathInput<Values>): FormState<Values> => {
379
- const elements = currentArray({ source: input.state.values, path: input.path })
380
- if (input.index < 0 || input.index >= elements.length) {
381
- return input.state
382
- }
383
- const nextValues = setNestedValue(
384
- input.state.values,
385
- input.path,
386
- elements.filter((_, position) => position !== input.index),
387
- )
388
- return {
389
- ...withValues(input.state, nextValues),
390
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
391
- keys.filter((_, position) => position !== input.index),
392
- ),
393
- }
394
- }
395
-
396
- const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Values> => {
397
- const elements = currentArray({ source: input.state.values, path: input.path })
398
- const next = moveAt(elements, input.from, input.to)
399
- if (next === elements) {
400
- return input.state
401
- }
402
- const nextValues = setNestedValue(input.state.values, input.path, next)
403
- return {
404
- ...withValues(input.state, nextValues),
405
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
406
- moveAt(keys, input.from, input.to),
407
- ),
408
- }
409
- }
410
-
411
- const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Values> => {
412
- const elements = currentArray({ source: input.state.values, path: input.path })
413
- if (
414
- input.first < 0 ||
415
- input.second < 0 ||
416
- input.first >= elements.length ||
417
- input.second >= elements.length ||
418
- input.first === input.second
419
- ) {
420
- return input.state
421
- }
422
- const swapped = replaceAt(
423
- replaceAt(elements, input.first, elements[input.second]),
424
- input.second,
425
- elements[input.first],
426
- )
427
- const nextValues = setNestedValue(input.state.values, input.path, swapped)
428
- return {
429
- ...withValues(input.state, nextValues),
430
- arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
431
- replaceAt(
432
- replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()),
433
- input.second,
434
- keys[input.first] ?? makeArrayKey(),
435
- ),
436
- ),
437
- }
438
- }
439
-
440
- interface MakeConfig<Schema extends S.Schema.AnyNoContext, Inputs extends InputRecord> {
441
- readonly schema: Schema
442
- // 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
443
- readonly inputs?: Inputs
444
- }
445
-
446
- export const make = <
447
- const N extends string,
448
- Schema extends S.Schema.AnyNoContext,
449
- const Inputs extends InputRecord = {},
450
- >(
451
- name: N,
452
- config: MakeConfig<Schema, Inputs>,
453
- ): FormClass<N, Schema, Inputs> => {
454
- const valuesSchema = S.encodedSchema(config.schema)
455
- const stringRecord = S.Record({ key: S.String, value: S.String })
456
- const stateSchema = S.Struct({
457
- values: valuesSchema,
458
- initialValues: valuesSchema,
459
- touched: S.Record({ key: S.String, value: S.Boolean }),
460
- errors: stringRecord,
461
- dirtyPaths: S.Array(S.String),
462
- submitCount: S.Number,
463
- validationCount: S.Number,
464
- lastSubmittedValues: S.OptionFromSelf(valuesSchema),
465
- arrayKeys: S.Record({ key: S.String, value: S.Array(S.String) }),
466
- })
467
- const state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>> = State.make(
468
- `${name}/state`,
469
- stateSchema,
470
- { title: `${name} form state` },
471
- )
472
- const events: FormEvents = {
473
- set: Event.make(`${name}/SetField`, S.Struct({ path: S.String, value: S.Unknown })),
474
- blur: Event.make(`${name}/BlurField`, S.Struct({ path: S.String })),
475
- reset: Event.make(`${name}/Reset`, S.Struct({})),
476
- validationFinished: Event.make(
477
- `${name}/ValidationFinished`,
478
- S.Struct({
479
- errors: S.Record({ key: S.String, value: S.String }),
480
- }),
481
- ),
482
- submitAttempted: Event.make(`${name}/SubmitAttempted`, S.Struct({})),
483
- submitSucceeded: Event.make(`${name}/SubmitSucceeded`, S.Struct({ values: S.Unknown })),
484
- append: Event.make(`${name}/AppendItem`, S.Struct({ path: S.String, value: S.Unknown })),
485
- remove: Event.make(`${name}/RemoveItem`, S.Struct({ path: S.String, index: S.Number })),
486
- move: Event.make(
487
- `${name}/MoveItem`,
488
- S.Struct({ path: S.String, from: S.Number, to: S.Number }),
489
- ),
490
- swap: Event.make(`${name}/SwapItems`, S.Struct({ path: S.String, a: S.Number, b: S.Number })),
491
- }
492
- const reducer: FormClass<N, Schema, Inputs>['reducer'] = Reducer.make(`${name}/FormReducer`, {
493
- states: [state],
494
- events: Object.values(events),
495
- })
496
- const runtimeConfig = Context.GenericTag<
497
- RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
498
- RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
499
- >(`reform/form/${name}/config`)
500
- const inputs: Inputs = Fn.unsafeCoerce(
501
- Option.getOrElse(Option.fromNullable(config.inputs), () => ({})),
502
- )
503
- class FormImpl {
504
- static readonly manifest: FormManifest<N, Schema, Inputs> = {
505
- kind: 'Form',
506
- name,
507
- schema: config.schema,
508
- inputs,
509
- }
510
- static readonly state = state
511
- static readonly config = runtimeConfig
512
- static readonly events = events
513
- static readonly reducer = reducer
514
- static capture<Result>(visitor: FormVisitor<Result>): Result {
515
- return visitor.visit(FormImpl)
516
- }
517
- }
518
- return FormImpl
519
- }
26
+ export { arrayKeysFor } from './formState'
27
+ export { make } from './formMake'
28
+ export { view } from './formView'
29
+ export type {
30
+ AnyForm,
31
+ ArrayBinding,
32
+ ArrayItemView,
33
+ Decoded,
34
+ FieldBinding,
35
+ FieldLimitations,
36
+ FieldLimitationsExternalApi,
37
+ FormClass,
38
+ FormLiveConfig,
39
+ FormManifest,
40
+ FormManifestReflection,
41
+ FormSchemaReflection,
42
+ FormState,
43
+ FormView,
44
+ FormVisitor,
45
+ Inputs,
46
+ Limitations,
47
+ RuntimeConfig,
48
+ Values,
49
+ View,
50
+ } from './formTypes'
520
51
 
521
52
  export const live = <
522
53
  N extends string,
@@ -645,193 +176,3 @@ export const live = <
645
176
  Layer.provideMerge(State.live(form.state, initialState(config.initial))),
646
177
  )
647
178
  }
648
-
649
- const readInput = Effect.fn('forms.readInput')(function* <Input extends AnySource>(
650
- source: Input,
651
- ): Effect.fn.Return<ReformSourceValue<Input>, never, SourceIdentifier<Input>> {
652
- const store = yield* Context.GenericTag<SourceIdentifier<Input>, Store<ReformSourceValue<Input>>>(
653
- source.store.key,
654
- )
655
- const tracker = yield* Effect.serviceOption(CurrentTracker)
656
- if (Option.isSome(tracker)) {
657
- tracker.value.add(store)
658
- }
659
- return store.getSnapshot()
660
- })
661
-
662
- const readInputs = <Inputs extends InputRecord>(
663
- inputs: Inputs,
664
- ): Effect.Effect<InputsObject<Inputs>, never, InputStores<Inputs>> =>
665
- Fn.unsafeCoerce(
666
- Effect.gen(function* () {
667
- const pairs = yield* Effect.forEach(Object.values(inputs), (source) =>
668
- Effect.map(readInput(source), (snapshot): readonly [string, unknown] => [
669
- source.name,
670
- snapshot,
671
- ]),
672
- )
673
- return Record.fromEntries(pairs)
674
- }),
675
- )
676
-
677
- const limitationsFor = <A>(limitations: Limitations, path: string): FieldLimitations<A> =>
678
- Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(limitations[path]), () => ({})))
679
-
680
- const trigger = <P>(
681
- eventTrigger: Effect.Effect<Trigger<P>, never, Bus>,
682
- ): Effect.Effect<Trigger<P>, never, Bus> => eventTrigger
683
-
684
- // oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view preserves the exact form state/config/input requirements verbatim
685
- export const view = <
686
- N extends string,
687
- Schema extends S.Schema.AnyNoContext,
688
- FormInputs extends InputRecord,
689
- >(
690
- form: FormClass<N, Schema, FormInputs>,
691
- ): Effect.Effect<
692
- FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>>,
693
- never,
694
- | State.StateStore<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
695
- | RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>
696
- | Bus
697
- | InputStores<FormInputs>
698
- > =>
699
- Effect.gen(function* () {
700
- const state = yield* form.state
701
- const runtimeConfig = yield* form.config
702
- const inputs = yield* readInputs(form.manifest.inputs)
703
- const limitations = Option.getOrElse(
704
- Option.fromNullable(runtimeConfig.limit?.({ values: state.values, inputs })),
705
- () => ({}),
706
- )
707
- const setField = yield* trigger(form.events.set.trigger)
708
- const blurField = yield* trigger(form.events.blur.trigger)
709
- const reset = yield* trigger(form.events.reset.trigger)
710
- const append = yield* trigger(form.events.append.trigger)
711
- const remove = yield* trigger(form.events.remove.trigger)
712
- const move = yield* trigger(form.events.move.trigger)
713
- const swap = yield* trigger(form.events.swap.trigger)
714
- const runtime = yield* Effect.runtime<Bus>()
715
-
716
- const schema = form.manifest.schema
717
- const decodeEither = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
718
- const canSubmit = Either.isRight(decodeEither) && Record.keys(state.errors).length === 0
719
-
720
- // 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
721
- const runValidation = (publishSubmitAttempt: boolean) =>
722
- Effect.gen(function* () {
723
- if (publishSubmitAttempt) {
724
- yield* Event.dispatch(form.events.submitAttempted, {})
725
- }
726
- const decoded = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
727
- if (Either.isLeft(decoded)) {
728
- yield* Event.dispatch(form.events.validationFinished, {
729
- errors: routeParseError(decoded.left),
730
- })
731
- return Option.none<DecodedOfSchema<Schema>>()
732
- }
733
- const custom = yield* runtimeConfig.validate({
734
- values: state.values,
735
- decoded: decoded.right,
736
- inputs,
737
- })
738
- const errors = errorsToRecord(custom)
739
- yield* Event.dispatch(form.events.validationFinished, { errors })
740
- return Record.keys(errors).length === 0
741
- ? Option.some(decoded.right)
742
- : Option.none<DecodedOfSchema<Schema>>()
743
- })
744
-
745
- const submit = () => {
746
- Runtime.runFork(runtime)(
747
- Effect.gen(function* () {
748
- const decoded = yield* runValidation(true)
749
- if (Option.isNone(decoded)) {
750
- return
751
- }
752
- yield* runtimeConfig.submit({ values: state.values, decoded: decoded.value, inputs })
753
- yield* Event.dispatch(form.events.submitSucceeded, { values: state.values })
754
- }).pipe(
755
- Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause)),
756
- ),
757
- )
758
- }
759
-
760
- const validate = () => {
761
- Runtime.runFork(runtime)(runValidation(false))
762
- }
763
-
764
- const field = <P extends FieldPath<ValuesOfSchema<Schema>>>(
765
- path: P,
766
- ): FieldBinding<PathValue<ValuesOfSchema<Schema>, P>> => {
767
- const fieldValue: PathValue<ValuesOfSchema<Schema>, P> = Fn.unsafeCoerce(
768
- getNestedValue(state.values, path),
769
- )
770
- return {
771
- path,
772
- value: fieldValue,
773
- set: (next) => {
774
- if (typeof next === 'function') {
775
- const updater = Fn.unsafeCoerce<
776
- typeof next,
777
- (prev: PathValue<ValuesOfSchema<Schema>, P>) => PathValue<ValuesOfSchema<Schema>, P>
778
- >(next)
779
- setField({ path, value: updater(fieldValue) })
780
- return
781
- }
782
- setField({ path, value: next })
783
- },
784
- blur: () => blurField({ path }),
785
- error: firstError(state.errors, path),
786
- dirty: isPathOrParentDirty(state.dirtyPaths, path),
787
- touched: state.touched[path] === true,
788
- validating: false,
789
- limitations: limitationsFor<PathValue<ValuesOfSchema<Schema>, P>>(limitations, path),
790
- }
791
- }
792
-
793
- const array = <P extends ArrayPath<ValuesOfSchema<Schema>>>(
794
- path: P,
795
- ): ArrayBinding<ArrayItem<ValuesOfSchema<Schema>, P>> => {
796
- const arrayValues: ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>> = Fn.unsafeCoerce(
797
- currentArray({ source: state.values, path }),
798
- )
799
- const keys = state.arrayKeys[path] ?? arrayValues.map((_, index) => `${path}-${index}`)
800
- return {
801
- path,
802
- items: arrayValues.map((element, index) => ({
803
- value: element,
804
- index,
805
- key: keys[index] ?? `${path}-${index}`,
806
- remove: () => remove({ path, index }),
807
- move: (to) => move({ path, from: index, to }),
808
- })),
809
- append: (element) => append({ path, value: element }),
810
- remove: (index) => remove({ path, index }),
811
- move: (from, to) => move({ path, from, to }),
812
- swap: (first, second) => swap({ path, a: first, b: second }),
813
- limitations: limitationsFor<ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>>>(
814
- limitations,
815
- path,
816
- ),
817
- }
818
- }
819
-
820
- const formView: FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>> = Fn.unsafeCoerce({
821
- values: state.values,
822
- inputs,
823
- errors: state.errors,
824
- dirty: state.dirtyPaths.length > 0,
825
- canSubmit,
826
- submitCount: state.submitCount,
827
- validationCount: state.validationCount,
828
- lastSubmittedValues: state.lastSubmittedValues,
829
- submit,
830
- reset: () => reset({}),
831
- validate,
832
- field,
833
- array,
834
- variantValue: (path: string) => Fn.unsafeCoerce(getNestedValue(state.values, path)),
835
- })
836
- return formView
837
- })