@playfast/reform-forms 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ import {
2
+ Context,
3
+ Effect,
4
+ Either,
5
+ Function as Fn,
6
+ Option,
7
+ Record,
8
+ Runtime,
9
+ Schema as S,
10
+ } from 'effect'
11
+ import { Event, type Trigger } from '@playfast/reform'
12
+ import {
13
+ type AnySource,
14
+ Bus,
15
+ CurrentTracker,
16
+ type SourceIdentifier,
17
+ type SourceValue as ReformSourceValue,
18
+ type Store,
19
+ } from '@playfast/reform/internal'
20
+ import { State } from '@playfast/reform'
21
+ import {
22
+ getNestedValue,
23
+ isPathOrParentDirty,
24
+ type ArrayItem,
25
+ type ArrayPath,
26
+ type FieldPath,
27
+ type PathValue,
28
+ } from './path'
29
+ import { errorsToRecord, firstError, routeParseError } from './validation'
30
+ import { currentArray } from './formState'
31
+ import type {
32
+ ArrayBinding,
33
+ DecodedOfSchema,
34
+ FieldLimitations,
35
+ FieldBinding,
36
+ FormClass,
37
+ FormState,
38
+ FormView,
39
+ InputRecord,
40
+ InputsObject,
41
+ InputStores,
42
+ Limitations,
43
+ RuntimeConfig,
44
+ ValuesOfSchema,
45
+ } from './formTypes'
46
+
47
+ const readInput = Effect.fn('forms.readInput')(function* <Input extends AnySource>(
48
+ source: Input,
49
+ ): Effect.fn.Return<ReformSourceValue<Input>, never, SourceIdentifier<Input>> {
50
+ const store = yield* Context.GenericTag<SourceIdentifier<Input>, Store<ReformSourceValue<Input>>>(
51
+ source.store.key,
52
+ )
53
+ const tracker = yield* Effect.serviceOption(CurrentTracker)
54
+ if (Option.isSome(tracker)) {
55
+ tracker.value.add(store)
56
+ }
57
+ return store.getSnapshot()
58
+ })
59
+
60
+ const readInputs = <Inputs extends InputRecord>(
61
+ inputs: Inputs,
62
+ ): Effect.Effect<InputsObject<Inputs>, never, InputStores<Inputs>> =>
63
+ Fn.unsafeCoerce(
64
+ Effect.gen(function* () {
65
+ const pairs = yield* Effect.forEach(Object.values(inputs), (source) =>
66
+ Effect.map(readInput(source), (snapshot): readonly [string, unknown] => [
67
+ source.name,
68
+ snapshot,
69
+ ]),
70
+ )
71
+ return Record.fromEntries(pairs)
72
+ }),
73
+ )
74
+
75
+ const limitationsFor = <A>(limitations: Limitations, path: string): FieldLimitations<A> =>
76
+ Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(limitations[path]), () => ({})))
77
+
78
+ const trigger = <P>(
79
+ eventTrigger: Effect.Effect<Trigger<P>, never, Bus>,
80
+ ): Effect.Effect<Trigger<P>, never, Bus> => eventTrigger
81
+
82
+ // oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view preserves the exact form state/config/input requirements verbatim
83
+ export const view = <
84
+ N extends string,
85
+ Schema extends S.Schema.AnyNoContext,
86
+ FormInputs extends InputRecord,
87
+ >(
88
+ form: FormClass<N, Schema, FormInputs>,
89
+ ): Effect.Effect<
90
+ FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>>,
91
+ never,
92
+ | State.StateStore<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
93
+ | RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>
94
+ | Bus
95
+ | InputStores<FormInputs>
96
+ > =>
97
+ Effect.gen(function* () {
98
+ const state = yield* form.state
99
+ const runtimeConfig = yield* form.config
100
+ const inputs = yield* readInputs(form.manifest.inputs)
101
+ const limitations = Option.getOrElse(
102
+ Option.fromNullable(runtimeConfig.limit?.({ values: state.values, inputs })),
103
+ () => ({}),
104
+ )
105
+ const setField = yield* trigger(form.events.set.trigger)
106
+ const blurField = yield* trigger(form.events.blur.trigger)
107
+ const reset = yield* trigger(form.events.reset.trigger)
108
+ const append = yield* trigger(form.events.append.trigger)
109
+ const remove = yield* trigger(form.events.remove.trigger)
110
+ const move = yield* trigger(form.events.move.trigger)
111
+ const swap = yield* trigger(form.events.swap.trigger)
112
+ const runtime = yield* Effect.runtime<Bus>()
113
+
114
+ const schema = form.manifest.schema
115
+ const decodeEither = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
116
+ const canSubmit = Either.isRight(decodeEither) && Record.keys(state.errors).length === 0
117
+
118
+ // 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
119
+ const runValidation = (publishSubmitAttempt: boolean) =>
120
+ Effect.gen(function* () {
121
+ if (publishSubmitAttempt) {
122
+ yield* Event.dispatch(form.events.submitAttempted, {})
123
+ }
124
+ const decoded = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
125
+ if (Either.isLeft(decoded)) {
126
+ yield* Event.dispatch(form.events.validationFinished, {
127
+ errors: routeParseError(decoded.left),
128
+ })
129
+ return Option.none<DecodedOfSchema<Schema>>()
130
+ }
131
+ const custom = yield* runtimeConfig.validate({
132
+ values: state.values,
133
+ decoded: decoded.right,
134
+ inputs,
135
+ })
136
+ const errors = errorsToRecord(custom)
137
+ yield* Event.dispatch(form.events.validationFinished, { errors })
138
+ return Record.keys(errors).length === 0
139
+ ? Option.some(decoded.right)
140
+ : Option.none<DecodedOfSchema<Schema>>()
141
+ })
142
+
143
+ const submit = () => {
144
+ Runtime.runFork(runtime)(
145
+ Effect.gen(function* () {
146
+ const decoded = yield* runValidation(true)
147
+ if (Option.isNone(decoded)) {
148
+ return
149
+ }
150
+ yield* runtimeConfig.submit({ values: state.values, decoded: decoded.value, inputs })
151
+ yield* Event.dispatch(form.events.submitSucceeded, { values: state.values })
152
+ }).pipe(
153
+ Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause)),
154
+ ),
155
+ )
156
+ }
157
+
158
+ const validate = () => {
159
+ Runtime.runFork(runtime)(runValidation(false))
160
+ }
161
+
162
+ const field = <P extends FieldPath<ValuesOfSchema<Schema>>>(
163
+ path: P,
164
+ ): FieldBinding<PathValue<ValuesOfSchema<Schema>, P>> => {
165
+ const fieldValue: PathValue<ValuesOfSchema<Schema>, P> = Fn.unsafeCoerce(
166
+ getNestedValue(state.values, path),
167
+ )
168
+ return {
169
+ path,
170
+ value: fieldValue,
171
+ set: (next) => {
172
+ if (typeof next === 'function') {
173
+ const updater = Fn.unsafeCoerce<
174
+ typeof next,
175
+ (prev: PathValue<ValuesOfSchema<Schema>, P>) => PathValue<ValuesOfSchema<Schema>, P>
176
+ >(next)
177
+ setField({ path, value: updater(fieldValue) })
178
+ return
179
+ }
180
+ setField({ path, value: next })
181
+ },
182
+ blur: () => blurField({ path }),
183
+ error: firstError(state.errors, path),
184
+ dirty: isPathOrParentDirty(state.dirtyPaths, path),
185
+ touched: state.touched[path] === true,
186
+ validating: false,
187
+ limitations: limitationsFor<PathValue<ValuesOfSchema<Schema>, P>>(limitations, path),
188
+ }
189
+ }
190
+
191
+ const array = <P extends ArrayPath<ValuesOfSchema<Schema>>>(
192
+ path: P,
193
+ ): ArrayBinding<ArrayItem<ValuesOfSchema<Schema>, P>> => {
194
+ const arrayValues: ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>> = Fn.unsafeCoerce(
195
+ currentArray({ source: state.values, path }),
196
+ )
197
+ const keys = state.arrayKeys[path] ?? arrayValues.map((_, index) => `${path}-${index}`)
198
+ return {
199
+ path,
200
+ items: arrayValues.map((element, index) => ({
201
+ value: element,
202
+ index,
203
+ key: keys[index] ?? `${path}-${index}`,
204
+ remove: () => remove({ path, index }),
205
+ move: (to) => move({ path, from: index, to }),
206
+ })),
207
+ append: (element) => append({ path, value: element }),
208
+ remove: (index) => remove({ path, index }),
209
+ move: (from, to) => move({ path, from, to }),
210
+ swap: (first, second) => swap({ path, a: first, b: second }),
211
+ limitations: limitationsFor<ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>>>(
212
+ limitations,
213
+ path,
214
+ ),
215
+ }
216
+ }
217
+
218
+ const formView: FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>> = Fn.unsafeCoerce({
219
+ values: state.values,
220
+ inputs,
221
+ errors: state.errors,
222
+ dirty: state.dirtyPaths.length > 0,
223
+ canSubmit,
224
+ submitCount: state.submitCount,
225
+ validationCount: state.validationCount,
226
+ lastSubmittedValues: state.lastSubmittedValues,
227
+ submit,
228
+ reset: () => reset({}),
229
+ validate,
230
+ field,
231
+ array,
232
+ variantValue: (path: string) => Fn.unsafeCoerce(getNestedValue(state.values, path)),
233
+ })
234
+ return formView
235
+ })
236
+