@playfast/reform-forms 0.0.7 → 0.0.9
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 +2 -2
- package/src/form.test.ts +92 -0
- package/src/form.ts +644 -0
- package/src/index.ts +47 -0
- package/src/path.test.ts +21 -0
- package/src/path.ts +194 -0
- package/src/validation.ts +42 -0
- package/dist/form.d.ts +0 -184
- package/dist/form.d.ts.map +0 -1
- package/dist/form.js +0 -292
- package/dist/form.js.map +0 -1
- package/dist/index.d.ts +0 -8
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -5
- package/dist/index.js.map +0 -1
- package/dist/path.d.ts +0 -36
- package/dist/path.d.ts.map +0 -1
- package/dist/path.js +0 -104
- package/dist/path.js.map +0 -1
- package/dist/validation.d.ts +0 -12
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js +0 -33
- package/dist/validation.js.map +0 -1
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'
|