@playfast/reform-forms 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ import { Context, Function as Fn, Option, Schema as S } from 'effect'
2
+ import { Event, Reducer, State } from '@playfast/reform'
3
+ import type {
4
+ DecodedOfSchema,
5
+ FormClass,
6
+ FormEvents,
7
+ FormManifest,
8
+ FormState,
9
+ FormVisitor,
10
+ InputRecord,
11
+ InputsObject,
12
+ RuntimeConfig,
13
+ ValuesOfSchema,
14
+ } from './formTypes'
15
+
16
+ interface MakeConfig<Schema extends S.Schema.AnyNoContext, Inputs extends InputRecord> {
17
+ readonly schema: Schema
18
+ // 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
19
+ readonly inputs?: Inputs
20
+ }
21
+
22
+ export const make = <
23
+ const N extends string,
24
+ Schema extends S.Schema.AnyNoContext,
25
+ const Inputs extends InputRecord = {},
26
+ >(
27
+ name: N,
28
+ config: MakeConfig<Schema, Inputs>,
29
+ ): FormClass<N, Schema, Inputs> => {
30
+ const valuesSchema = S.encodedSchema(config.schema)
31
+ const stringRecord = S.Record({ key: S.String, value: S.String })
32
+ const stateSchema = S.Struct({
33
+ values: valuesSchema,
34
+ initialValues: valuesSchema,
35
+ touched: S.Record({ key: S.String, value: S.Boolean }),
36
+ errors: stringRecord,
37
+ dirtyPaths: S.Array(S.String),
38
+ submitCount: S.Number,
39
+ validationCount: S.Number,
40
+ lastSubmittedValues: S.OptionFromSelf(valuesSchema),
41
+ arrayKeys: S.Record({ key: S.String, value: S.Array(S.String) }),
42
+ })
43
+ const state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>> = State.make(
44
+ `${name}/state`,
45
+ stateSchema,
46
+ { title: `${name} form state` },
47
+ )
48
+ const events: FormEvents = {
49
+ set: Event.make(`${name}/SetField`, S.Struct({ path: S.String, value: S.Unknown })),
50
+ blur: Event.make(`${name}/BlurField`, S.Struct({ path: S.String })),
51
+ reset: Event.make(`${name}/Reset`, S.Struct({})),
52
+ validationFinished: Event.make(
53
+ `${name}/ValidationFinished`,
54
+ S.Struct({
55
+ errors: S.Record({ key: S.String, value: S.String }),
56
+ }),
57
+ ),
58
+ submitAttempted: Event.make(`${name}/SubmitAttempted`, S.Struct({})),
59
+ submitSucceeded: Event.make(`${name}/SubmitSucceeded`, S.Struct({ values: S.Unknown })),
60
+ append: Event.make(`${name}/AppendItem`, S.Struct({ path: S.String, value: S.Unknown })),
61
+ remove: Event.make(`${name}/RemoveItem`, S.Struct({ path: S.String, index: S.Number })),
62
+ move: Event.make(
63
+ `${name}/MoveItem`,
64
+ S.Struct({ path: S.String, from: S.Number, to: S.Number }),
65
+ ),
66
+ swap: Event.make(`${name}/SwapItems`, S.Struct({ path: S.String, a: S.Number, b: S.Number })),
67
+ }
68
+ const reducer: FormClass<N, Schema, Inputs>['reducer'] = Reducer.make(`${name}/FormReducer`, {
69
+ states: [state],
70
+ events: Object.values(events),
71
+ })
72
+ const runtimeConfig = Context.GenericTag<
73
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
74
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
75
+ >(`reform/form/${name}/config`)
76
+ const inputs: Inputs = Fn.unsafeCoerce(
77
+ Option.getOrElse(Option.fromNullable(config.inputs), () => ({})),
78
+ )
79
+ class FormImpl {
80
+ static readonly manifest: FormManifest<N, Schema, Inputs> = {
81
+ kind: 'Form',
82
+ name,
83
+ schema: config.schema,
84
+ inputs,
85
+ }
86
+ static readonly state = state
87
+ static readonly config = runtimeConfig
88
+ static readonly events = events
89
+ static readonly reducer = reducer
90
+ static capture<Result>(visitor: FormVisitor<Result>): Result {
91
+ return visitor.visit(FormImpl)
92
+ }
93
+ }
94
+ return FormImpl
95
+ }
@@ -0,0 +1,181 @@
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) =>
20
+ visit({ current: element, path: `${node.path}[${index}]` }),
21
+ )
22
+ return
23
+ }
24
+ if (node.current !== null && typeof node.current === 'object') {
25
+ Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(
26
+ ([key, child]) =>
27
+ visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
28
+ )
29
+ }
30
+ }
31
+ visit({ current: source, path })
32
+ return out
33
+ }
34
+
35
+ export const initialState = <Values>(initial: Values): FormState<Values> => ({
36
+ values: initial,
37
+ initialValues: initial,
38
+ touched: {},
39
+ errors: {},
40
+ dirtyPaths: [],
41
+ submitCount: 0,
42
+ validationCount: 0,
43
+ lastSubmittedValues: Option.none(),
44
+ arrayKeys: arrayKeysFor(initial),
45
+ })
46
+
47
+ export const markTouched = (
48
+ touched: Readonly<Record<string, boolean>>,
49
+ path: string,
50
+ ): Readonly<Record<string, boolean>> => ({ ...touched, [path]: true })
51
+
52
+ interface ArrayLookup {
53
+ readonly source: unknown
54
+ readonly path: string
55
+ }
56
+
57
+ interface KeyVisitNode {
58
+ readonly current: unknown
59
+ readonly path: string
60
+ }
61
+
62
+ interface PathInput<Values> {
63
+ readonly state: FormState<Values>
64
+ readonly path: string
65
+ }
66
+
67
+ interface SetPathInput<Values> extends PathInput<Values> {
68
+ readonly value: unknown
69
+ }
70
+
71
+ interface RemovePathInput<Values> extends PathInput<Values> {
72
+ readonly index: number
73
+ }
74
+
75
+ interface MovePathInput<Values> extends PathInput<Values> {
76
+ readonly from: number
77
+ readonly to: number
78
+ }
79
+
80
+ interface SwapPathInput<Values> extends PathInput<Values> {
81
+ readonly first: number
82
+ readonly second: number
83
+ }
84
+
85
+ const withValues = <Values>(state: FormState<Values>, nextValues: Values): FormState<Values> => ({
86
+ ...state,
87
+ values: nextValues,
88
+ dirtyPaths: recalculateDirtyPaths(state.initialValues, nextValues),
89
+ })
90
+
91
+ export const currentArray = (input: ArrayLookup): ReadonlyArray<unknown> => {
92
+ const current = getNestedValue(input.source, input.path)
93
+ return Array.isArray(current) ? current : []
94
+ }
95
+
96
+ const updateKeys = (
97
+ state: FormState<unknown>,
98
+ path: string,
99
+ transform: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
100
+ ): Readonly<Record<string, ReadonlyArray<string>>> => {
101
+ const existing =
102
+ state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
103
+ return { ...state.arrayKeys, [path]: transform(existing) }
104
+ }
105
+
106
+ export const setAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> =>
107
+ withValues(input.state, setNestedValue(input.state.values, input.path, input.value))
108
+
109
+ export const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> => {
110
+ const elements = currentArray({ source: input.state.values, path: input.path })
111
+ const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
112
+ return {
113
+ ...withValues(input.state, nextValues),
114
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [
115
+ ...keys,
116
+ makeArrayKey(),
117
+ ]),
118
+ }
119
+ }
120
+
121
+ export const removeAtPath = <Values>(input: RemovePathInput<Values>): FormState<Values> => {
122
+ const elements = currentArray({ source: input.state.values, path: input.path })
123
+ if (input.index < 0 || input.index >= elements.length) {
124
+ return input.state
125
+ }
126
+ const nextValues = setNestedValue(
127
+ input.state.values,
128
+ input.path,
129
+ elements.filter((_, position) => position !== input.index),
130
+ )
131
+ return {
132
+ ...withValues(input.state, nextValues),
133
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
134
+ keys.filter((_, position) => position !== input.index),
135
+ ),
136
+ }
137
+ }
138
+
139
+ export const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Values> => {
140
+ const elements = currentArray({ source: input.state.values, path: input.path })
141
+ const next = moveAt(elements, input.from, input.to)
142
+ if (next === elements) {
143
+ return input.state
144
+ }
145
+ const nextValues = setNestedValue(input.state.values, input.path, next)
146
+ return {
147
+ ...withValues(input.state, nextValues),
148
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
149
+ moveAt(keys, input.from, input.to),
150
+ ),
151
+ }
152
+ }
153
+
154
+ export const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Values> => {
155
+ const elements = currentArray({ source: input.state.values, path: input.path })
156
+ if (
157
+ input.first < 0 ||
158
+ input.second < 0 ||
159
+ input.first >= elements.length ||
160
+ input.second >= elements.length ||
161
+ input.first === input.second
162
+ ) {
163
+ return input.state
164
+ }
165
+ const swapped = replaceAt(
166
+ replaceAt(elements, input.first, elements[input.second]),
167
+ input.second,
168
+ elements[input.first],
169
+ )
170
+ const nextValues = setNestedValue(input.state.values, input.path, swapped)
171
+ return {
172
+ ...withValues(input.state, nextValues),
173
+ arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
174
+ replaceAt(
175
+ replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()),
176
+ input.second,
177
+ keys[input.first] ?? makeArrayKey(),
178
+ ),
179
+ ),
180
+ }
181
+ }
@@ -0,0 +1,235 @@
1
+ import type { Context, Effect, Option, Schema as S } from 'effect'
2
+ import type { Event, Reducer, State } from '@playfast/reform'
3
+ import type {
4
+ AnySource,
5
+ SourceName,
6
+ SourceValue as ReformSourceValue,
7
+ } from '@playfast/reform/internal'
8
+ import type {
9
+ ArrayItem,
10
+ ArrayPath,
11
+ FieldPath,
12
+ PathValue,
13
+ VariantPath,
14
+ VariantValue,
15
+ } from './path'
16
+ import type { FormError, FormErrors } from './validation'
17
+
18
+ export interface FieldLimitationsExternalApi<A = unknown> {
19
+ readonly required?: boolean
20
+ readonly disabled?: boolean
21
+ readonly readonly?: boolean
22
+ readonly visible?: boolean
23
+ readonly min?: number
24
+ readonly max?: number
25
+ readonly minLength?: number
26
+ readonly maxLength?: number
27
+ readonly minItems?: number
28
+ readonly maxItems?: number
29
+ readonly options?: ReadonlyArray<A>
30
+ readonly meta?: Readonly<Record<string, unknown>>
31
+ }
32
+
33
+ export type FieldLimitations<A = unknown> = FieldLimitationsExternalApi<A>
34
+
35
+ export type Limitations = Readonly<Record<string, FieldLimitations>>
36
+
37
+ export interface FormState<Values> {
38
+ readonly values: Values
39
+ readonly initialValues: Values
40
+ readonly touched: Readonly<Record<string, boolean>>
41
+ readonly errors: FormErrors
42
+ readonly dirtyPaths: ReadonlyArray<string>
43
+ readonly submitCount: number
44
+ readonly validationCount: number
45
+ readonly lastSubmittedValues: Option.Option<Values>
46
+ readonly arrayKeys: Readonly<Record<string, ReadonlyArray<string>>>
47
+ }
48
+
49
+ export interface FieldBinding<A> {
50
+ readonly path: string
51
+ readonly value: A
52
+ readonly set: (value: A | ((prev: A) => A)) => void
53
+ readonly blur: () => void
54
+ readonly error: Option.Option<string>
55
+ readonly dirty: boolean
56
+ readonly touched: boolean
57
+ readonly validating: boolean
58
+ readonly limitations: FieldLimitations<A>
59
+ }
60
+
61
+ export interface ArrayItemView<Item> {
62
+ readonly key: string
63
+ readonly index: number
64
+ readonly value: Item
65
+ readonly remove: () => void
66
+ readonly move: (to: number) => void
67
+ }
68
+
69
+ export interface ArrayBinding<Item> {
70
+ readonly path: string
71
+ readonly items: ReadonlyArray<ArrayItemView<Item>>
72
+ readonly append: (value?: Item) => void
73
+ readonly remove: (index: number) => void
74
+ readonly move: (from: number, to: number) => void
75
+ readonly swap: (a: number, b: number) => void
76
+ readonly limitations: FieldLimitations<ReadonlyArray<Item>>
77
+ }
78
+
79
+ export interface FormView<Values, Inputs = {}> {
80
+ readonly values: Values
81
+ readonly inputs: Inputs
82
+ readonly errors: FormErrors
83
+ readonly dirty: boolean
84
+ readonly canSubmit: boolean
85
+ readonly submitCount: number
86
+ readonly validationCount: number
87
+ readonly lastSubmittedValues: Option.Option<Values>
88
+ readonly submit: () => void
89
+ readonly reset: () => void
90
+ readonly validate: () => void
91
+ readonly field: <P extends FieldPath<Values>>(path: P) => FieldBinding<PathValue<Values, P>>
92
+ readonly array: <P extends ArrayPath<Values>>(path: P) => ArrayBinding<ArrayItem<Values, P>>
93
+ readonly variantValue: <P extends VariantPath<Values>>(path: P) => VariantValue<Values, P>
94
+ }
95
+
96
+ export type InputRecord = Readonly<Record<string, AnySource>>
97
+
98
+ export type InputsObject<Inputs extends InputRecord> = {
99
+ readonly [K in keyof Inputs as SourceName<Inputs[K]>]: ReformSourceValue<Inputs[K]>
100
+ }
101
+
102
+ export type InputStores<Inputs extends InputRecord> = {
103
+ [K in keyof Inputs]: Inputs[K] extends {
104
+ readonly store: Context.Tag<infer Service, infer _Store>
105
+ }
106
+ ? Service
107
+ : never
108
+ }[keyof Inputs]
109
+
110
+ export type ValuesOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Encoded<Schema>
111
+ export type DecodedOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Type<Schema>
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: { readonly values: Values; readonly inputs: Inputs }) => Limitations
117
+ readonly validate: (ctx: {
118
+ readonly values: Values
119
+ readonly decoded: Decoded
120
+ readonly inputs: Inputs
121
+ }) => Effect.Effect<ReadonlyArray<FormError>, never, never>
122
+ readonly submit: (ctx: {
123
+ readonly values: Values
124
+ readonly decoded: Decoded
125
+ readonly inputs: Inputs
126
+ }) => Effect.Effect<void, unknown, never>
127
+ }
128
+
129
+ export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
130
+ readonly initial: Values
131
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
132
+ readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
133
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
134
+ readonly validate?: (ctx: {
135
+ readonly values: Values
136
+ readonly decoded: Decoded
137
+ readonly inputs: Inputs
138
+ }) =>
139
+ | void
140
+ | FormError
141
+ | ReadonlyArray<FormError>
142
+ | Effect.Effect<void | FormError | ReadonlyArray<FormError>, never, R>
143
+ // oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
144
+ readonly submit?: (ctx: {
145
+ readonly values: Values
146
+ readonly decoded: Decoded
147
+ readonly inputs: Inputs
148
+ }) => void | Effect.Effect<unknown, unknown, R>
149
+ }
150
+
151
+ export interface FormSchemaReflection {
152
+ readonly ast: S.Schema<unknown, unknown, unknown>['ast']
153
+ }
154
+
155
+ export interface FormManifestReflection<N extends string> {
156
+ readonly kind: 'Form'
157
+ readonly name: N
158
+ readonly schema: FormSchemaReflection
159
+ readonly inputs: InputRecord
160
+ }
161
+
162
+ export interface FormManifest<
163
+ N extends string,
164
+ Schema extends S.Schema.AnyNoContext,
165
+ Inputs extends InputRecord,
166
+ > extends FormManifestReflection<N> {
167
+ readonly schema: Schema
168
+ readonly inputs: Inputs
169
+ }
170
+
171
+ export interface FormEvents {
172
+ readonly set: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
173
+ readonly blur: Event.EventClass<string, { readonly path: string }>
174
+ readonly reset: Event.EventClass<string, {}>
175
+ readonly validationFinished: Event.EventClass<string, { readonly errors: FormErrors }>
176
+ readonly submitAttempted: Event.EventClass<string, {}>
177
+ readonly submitSucceeded: Event.EventClass<string, { readonly values: unknown }>
178
+ readonly append: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
179
+ readonly remove: Event.EventClass<string, { readonly path: string; readonly index: number }>
180
+ readonly move: Event.EventClass<
181
+ string,
182
+ { readonly path: string; readonly from: number; readonly to: number }
183
+ >
184
+ readonly swap: Event.EventClass<
185
+ string,
186
+ { readonly path: string; readonly a: number; readonly b: number }
187
+ >
188
+ }
189
+
190
+ export interface FormVisitor<Result> {
191
+ readonly visit: <
192
+ N extends string,
193
+ Schema extends S.Schema.AnyNoContext,
194
+ Inputs extends InputRecord,
195
+ >(
196
+ form: FormClass<N, Schema, Inputs>,
197
+ ) => Result
198
+ }
199
+
200
+ export interface AnyForm {
201
+ new (): {}
202
+ readonly manifest: FormManifestReflection<string>
203
+ readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
204
+ }
205
+
206
+ export interface FormClass<
207
+ N extends string,
208
+ Schema extends S.Schema.AnyNoContext,
209
+ Inputs extends InputRecord,
210
+ > {
211
+ new (): {}
212
+ readonly manifest: FormManifest<N, Schema, Inputs>
213
+ readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
214
+ readonly state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
215
+ readonly config: Context.Tag<
216
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
217
+ RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
218
+ >
219
+ readonly events: FormEvents
220
+ readonly reducer: Reducer.StateReducerClass<
221
+ State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>,
222
+ ReadonlyArray<Event.AnyEvent>
223
+ >
224
+ }
225
+
226
+ export type Values<F> =
227
+ F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? ValuesOfSchema<Schema> : never
228
+
229
+ export type Decoded<F> =
230
+ F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? DecodedOfSchema<Schema> : never
231
+
232
+ export type Inputs<F> =
233
+ F extends FormClass<infer _Name, infer _Schema, infer Inputs> ? InputsObject<Inputs> : never
234
+
235
+ export type View<F> = FormView<Values<F>, Inputs<F>>