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