@playfast/reform-forms 1.0.1 → 1.1.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/package.json +1 -1
- package/src/form.test.ts +62 -63
- package/src/form.ts +47 -553
- package/src/formState.ts +166 -0
- package/src/formTypes.ts +211 -0
- package/src/formView.ts +202 -0
- package/src/path.ts +1 -4
package/src/formState.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { Function as Fn, Option, Record } from 'effect'
|
|
2
|
+
import {
|
|
3
|
+
getNestedValue,
|
|
4
|
+
moveAt,
|
|
5
|
+
recalculateDirtyPaths,
|
|
6
|
+
replaceAt,
|
|
7
|
+
setNestedValue,
|
|
8
|
+
} from './path'
|
|
9
|
+
import type { FormState } from './formTypes'
|
|
10
|
+
|
|
11
|
+
const arrayKeyCounter = { current: 0 }
|
|
12
|
+
const makeArrayKey = (): string => `form-item-${arrayKeyCounter.current++}`
|
|
13
|
+
|
|
14
|
+
export const arrayKeysFor = (source: unknown, path = ''): Record<string, ReadonlyArray<string>> => {
|
|
15
|
+
const out: Record<string, ReadonlyArray<string>> = {}
|
|
16
|
+
const visit = (node: KeyVisitNode): void => {
|
|
17
|
+
if (Array.isArray(node.current)) {
|
|
18
|
+
out[node.path] = node.current.map(() => makeArrayKey())
|
|
19
|
+
node.current.forEach((element, index) => visit({ current: element, path: `${node.path}[${index}]` }))
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
if (node.current !== null && typeof node.current === 'object') {
|
|
23
|
+
Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(([key, child]) =>
|
|
24
|
+
visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
visit({ current: source, path })
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const initialState = <Values>(initial: Values): FormState<Values> => ({
|
|
33
|
+
values: initial,
|
|
34
|
+
initialValues: initial,
|
|
35
|
+
touched: {},
|
|
36
|
+
errors: {},
|
|
37
|
+
dirtyPaths: [],
|
|
38
|
+
submitCount: 0,
|
|
39
|
+
validationCount: 0,
|
|
40
|
+
lastSubmittedValues: Option.none(),
|
|
41
|
+
arrayKeys: arrayKeysFor(initial),
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export const markTouched = (touched: Readonly<Record<string, boolean>>, path: string): Readonly<Record<string, boolean>> =>
|
|
45
|
+
({ ...touched, [path]: true })
|
|
46
|
+
|
|
47
|
+
interface ArrayLookup {
|
|
48
|
+
readonly source: unknown
|
|
49
|
+
readonly path: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface KeyVisitNode {
|
|
53
|
+
readonly current: unknown
|
|
54
|
+
readonly path: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PathInput<Values> {
|
|
58
|
+
readonly state: FormState<Values>
|
|
59
|
+
readonly path: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface SetPathInput<Values> extends PathInput<Values> {
|
|
63
|
+
readonly value: unknown
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface RemovePathInput<Values> extends PathInput<Values> {
|
|
67
|
+
readonly index: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface MovePathInput<Values> extends PathInput<Values> {
|
|
71
|
+
readonly from: number
|
|
72
|
+
readonly to: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface SwapPathInput<Values> extends PathInput<Values> {
|
|
76
|
+
readonly first: number
|
|
77
|
+
readonly second: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const withValues = <Values>(state: FormState<Values>, nextValues: Values): FormState<Values> => ({
|
|
81
|
+
...state,
|
|
82
|
+
values: nextValues,
|
|
83
|
+
dirtyPaths: recalculateDirtyPaths(state.initialValues, nextValues),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
export const currentArray = (input: ArrayLookup): ReadonlyArray<unknown> => {
|
|
87
|
+
const current = getNestedValue(input.source, input.path)
|
|
88
|
+
return Array.isArray(current) ? current : []
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const updateKeys = (
|
|
92
|
+
state: FormState<unknown>,
|
|
93
|
+
path: string,
|
|
94
|
+
transform: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
|
|
95
|
+
): Readonly<Record<string, ReadonlyArray<string>>> => {
|
|
96
|
+
const existing = state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
|
|
97
|
+
return { ...state.arrayKeys, [path]: transform(existing) }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const setAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> =>
|
|
101
|
+
withValues(input.state, setNestedValue(input.state.values, input.path, input.value))
|
|
102
|
+
|
|
103
|
+
export const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> => {
|
|
104
|
+
const elements = currentArray({ source: input.state.values, path: input.path })
|
|
105
|
+
const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
|
|
106
|
+
return {
|
|
107
|
+
...withValues(input.state, nextValues),
|
|
108
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [...keys, makeArrayKey()]),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const removeAtPath = <Values>(input: RemovePathInput<Values>): FormState<Values> => {
|
|
113
|
+
const elements = currentArray({ source: input.state.values, path: input.path })
|
|
114
|
+
if (input.index < 0 || input.index >= elements.length) {
|
|
115
|
+
return input.state
|
|
116
|
+
}
|
|
117
|
+
const nextValues = setNestedValue(
|
|
118
|
+
input.state.values,
|
|
119
|
+
input.path,
|
|
120
|
+
elements.filter((_, position) => position !== input.index),
|
|
121
|
+
)
|
|
122
|
+
return {
|
|
123
|
+
...withValues(input.state, nextValues),
|
|
124
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
|
|
125
|
+
keys.filter((_, position) => position !== input.index),
|
|
126
|
+
),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Values> => {
|
|
131
|
+
const elements = currentArray({ source: input.state.values, path: input.path })
|
|
132
|
+
const next = moveAt(elements, input.from, input.to)
|
|
133
|
+
if (next === elements) {
|
|
134
|
+
return input.state
|
|
135
|
+
}
|
|
136
|
+
const nextValues = setNestedValue(input.state.values, input.path, next)
|
|
137
|
+
return {
|
|
138
|
+
...withValues(input.state, nextValues),
|
|
139
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => moveAt(keys, input.from, input.to)),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Values> => {
|
|
144
|
+
const elements = currentArray({ source: input.state.values, path: input.path })
|
|
145
|
+
if (
|
|
146
|
+
input.first < 0 ||
|
|
147
|
+
input.second < 0 ||
|
|
148
|
+
input.first >= elements.length ||
|
|
149
|
+
input.second >= elements.length ||
|
|
150
|
+
input.first === input.second
|
|
151
|
+
) {
|
|
152
|
+
return input.state
|
|
153
|
+
}
|
|
154
|
+
const swapped = replaceAt(replaceAt(elements, input.first, elements[input.second]), input.second, elements[input.first])
|
|
155
|
+
const nextValues = setNestedValue(input.state.values, input.path, swapped)
|
|
156
|
+
return {
|
|
157
|
+
...withValues(input.state, nextValues),
|
|
158
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
|
|
159
|
+
replaceAt(
|
|
160
|
+
replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()),
|
|
161
|
+
input.second,
|
|
162
|
+
keys[input.first] ?? makeArrayKey(),
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/formTypes.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Context, Effect, Option, Schema as S } from 'effect'
|
|
2
|
+
import type { AnySource, Event, Reducer, Source, State } from '@playfast/reform'
|
|
3
|
+
import type {
|
|
4
|
+
ArrayItem,
|
|
5
|
+
ArrayPath,
|
|
6
|
+
FieldPath,
|
|
7
|
+
PathValue,
|
|
8
|
+
VariantPath,
|
|
9
|
+
VariantValue,
|
|
10
|
+
} from './path'
|
|
11
|
+
import type { FormError, FormErrors } from './validation'
|
|
12
|
+
|
|
13
|
+
export type AnyValue = S.Schema.Type<S.Schema.Any>
|
|
14
|
+
|
|
15
|
+
// Wire-serialized UI knobs: ExternalApi postfix allows optional fields (Option would break the contract).
|
|
16
|
+
export interface FieldLimitationsExternalApi<A = unknown> {
|
|
17
|
+
readonly required?: boolean
|
|
18
|
+
readonly disabled?: boolean
|
|
19
|
+
readonly readonly?: boolean
|
|
20
|
+
readonly visible?: boolean
|
|
21
|
+
readonly min?: number
|
|
22
|
+
readonly max?: number
|
|
23
|
+
readonly minLength?: number
|
|
24
|
+
readonly maxLength?: number
|
|
25
|
+
readonly minItems?: number
|
|
26
|
+
readonly maxItems?: number
|
|
27
|
+
readonly options?: ReadonlyArray<A>
|
|
28
|
+
readonly meta?: Readonly<Record<string, unknown>>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type FieldLimitations<A = unknown> = FieldLimitationsExternalApi<A>
|
|
32
|
+
|
|
33
|
+
export type Limitations = Readonly<Record<string, FieldLimitations>>
|
|
34
|
+
|
|
35
|
+
export interface FormState<Values> {
|
|
36
|
+
readonly values: Values
|
|
37
|
+
readonly initialValues: Values
|
|
38
|
+
readonly touched: Readonly<Record<string, boolean>>
|
|
39
|
+
readonly errors: FormErrors
|
|
40
|
+
readonly dirtyPaths: ReadonlyArray<string>
|
|
41
|
+
readonly submitCount: number
|
|
42
|
+
readonly validationCount: number
|
|
43
|
+
readonly lastSubmittedValues: Option.Option<Values>
|
|
44
|
+
readonly arrayKeys: Readonly<Record<string, ReadonlyArray<string>>>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface FieldBinding<A> {
|
|
48
|
+
readonly path: string
|
|
49
|
+
readonly value: A
|
|
50
|
+
readonly set: (value: A | ((prev: A) => A)) => void
|
|
51
|
+
readonly blur: () => void
|
|
52
|
+
readonly error: Option.Option<string>
|
|
53
|
+
readonly dirty: boolean
|
|
54
|
+
readonly touched: boolean
|
|
55
|
+
readonly validating: boolean
|
|
56
|
+
readonly limitations: FieldLimitations<A>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ArrayItemView<Item> {
|
|
60
|
+
readonly key: string
|
|
61
|
+
readonly index: number
|
|
62
|
+
readonly value: Item
|
|
63
|
+
readonly remove: () => void
|
|
64
|
+
readonly move: (to: number) => void
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ArrayBinding<Item> {
|
|
68
|
+
readonly path: string
|
|
69
|
+
readonly items: ReadonlyArray<ArrayItemView<Item>>
|
|
70
|
+
readonly append: (value?: Item) => void
|
|
71
|
+
readonly remove: (index: number) => void
|
|
72
|
+
readonly move: (from: number, to: number) => void
|
|
73
|
+
readonly swap: (a: number, b: number) => void
|
|
74
|
+
readonly limitations: FieldLimitations<ReadonlyArray<Item>>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface FormView<Values, Inputs = {}> {
|
|
78
|
+
readonly values: Values
|
|
79
|
+
readonly inputs: Inputs
|
|
80
|
+
readonly errors: FormErrors
|
|
81
|
+
readonly dirty: boolean
|
|
82
|
+
readonly canSubmit: boolean
|
|
83
|
+
readonly submitCount: number
|
|
84
|
+
readonly validationCount: number
|
|
85
|
+
readonly lastSubmittedValues: Option.Option<Values>
|
|
86
|
+
readonly submit: () => void
|
|
87
|
+
readonly reset: () => void
|
|
88
|
+
readonly validate: () => void
|
|
89
|
+
readonly field: <P extends FieldPath<Values>>(path: P) => FieldBinding<PathValue<Values, P>>
|
|
90
|
+
readonly array: <P extends ArrayPath<Values>>(path: P) => ArrayBinding<ArrayItem<Values, P>>
|
|
91
|
+
readonly variantValue: <P extends VariantPath<Values>>(path: P) => VariantValue<Values, P>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export type InputRecord = Readonly<Record<string, AnySource>>
|
|
95
|
+
|
|
96
|
+
// Structural `S['name']`: Source value param is invariant, so `Source<_, unknown>` erases keys to never.
|
|
97
|
+
export type SourceName<S extends AnySource> = S['name']
|
|
98
|
+
export type SourceValue<S> = S extends Source<string, infer A> ? A : never
|
|
99
|
+
|
|
100
|
+
export type InputsObject<Inputs extends InputRecord> = {
|
|
101
|
+
readonly [K in keyof Inputs as SourceName<Inputs[K]>]: SourceValue<Inputs[K]>
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type InputStores<Inputs extends InputRecord> = {
|
|
105
|
+
[K in keyof Inputs]: Inputs[K] extends { readonly store: Context.Tag<infer Service, AnyValue> }
|
|
106
|
+
? Service
|
|
107
|
+
: never
|
|
108
|
+
}[keyof Inputs]
|
|
109
|
+
|
|
110
|
+
export type ValuesOfSchema<S extends S.Schema.Any> = S.Schema.Encoded<S>
|
|
111
|
+
export type DecodedOfSchema<S extends S.Schema.Any> = S.Schema.Type<S>
|
|
112
|
+
|
|
113
|
+
export interface RuntimeConfig<Values, Decoded, Inputs> {
|
|
114
|
+
readonly initial: Values
|
|
115
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob (not a serializable shape); Option would break external form definitions
|
|
116
|
+
readonly limit?: (ctx: {
|
|
117
|
+
readonly values: Values
|
|
118
|
+
readonly inputs: Inputs
|
|
119
|
+
}) => Limitations
|
|
120
|
+
readonly validate: (ctx: {
|
|
121
|
+
readonly values: Values
|
|
122
|
+
readonly decoded: Decoded
|
|
123
|
+
readonly inputs: Inputs
|
|
124
|
+
}) => Effect.Effect<ReadonlyArray<FormError>, never, never>
|
|
125
|
+
readonly submit: (ctx: {
|
|
126
|
+
readonly values: Values
|
|
127
|
+
readonly decoded: Decoded
|
|
128
|
+
readonly inputs: Inputs
|
|
129
|
+
}) => Effect.Effect<void, unknown, never>
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
|
|
133
|
+
readonly initial: Values
|
|
134
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
|
|
135
|
+
readonly limit?: (ctx: {
|
|
136
|
+
readonly values: Values
|
|
137
|
+
readonly inputs: Inputs
|
|
138
|
+
}) => Limitations
|
|
139
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
|
|
140
|
+
readonly validate?: (ctx: {
|
|
141
|
+
readonly values: Values
|
|
142
|
+
readonly decoded: Decoded
|
|
143
|
+
readonly inputs: Inputs
|
|
144
|
+
}) =>
|
|
145
|
+
| void
|
|
146
|
+
| FormError
|
|
147
|
+
| ReadonlyArray<FormError>
|
|
148
|
+
| Effect.Effect<void | FormError | ReadonlyArray<FormError>, never, R>
|
|
149
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
|
|
150
|
+
readonly submit?: (ctx: {
|
|
151
|
+
readonly values: Values
|
|
152
|
+
readonly decoded: Decoded
|
|
153
|
+
readonly inputs: Inputs
|
|
154
|
+
}) => void | Effect.Effect<unknown, unknown, R>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface FormManifest<N extends string, Schema extends S.Schema.Any, Inputs extends InputRecord> {
|
|
158
|
+
readonly kind: 'Form'
|
|
159
|
+
readonly name: N
|
|
160
|
+
readonly schema: Schema
|
|
161
|
+
readonly inputs: Inputs
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface FormEvents {
|
|
165
|
+
readonly set: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
|
|
166
|
+
readonly blur: Event.EventClass<string, { readonly path: string }>
|
|
167
|
+
readonly reset: Event.EventClass<string, {}>
|
|
168
|
+
readonly validationFinished: Event.EventClass<string, { readonly errors: FormErrors }>
|
|
169
|
+
readonly submitAttempted: Event.EventClass<string, {}>
|
|
170
|
+
readonly submitSucceeded: Event.EventClass<string, { readonly values: unknown }>
|
|
171
|
+
readonly append: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
|
|
172
|
+
readonly remove: Event.EventClass<string, { readonly path: string; readonly index: number }>
|
|
173
|
+
readonly move: Event.EventClass<string, { readonly path: string; readonly from: number; readonly to: number }>
|
|
174
|
+
readonly swap: Event.EventClass<string, { readonly path: string; readonly a: number; readonly b: number }>
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface FormClass<
|
|
178
|
+
N extends string,
|
|
179
|
+
Schema extends S.Schema.Any,
|
|
180
|
+
Inputs extends InputRecord,
|
|
181
|
+
> {
|
|
182
|
+
new (): {}
|
|
183
|
+
readonly manifest: FormManifest<N, Schema, Inputs>
|
|
184
|
+
readonly state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
|
|
185
|
+
readonly config: Context.Tag<
|
|
186
|
+
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
|
|
187
|
+
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
|
|
188
|
+
>
|
|
189
|
+
readonly events: FormEvents
|
|
190
|
+
readonly reducer: Reducer.StateReducerClass<
|
|
191
|
+
State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>,
|
|
192
|
+
ReadonlyArray<Event.AnyEvent>
|
|
193
|
+
>
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// `any` inputs wildcard: config tag is invariant via InputsObject; InputRecord would reject concrete forms.
|
|
197
|
+
export type AnyForm = FormClass<string, S.Schema.Any, AnyValue>
|
|
198
|
+
|
|
199
|
+
export type Values<F extends AnyForm> = F extends FormClass<string, infer Schema, AnyValue>
|
|
200
|
+
? ValuesOfSchema<Schema>
|
|
201
|
+
: never
|
|
202
|
+
|
|
203
|
+
export type Decoded<F extends AnyForm> = F extends FormClass<string, infer Schema, AnyValue>
|
|
204
|
+
? DecodedOfSchema<Schema>
|
|
205
|
+
: never
|
|
206
|
+
|
|
207
|
+
export type Inputs<F extends AnyForm> = F extends FormClass<string, S.Schema.Any, infer Inputs>
|
|
208
|
+
? InputsObject<Inputs>
|
|
209
|
+
: never
|
|
210
|
+
|
|
211
|
+
export type View<F extends AnyForm> = FormView<Values<F>, Inputs<F>>
|
package/src/formView.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Effect,
|
|
3
|
+
Either,
|
|
4
|
+
Function as Fn,
|
|
5
|
+
Option,
|
|
6
|
+
Record,
|
|
7
|
+
Runtime,
|
|
8
|
+
Schema as S,
|
|
9
|
+
} from 'effect'
|
|
10
|
+
import {
|
|
11
|
+
Bus,
|
|
12
|
+
CurrentTracker,
|
|
13
|
+
Event,
|
|
14
|
+
type Store,
|
|
15
|
+
type Trigger,
|
|
16
|
+
} from '@playfast/reform'
|
|
17
|
+
import type { AnySource } from '@playfast/reform'
|
|
18
|
+
import { currentArray } from './formState'
|
|
19
|
+
import { getNestedValue, isPathOrParentDirty } from './path'
|
|
20
|
+
import type { ArrayItem, ArrayPath, FieldPath, PathValue } from './path'
|
|
21
|
+
import { errorsToRecord, firstError, routeParseError } from './validation'
|
|
22
|
+
import type {
|
|
23
|
+
AnyForm,
|
|
24
|
+
ArrayBinding,
|
|
25
|
+
Decoded,
|
|
26
|
+
FieldBinding,
|
|
27
|
+
FieldLimitations,
|
|
28
|
+
FormState,
|
|
29
|
+
InputRecord,
|
|
30
|
+
Inputs,
|
|
31
|
+
InputsObject,
|
|
32
|
+
InputStores,
|
|
33
|
+
Limitations,
|
|
34
|
+
RuntimeConfig,
|
|
35
|
+
SourceValue,
|
|
36
|
+
Values,
|
|
37
|
+
View,
|
|
38
|
+
} from './formTypes'
|
|
39
|
+
|
|
40
|
+
const readInput = <Source extends AnySource>(
|
|
41
|
+
source: Source,
|
|
42
|
+
): Effect.Effect<SourceValue<Source>, never, Store<SourceValue<Source>>> =>
|
|
43
|
+
Fn.unsafeCoerce(
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const store = yield* source.store
|
|
46
|
+
const tracker = yield* Effect.serviceOption(CurrentTracker)
|
|
47
|
+
if (Option.isSome(tracker)) {
|
|
48
|
+
tracker.value.add(store)
|
|
49
|
+
}
|
|
50
|
+
return store.getSnapshot()
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const readInputs = <Inputs extends InputRecord>(
|
|
55
|
+
inputs: Inputs,
|
|
56
|
+
): Effect.Effect<InputsObject<Inputs>, never, InputStores<Inputs>> =>
|
|
57
|
+
Fn.unsafeCoerce(
|
|
58
|
+
Effect.gen(function* () {
|
|
59
|
+
const pairs = yield* Effect.forEach(Object.values(inputs), (source) =>
|
|
60
|
+
Effect.map(readInput(source), (snapshot) => [source.name, snapshot] as const),
|
|
61
|
+
)
|
|
62
|
+
return Record.fromEntries(pairs)
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const limitationsFor = <A>(
|
|
67
|
+
limitations: Limitations,
|
|
68
|
+
path: string,
|
|
69
|
+
): FieldLimitations<A> =>
|
|
70
|
+
Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(limitations[path]), () => ({})))
|
|
71
|
+
|
|
72
|
+
const trigger = <P>(eventTrigger: Effect.Effect<Trigger<P>, never, Bus>): Effect.Effect<Trigger<P>, never, Bus> =>
|
|
73
|
+
eventTrigger
|
|
74
|
+
|
|
75
|
+
// 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
|
|
76
|
+
export const view = <F extends AnyForm>(
|
|
77
|
+
form: F,
|
|
78
|
+
): Effect.Effect<View<F>, never, Store<FormState<Values<F>>> | RuntimeConfig<Values<F>, Decoded<F>, Inputs<F>> | Bus | InputStores<F['manifest']['inputs']>> =>
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const state = yield* form.state
|
|
81
|
+
const runtimeConfig = yield* form.config
|
|
82
|
+
// AnyForm erases inputs to `any`; restate at concrete F so R keeps declared input stores.
|
|
83
|
+
const inputs: Inputs<F> = Fn.unsafeCoerce(
|
|
84
|
+
yield* readInputs(Fn.unsafeCoerce<typeof form.manifest.inputs, InputRecord>(form.manifest.inputs)),
|
|
85
|
+
)
|
|
86
|
+
const limitations = Option.getOrElse(
|
|
87
|
+
Option.fromNullable(runtimeConfig.limit?.({ values: state.values, inputs })),
|
|
88
|
+
() => ({}),
|
|
89
|
+
)
|
|
90
|
+
const setField = yield* trigger(form.events.set.trigger)
|
|
91
|
+
const blurField = yield* trigger(form.events.blur.trigger)
|
|
92
|
+
const reset = yield* trigger(form.events.reset.trigger)
|
|
93
|
+
const append = yield* trigger(form.events.append.trigger)
|
|
94
|
+
const remove = yield* trigger(form.events.remove.trigger)
|
|
95
|
+
const move = yield* trigger(form.events.move.trigger)
|
|
96
|
+
const swap = yield* trigger(form.events.swap.trigger)
|
|
97
|
+
const runtime = yield* Effect.runtime<Bus>()
|
|
98
|
+
|
|
99
|
+
const schema: S.Schema<Decoded<F>, Values<F>, never> = Fn.unsafeCoerce(form.manifest.schema)
|
|
100
|
+
const decodeEither = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
|
|
101
|
+
const canSubmit = Either.isRight(decodeEither) && Record.keys(state.errors).length === 0
|
|
102
|
+
|
|
103
|
+
// 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
|
|
104
|
+
const runValidation = (publishSubmitAttempt: boolean) =>
|
|
105
|
+
Effect.gen(function* () {
|
|
106
|
+
if (publishSubmitAttempt) {
|
|
107
|
+
yield* Event.dispatch(form.events.submitAttempted, {})
|
|
108
|
+
}
|
|
109
|
+
const decoded = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
|
|
110
|
+
if (Either.isLeft(decoded)) {
|
|
111
|
+
yield* Event.dispatch(form.events.validationFinished, { errors: routeParseError(decoded.left) })
|
|
112
|
+
return Option.none<Decoded<F>>()
|
|
113
|
+
}
|
|
114
|
+
const custom = yield* runtimeConfig.validate({ values: state.values, decoded: decoded.right, inputs })
|
|
115
|
+
const errors = errorsToRecord(custom)
|
|
116
|
+
yield* Event.dispatch(form.events.validationFinished, { errors })
|
|
117
|
+
return Record.keys(errors).length === 0
|
|
118
|
+
? Option.some(Fn.unsafeCoerce<unknown, Decoded<F>>(decoded.right))
|
|
119
|
+
: Option.none<Decoded<F>>()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const submit = () => {
|
|
123
|
+
Runtime.runFork(runtime)(
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
const decoded = yield* runValidation(true)
|
|
126
|
+
if (Option.isNone(decoded)) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
yield* runtimeConfig.submit({ values: state.values, decoded: decoded.value, inputs })
|
|
130
|
+
yield* Event.dispatch(form.events.submitSucceeded, { values: state.values })
|
|
131
|
+
}).pipe(Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause))),
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const validate = () => {
|
|
136
|
+
Runtime.runFork(runtime)(runValidation(false))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const field = <P extends FieldPath<Values<F>>>(path: P): FieldBinding<PathValue<Values<F>, P>> => {
|
|
140
|
+
const fieldValue: PathValue<Values<F>, P> = Fn.unsafeCoerce(getNestedValue(state.values, path))
|
|
141
|
+
return {
|
|
142
|
+
path,
|
|
143
|
+
value: fieldValue,
|
|
144
|
+
set: (next) => {
|
|
145
|
+
if (typeof next === 'function') {
|
|
146
|
+
const updater = Fn.unsafeCoerce<typeof next, (prev: PathValue<Values<F>, P>) => PathValue<Values<F>, P>>(
|
|
147
|
+
next,
|
|
148
|
+
)
|
|
149
|
+
setField({ path, value: updater(fieldValue) })
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
setField({ path, value: next })
|
|
153
|
+
},
|
|
154
|
+
blur: () => blurField({ path }),
|
|
155
|
+
error: firstError(state.errors, path),
|
|
156
|
+
dirty: isPathOrParentDirty(state.dirtyPaths, path),
|
|
157
|
+
touched: state.touched[path] === true,
|
|
158
|
+
validating: false,
|
|
159
|
+
limitations: limitationsFor<PathValue<Values<F>, P>>(limitations, path),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const array = <P extends ArrayPath<Values<F>>>(path: P): ArrayBinding<ArrayItem<Values<F>, P>> => {
|
|
164
|
+
const arrayValues: ReadonlyArray<ArrayItem<Values<F>, P>> = Fn.unsafeCoerce(
|
|
165
|
+
currentArray({ source: state.values, path }),
|
|
166
|
+
)
|
|
167
|
+
const keys = state.arrayKeys[path] ?? arrayValues.map((_, index) => `${path}-${index}`)
|
|
168
|
+
return {
|
|
169
|
+
path,
|
|
170
|
+
items: arrayValues.map((element, index) => ({
|
|
171
|
+
value: element,
|
|
172
|
+
index,
|
|
173
|
+
key: keys[index] ?? `${path}-${index}`,
|
|
174
|
+
remove: () => remove({ path, index }),
|
|
175
|
+
move: (to) => move({ path, from: index, to }),
|
|
176
|
+
})),
|
|
177
|
+
append: (element) => append({ path, value: element }),
|
|
178
|
+
remove: (index) => remove({ path, index }),
|
|
179
|
+
move: (from, to) => move({ path, from, to }),
|
|
180
|
+
swap: (first, second) => swap({ path, a: first, b: second }),
|
|
181
|
+
limitations: limitationsFor<ReadonlyArray<ArrayItem<Values<F>, P>>>(limitations, path),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const formView: View<F> = Fn.unsafeCoerce({
|
|
186
|
+
values: state.values,
|
|
187
|
+
inputs,
|
|
188
|
+
errors: state.errors,
|
|
189
|
+
dirty: state.dirtyPaths.length > 0,
|
|
190
|
+
canSubmit,
|
|
191
|
+
submitCount: state.submitCount,
|
|
192
|
+
validationCount: state.validationCount,
|
|
193
|
+
lastSubmittedValues: state.lastSubmittedValues,
|
|
194
|
+
submit,
|
|
195
|
+
reset: () => reset({}),
|
|
196
|
+
validate,
|
|
197
|
+
field,
|
|
198
|
+
array,
|
|
199
|
+
variantValue: (path: string) => Fn.unsafeCoerce(getNestedValue(state.values, path)),
|
|
200
|
+
})
|
|
201
|
+
return formView
|
|
202
|
+
})
|
package/src/path.ts
CHANGED
|
@@ -5,8 +5,7 @@ import { sort as sortArray } from 'effect/Array'
|
|
|
5
5
|
const BRACKET_NOTATION_REGEX = /\[(\d+)\]/g
|
|
6
6
|
const NO_INDEX = -1
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
// is matched here to stop path recursion at primitive leaves.
|
|
8
|
+
// JSON null is a real form-value leaf — stop path recursion here.
|
|
10
9
|
export type Primitive =
|
|
11
10
|
| string
|
|
12
11
|
| number
|
|
@@ -83,8 +82,6 @@ export type VariantCase<V, Tag extends string> = Extract<V, { readonly _tag: Tag
|
|
|
83
82
|
|
|
84
83
|
export type VariantBody<V, Tag extends string> = Omit<VariantCase<V, Tag>, '_tag'>
|
|
85
84
|
|
|
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
85
|
const asRecord = (source: unknown): Record<string, unknown> => Fn.unsafeCoerce(source)
|
|
89
86
|
|
|
90
87
|
export const schemaPathToFieldPath = (path: ReadonlyArray<PropertyKey>): string =>
|