@lucas-barake/effect-form 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Form.ts CHANGED
@@ -2,22 +2,19 @@
2
2
  * @since 1.0.0
3
3
  */
4
4
  import type * as Effect from "effect/Effect"
5
+ import type * as Option from "effect/Option"
5
6
  import * as Predicate from "effect/Predicate"
6
7
  import * as Schema from "effect/Schema"
7
8
 
8
- /**
9
- * Unique identifier for FormBuilder instances.
10
- *
11
- * @since 1.0.0
12
- * @category Symbols
13
- */
14
- export const TypeId: unique symbol = Symbol.for("@lucas-barake/effect-form/Form")
15
-
16
- /**
17
- * @since 1.0.0
18
- * @category Symbols
19
- */
20
- export type TypeId = typeof TypeId
9
+ import type {
10
+ AnyFieldDef,
11
+ ArrayFieldDef,
12
+ DecodedFromFields,
13
+ EncodedFromFields,
14
+ FieldDef,
15
+ FieldsRecord,
16
+ } from "./Field.js"
17
+ import { isArrayFieldDef, isFieldDef } from "./Field.js"
21
18
 
22
19
  /**
23
20
  * Unique identifier for Field references.
@@ -60,117 +57,23 @@ export const makeFieldRef = <S>(key: string): Field<S> => ({
60
57
  key,
61
58
  })
62
59
 
63
- /**
64
- * A scalar field definition containing the key and schema.
65
- *
66
- * @since 1.0.0
67
- * @category Models
68
- */
69
- export interface FieldDef<K extends string, S extends Schema.Schema.Any> {
70
- readonly _tag: "field"
71
- readonly key: K
72
- readonly schema: S
73
- }
74
-
75
- /**
76
- * An array field definition containing a schema for items.
77
- *
78
- * @since 1.0.0
79
- * @category Models
80
- */
81
- export interface ArrayFieldDef<K extends string, S extends Schema.Schema.Any> {
82
- readonly _tag: "array"
83
- readonly key: K
84
- readonly itemSchema: S
85
- }
86
-
87
- /**
88
- * Union of all field definition types.
89
- *
90
- * @since 1.0.0
91
- * @category Models
92
- */
93
- export type AnyFieldDef = FieldDef<string, Schema.Schema.Any> | ArrayFieldDef<string, Schema.Schema.Any>
94
-
95
- /**
96
- * Creates a scalar field definition.
97
- *
98
- * @example
99
- * ```ts
100
- * const NameField = Form.makeField("name", Schema.String)
101
- * const form = Form.empty.addField(NameField)
102
- * ```
103
- *
104
- * @since 1.0.0
105
- * @category Constructors
106
- */
107
- export const makeField = <K extends string, S extends Schema.Schema.Any>(
108
- key: K,
109
- schema: S,
110
- ): FieldDef<K, S> => ({
111
- _tag: "field",
112
- key,
113
- schema,
114
- })
115
-
116
- /**
117
- * Creates an array field definition.
118
- *
119
- * @example
120
- * ```ts
121
- * // Array of primitives
122
- * const TagsField = Form.makeArrayField("tags", Schema.String)
123
- *
124
- * // Array of objects
125
- * const ItemsField = Form.makeArrayField("items", Schema.Struct({
126
- * name: Schema.String,
127
- * quantity: Schema.Number
128
- * }))
129
- * ```
130
- *
131
- * @since 1.0.0
132
- * @category Constructors
133
- */
134
- export const makeArrayField = <K extends string, S extends Schema.Schema.Any>(
135
- key: K,
136
- itemSchema: S,
137
- ): ArrayFieldDef<K, S> => ({
138
- _tag: "array",
139
- key,
140
- itemSchema,
141
- })
142
-
143
- /**
144
- * A record of field definitions.
145
- *
146
- * @since 1.0.0
147
- * @category Models
148
- */
149
- export type FieldsRecord = Record<string, AnyFieldDef>
60
+ // ================================
61
+ // FormBuilder
62
+ // ================================
150
63
 
151
64
  /**
152
- * Extracts the encoded (input) type from a fields record.
65
+ * Unique identifier for FormBuilder instances.
153
66
  *
154
67
  * @since 1.0.0
155
- * @category Type Helpers
68
+ * @category Symbols
156
69
  */
157
- export type EncodedFromFields<T extends FieldsRecord> = {
158
- readonly [K in keyof T]: T[K] extends FieldDef<any, infer S> ? Schema.Schema.Encoded<S>
159
- : T[K] extends ArrayFieldDef<any, infer S> ? ReadonlyArray<Schema.Schema.Encoded<S>>
160
- : never
161
- }
70
+ export const TypeId: unique symbol = Symbol.for("@lucas-barake/effect-form/Form")
162
71
 
163
72
  /**
164
- * Extracts the decoded (output) type from a fields record.
165
- *
166
73
  * @since 1.0.0
167
- * @category Type Helpers
74
+ * @category Symbols
168
75
  */
169
- export type DecodedFromFields<T extends FieldsRecord> = {
170
- readonly [K in keyof T]: T[K] extends FieldDef<any, infer S> ? Schema.Schema.Type<S>
171
- : T[K] extends ArrayFieldDef<any, infer S> ? ReadonlyArray<Schema.Schema.Type<S>>
172
- : never
173
- }
76
+ export type TypeId = typeof TypeId
174
77
 
175
78
  /**
176
79
  * The state of a form at runtime.
@@ -181,6 +84,7 @@ export type DecodedFromFields<T extends FieldsRecord> = {
181
84
  export interface FormState<TFields extends FieldsRecord> {
182
85
  readonly values: EncodedFromFields<TFields>
183
86
  readonly initialValues: EncodedFromFields<TFields>
87
+ readonly lastSubmittedValues: Option.Option<EncodedFromFields<TFields>>
184
88
  readonly touched: { readonly [K in keyof TFields]: boolean }
185
89
  readonly submitCount: number
186
90
  readonly dirtyFields: ReadonlySet<string>
@@ -372,56 +276,6 @@ const FormBuilderProto = {
372
276
  */
373
277
  export const isFormBuilder = (u: unknown): u is FormBuilder<any, any> => Predicate.hasProperty(u, TypeId)
374
278
 
375
- /**
376
- * Checks if a field definition is an array field.
377
- *
378
- * @since 1.0.0
379
- * @category Guards
380
- */
381
- export const isArrayFieldDef = (def: AnyFieldDef): def is ArrayFieldDef<string, any> => def._tag === "array"
382
-
383
- /**
384
- * Checks if a field definition is a simple field.
385
- *
386
- * @since 1.0.0
387
- * @category Guards
388
- */
389
- export const isFieldDef = (def: AnyFieldDef): def is FieldDef<string, Schema.Schema.Any> => def._tag === "field"
390
-
391
- /**
392
- * Gets a default encoded value from a schema.
393
- *
394
- * @since 1.0.0
395
- * @category Helpers
396
- */
397
- export const getDefaultFromSchema = (schema: Schema.Schema.Any): unknown => {
398
- const ast = schema.ast
399
- switch (ast._tag) {
400
- case "StringKeyword":
401
- case "TemplateLiteral":
402
- return ""
403
- case "NumberKeyword":
404
- return 0
405
- case "BooleanKeyword":
406
- return false
407
- case "TypeLiteral": {
408
- const result: Record<string, unknown> = {}
409
- for (const prop of ast.propertySignatures) {
410
- result[prop.name as string] = getDefaultFromSchema(Schema.make(prop.type))
411
- }
412
- return result
413
- }
414
- case "Transformation":
415
- return getDefaultFromSchema(Schema.make(ast.from))
416
- case "Refinement":
417
- return getDefaultFromSchema(Schema.make(ast.from))
418
- case "Suspend":
419
- return getDefaultFromSchema(Schema.make(ast.f()))
420
- default:
421
- return ""
422
- }
423
- }
424
-
425
279
  /**
426
280
  * An empty `FormBuilder` to start building a form.
427
281
  *
@@ -488,35 +342,3 @@ export const buildSchema = <TFields extends FieldsRecord, R>(
488
342
  R
489
343
  >
490
344
  }
491
-
492
- /**
493
- * Gets default encoded values for a fields record.
494
- *
495
- * @since 1.0.0
496
- * @category Helpers
497
- */
498
- export const getDefaultEncodedValues = (fields: FieldsRecord): Record<string, unknown> => {
499
- const result: Record<string, unknown> = {}
500
- for (const [key, def] of Object.entries(fields)) {
501
- if (isArrayFieldDef(def)) {
502
- result[key] = []
503
- } else {
504
- result[key] = ""
505
- }
506
- }
507
- return result
508
- }
509
-
510
- /**
511
- * Creates a touched record with all fields set to the given value.
512
- *
513
- * @since 1.0.0
514
- * @category Helpers
515
- */
516
- export const createTouchedRecord = (fields: FieldsRecord, value: boolean): Record<string, boolean> => {
517
- const result: Record<string, boolean> = {}
518
- for (const key of Object.keys(fields)) {
519
- result[key] = value
520
- }
521
- return result
522
- }
package/src/FormAtoms.ts CHANGED
@@ -9,14 +9,12 @@
9
9
  import * as Atom from "@effect-atom/atom/Atom"
10
10
  import type * as Registry from "@effect-atom/atom/Registry"
11
11
  import * as Effect from "effect/Effect"
12
- import * as Equal from "effect/Equal"
13
12
  import { pipe } from "effect/Function"
14
13
  import * as Option from "effect/Option"
15
14
  import type * as ParseResult from "effect/ParseResult"
16
15
  import * as Schema from "effect/Schema"
17
- import * as Utils from "effect/Utils"
18
- import type * as Form from "./Form.js"
19
- import { buildSchema, createTouchedRecord, getDefaultFromSchema, makeFieldRef } from "./Form.js"
16
+ import * as Field from "./Field.js"
17
+ import * as Form from "./Form.js"
20
18
  import { recalculateDirtyFieldsForArray, recalculateDirtySubtree } from "./internal/dirty.js"
21
19
  import { getNestedValue, setNestedValue } from "./internal/path.js"
22
20
  import { createWeakRegistry, type WeakRegistry } from "./internal/weak-registry.js"
@@ -40,7 +38,7 @@ export interface FieldAtoms {
40
38
  * @since 1.0.0
41
39
  * @category Models
42
40
  */
43
- export interface FormAtomsConfig<TFields extends Form.FieldsRecord, R> {
41
+ export interface FormAtomsConfig<TFields extends Field.FieldsRecord, R> {
44
42
  readonly runtime: Atom.AtomRuntime<R, any>
45
43
  readonly formBuilder: Form.FormBuilder<TFields, R>
46
44
  }
@@ -51,9 +49,9 @@ export interface FormAtomsConfig<TFields extends Form.FieldsRecord, R> {
51
49
  * @since 1.0.0
52
50
  * @category Models
53
51
  */
54
- export type FieldRefs<TFields extends Form.FieldsRecord> = {
55
- readonly [K in keyof TFields]: TFields[K] extends Form.FieldDef<any, infer S> ? Form.Field<Schema.Schema.Encoded<S>>
56
- : TFields[K] extends Form.ArrayFieldDef<any, infer S> ? Form.Field<ReadonlyArray<Schema.Schema.Encoded<S>>>
52
+ export type FieldRefs<TFields extends Field.FieldsRecord> = {
53
+ readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S> ? Form.Field<Schema.Schema.Encoded<S>>
54
+ : TFields[K] extends Field.ArrayFieldDef<any, infer S> ? Form.Field<ReadonlyArray<Schema.Schema.Encoded<S>>>
57
55
  : never
58
56
  }
59
57
 
@@ -63,20 +61,23 @@ export type FieldRefs<TFields extends Form.FieldsRecord> = {
63
61
  * @since 1.0.0
64
62
  * @category Models
65
63
  */
66
- export interface FormAtoms<TFields extends Form.FieldsRecord, R> {
64
+ export interface FormAtoms<TFields extends Field.FieldsRecord, R> {
67
65
  readonly stateAtom: Atom.Writable<Option.Option<Form.FormState<TFields>>, Option.Option<Form.FormState<TFields>>>
68
66
  readonly crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>
69
67
  readonly dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>
70
68
  readonly isDirtyAtom: Atom.Atom<boolean>
71
69
  readonly submitCountAtom: Atom.Atom<number>
70
+ readonly lastSubmittedValuesAtom: Atom.Atom<Option.Option<Field.EncodedFromFields<TFields>>>
71
+ readonly changedSinceSubmitFieldsAtom: Atom.Atom<ReadonlySet<string>>
72
+ readonly hasChangedSinceSubmitAtom: Atom.Atom<boolean>
72
73
  readonly onSubmitAtom: Atom.Writable<
73
- Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown> | null,
74
- Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown> | null
74
+ Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown> | null,
75
+ Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown> | null
75
76
  >
76
77
 
77
- readonly decodeAndSubmit: Atom.AtomResultFn<Form.EncodedFromFields<TFields>, unknown, unknown>
78
+ readonly decodeAndSubmit: Atom.AtomResultFn<Field.EncodedFromFields<TFields>, unknown, unknown>
78
79
 
79
- readonly combinedSchema: Schema.Schema<Form.DecodedFromFields<TFields>, Form.EncodedFromFields<TFields>, R>
80
+ readonly combinedSchema: Schema.Schema<Field.DecodedFromFields<TFields>, Field.EncodedFromFields<TFields>, R>
80
81
 
81
82
  readonly fieldRefs: FieldRefs<TFields>
82
83
 
@@ -101,51 +102,30 @@ export interface FormAtoms<TFields extends Form.FieldsRecord, R> {
101
102
  * @since 1.0.0
102
103
  * @category Models
103
104
  */
104
- export interface FormOperations<TFields extends Form.FieldsRecord> {
105
- /**
106
- * Creates the initial form state from default values.
107
- */
108
- readonly createInitialState: (defaultValues: Form.EncodedFromFields<TFields>) => Form.FormState<TFields>
105
+ export interface FormOperations<TFields extends Field.FieldsRecord> {
106
+ readonly createInitialState: (defaultValues: Field.EncodedFromFields<TFields>) => Form.FormState<TFields>
109
107
 
110
- /**
111
- * Creates a reset state (back to initial values).
112
- */
113
108
  readonly createResetState: (state: Form.FormState<TFields>) => Form.FormState<TFields>
114
109
 
115
- /**
116
- * Creates state with all fields marked as touched and submit count incremented.
117
- */
118
110
  readonly createSubmitState: (state: Form.FormState<TFields>) => Form.FormState<TFields>
119
111
 
120
- /**
121
- * Updates a single field value in the state.
122
- */
123
112
  readonly setFieldValue: (
124
113
  state: Form.FormState<TFields>,
125
114
  fieldPath: string,
126
115
  value: unknown,
127
116
  ) => Form.FormState<TFields>
128
117
 
129
- /**
130
- * Sets all form values, recalculating dirty fields.
131
- */
132
118
  readonly setFormValues: (
133
119
  state: Form.FormState<TFields>,
134
- values: Form.EncodedFromFields<TFields>,
120
+ values: Field.EncodedFromFields<TFields>,
135
121
  ) => Form.FormState<TFields>
136
122
 
137
- /**
138
- * Sets a field as touched.
139
- */
140
123
  readonly setFieldTouched: (
141
124
  state: Form.FormState<TFields>,
142
125
  fieldPath: string,
143
126
  touched: boolean,
144
127
  ) => Form.FormState<TFields>
145
128
 
146
- /**
147
- * Appends an item to an array field.
148
- */
149
129
  readonly appendArrayItem: (
150
130
  state: Form.FormState<TFields>,
151
131
  arrayPath: string,
@@ -153,18 +133,12 @@ export interface FormOperations<TFields extends Form.FieldsRecord> {
153
133
  value?: unknown,
154
134
  ) => Form.FormState<TFields>
155
135
 
156
- /**
157
- * Removes an item from an array field.
158
- */
159
136
  readonly removeArrayItem: (
160
137
  state: Form.FormState<TFields>,
161
138
  arrayPath: string,
162
139
  index: number,
163
140
  ) => Form.FormState<TFields>
164
141
 
165
- /**
166
- * Swaps two items in an array field.
167
- */
168
142
  readonly swapArrayItems: (
169
143
  state: Form.FormState<TFields>,
170
144
  arrayPath: string,
@@ -172,15 +146,18 @@ export interface FormOperations<TFields extends Form.FieldsRecord> {
172
146
  indexB: number,
173
147
  ) => Form.FormState<TFields>
174
148
 
175
- /**
176
- * Moves an item in an array field.
177
- */
178
149
  readonly moveArrayItem: (
179
150
  state: Form.FormState<TFields>,
180
151
  arrayPath: string,
181
152
  fromIndex: number,
182
153
  toIndex: number,
183
154
  ) => Form.FormState<TFields>
155
+
156
+ /**
157
+ * Reverts values to the last submitted state.
158
+ * No-op if form has never been submitted or is already in sync.
159
+ */
160
+ readonly revertToLastSubmit: (state: Form.FormState<TFields>) => Form.FormState<TFields>
184
161
  }
185
162
 
186
163
  /**
@@ -209,13 +186,13 @@ export interface FormOperations<TFields extends Form.FieldsRecord> {
209
186
  * @since 1.0.0
210
187
  * @category Constructors
211
188
  */
212
- export const make = <TFields extends Form.FieldsRecord, R>(
189
+ export const make = <TFields extends Field.FieldsRecord, R>(
213
190
  config: FormAtomsConfig<TFields, R>,
214
191
  ): FormAtoms<TFields, R> => {
215
192
  const { formBuilder, runtime } = config
216
193
  const { fields } = formBuilder
217
194
 
218
- const combinedSchema = buildSchema(formBuilder)
195
+ const combinedSchema = Form.buildSchema(formBuilder)
219
196
 
220
197
  const stateAtom = Atom.make(Option.none<Form.FormState<TFields>>()).pipe(Atom.setIdleTTL(0))
221
198
  const crossFieldErrorsAtom = Atom.make<Map<string, string>>(new Map()).pipe(Atom.setIdleTTL(0))
@@ -232,26 +209,28 @@ export const make = <TFields extends Form.FieldsRecord, R>(
232
209
  Atom.setIdleTTL(0),
233
210
  )
234
211
 
235
- const onSubmitAtom = Atom.make<Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown> | null>(
236
- null,
212
+ const lastSubmittedValuesAtom = Atom.readable(
213
+ (get) => Option.getOrThrow(get(stateAtom)).lastSubmittedValues,
237
214
  ).pipe(Atom.setIdleTTL(0))
238
215
 
239
- const updateDirtyFields = (
240
- state: Form.FormState<TFields>,
241
- fieldPath: string,
242
- newValue: unknown,
243
- ): ReadonlySet<string> => {
244
- const initialValue = getNestedValue(state.initialValues, fieldPath)
245
- const isEqual = Utils.structuralRegion(() => Equal.equals(newValue, initialValue))
246
-
247
- const newDirtyFields = new Set(state.dirtyFields)
248
- if (!isEqual) {
249
- newDirtyFields.add(fieldPath)
250
- } else {
251
- newDirtyFields.delete(fieldPath)
252
- }
253
- return newDirtyFields
254
- }
216
+ const changedSinceSubmitFieldsAtom = Atom.readable((get) => {
217
+ const state = Option.getOrThrow(get(stateAtom))
218
+ return Option.match(state.lastSubmittedValues, {
219
+ onNone: () => new Set<string>(),
220
+ onSome: (lastSubmitted) => recalculateDirtySubtree(new Set(), lastSubmitted, state.values, ""),
221
+ })
222
+ }).pipe(Atom.setIdleTTL(0))
223
+
224
+ const hasChangedSinceSubmitAtom = Atom.readable((get) => {
225
+ const state = Option.getOrThrow(get(stateAtom))
226
+ if (Option.isNone(state.lastSubmittedValues)) return false
227
+ if (state.values === state.lastSubmittedValues.value) return false
228
+ return get(changedSinceSubmitFieldsAtom).size > 0
229
+ }).pipe(Atom.setIdleTTL(0))
230
+
231
+ const onSubmitAtom = Atom.make<Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown> | null>(
232
+ null,
233
+ ).pipe(Atom.setIdleTTL(0))
255
234
 
256
235
  const validationAtomsRegistry = createWeakRegistry<Atom.AtomResultFn<unknown, void, ParseResult.ParseError>>()
257
236
  const fieldAtomsRegistry = createWeakRegistry<FieldAtoms>()
@@ -282,12 +261,18 @@ export const make = <TFields extends Form.FieldsRecord, R>(
282
261
  (get) => getNestedValue(Option.getOrThrow(get(stateAtom)).values, fieldPath),
283
262
  (ctx, value) => {
284
263
  const currentState = Option.getOrThrow(ctx.get(stateAtom))
264
+ const newValues = setNestedValue(currentState.values, fieldPath, value)
285
265
  ctx.set(
286
266
  stateAtom,
287
267
  Option.some({
288
268
  ...currentState,
289
- values: setNestedValue(currentState.values, fieldPath, value),
290
- dirtyFields: updateDirtyFields(currentState, fieldPath, value),
269
+ values: newValues,
270
+ dirtyFields: recalculateDirtySubtree(
271
+ currentState.dirtyFields,
272
+ currentState.initialValues,
273
+ newValues,
274
+ fieldPath,
275
+ ),
291
276
  }),
292
277
  )
293
278
  },
@@ -330,10 +315,10 @@ export const make = <TFields extends Form.FieldsRecord, R>(
330
315
  fieldAtomsRegistry.clear()
331
316
  }
332
317
 
333
- const decodeAndSubmit = runtime.fn<Form.EncodedFromFields<TFields>>()((values, get) =>
318
+ const decodeAndSubmit = runtime.fn<Field.EncodedFromFields<TFields>>()((values, get) =>
334
319
  Effect.gen(function*() {
335
320
  const decoded = yield* Schema.decodeUnknown(combinedSchema)(values) as Effect.Effect<
336
- Form.DecodedFromFields<TFields>,
321
+ Field.DecodedFromFields<TFields>,
337
322
  ParseResult.ParseError,
338
323
  R
339
324
  >
@@ -341,17 +326,18 @@ export const make = <TFields extends Form.FieldsRecord, R>(
341
326
  get.set(onSubmit, decoded)
342
327
  return yield* get.result(onSubmit, { suspendOnWaiting: true })
343
328
  })
344
- ).pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<Form.EncodedFromFields<TFields>, unknown, unknown>
329
+ ).pipe(Atom.setIdleTTL(0)) as Atom.AtomResultFn<Field.EncodedFromFields<TFields>, unknown, unknown>
345
330
 
346
331
  const fieldRefs = Object.fromEntries(
347
- Object.keys(fields).map((key) => [key, makeFieldRef(key)]),
332
+ Object.keys(fields).map((key) => [key, Form.makeFieldRef(key)]),
348
333
  ) as FieldRefs<TFields>
349
334
 
350
335
  const operations: FormOperations<TFields> = {
351
336
  createInitialState: (defaultValues) => ({
352
337
  values: defaultValues,
353
338
  initialValues: defaultValues,
354
- touched: createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean },
339
+ lastSubmittedValues: Option.none(),
340
+ touched: Field.createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean },
355
341
  submitCount: 0,
356
342
  dirtyFields: new Set(),
357
343
  }),
@@ -359,14 +345,16 @@ export const make = <TFields extends Form.FieldsRecord, R>(
359
345
  createResetState: (state) => ({
360
346
  values: state.initialValues,
361
347
  initialValues: state.initialValues,
362
- touched: createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean },
348
+ lastSubmittedValues: Option.none(),
349
+ touched: Field.createTouchedRecord(fields, false) as { readonly [K in keyof TFields]: boolean },
363
350
  submitCount: 0,
364
351
  dirtyFields: new Set(),
365
352
  }),
366
353
 
367
354
  createSubmitState: (state) => ({
368
355
  ...state,
369
- touched: createTouchedRecord(fields, true) as { readonly [K in keyof TFields]: boolean },
356
+ lastSubmittedValues: Option.some(state.values),
357
+ touched: Field.createTouchedRecord(fields, true) as { readonly [K in keyof TFields]: boolean },
370
358
  submitCount: state.submitCount + 1,
371
359
  }),
372
360
 
@@ -380,7 +368,7 @@ export const make = <TFields extends Form.FieldsRecord, R>(
380
368
  )
381
369
  return {
382
370
  ...state,
383
- values: newValues as Form.EncodedFromFields<TFields>,
371
+ values: newValues as Field.EncodedFromFields<TFields>,
384
372
  dirtyFields: newDirtyFields,
385
373
  }
386
374
  },
@@ -405,12 +393,12 @@ export const make = <TFields extends Form.FieldsRecord, R>(
405
393
  }),
406
394
 
407
395
  appendArrayItem: (state, arrayPath, itemSchema, value) => {
408
- const newItem = value ?? getDefaultFromSchema(itemSchema)
396
+ const newItem = value ?? Field.getDefaultFromSchema(itemSchema)
409
397
  const currentItems = (getNestedValue(state.values, arrayPath) ?? []) as ReadonlyArray<unknown>
410
398
  const newItems = [...currentItems, newItem]
411
399
  return {
412
400
  ...state,
413
- values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
401
+ values: setNestedValue(state.values, arrayPath, newItems) as Field.EncodedFromFields<TFields>,
414
402
  dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
415
403
  }
416
404
  },
@@ -420,7 +408,7 @@ export const make = <TFields extends Form.FieldsRecord, R>(
420
408
  const newItems = currentItems.filter((_, i) => i !== index)
421
409
  return {
422
410
  ...state,
423
- values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
411
+ values: setNestedValue(state.values, arrayPath, newItems) as Field.EncodedFromFields<TFields>,
424
412
  dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
425
413
  }
426
414
  },
@@ -440,7 +428,7 @@ export const make = <TFields extends Form.FieldsRecord, R>(
440
428
  newItems[indexB] = temp
441
429
  return {
442
430
  ...state,
443
- values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
431
+ values: setNestedValue(state.values, arrayPath, newItems) as Field.EncodedFromFields<TFields>,
444
432
  dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
445
433
  }
446
434
  },
@@ -459,10 +447,33 @@ export const make = <TFields extends Form.FieldsRecord, R>(
459
447
  newItems.splice(toIndex, 0, item)
460
448
  return {
461
449
  ...state,
462
- values: setNestedValue(state.values, arrayPath, newItems) as Form.EncodedFromFields<TFields>,
450
+ values: setNestedValue(state.values, arrayPath, newItems) as Field.EncodedFromFields<TFields>,
463
451
  dirtyFields: recalculateDirtyFieldsForArray(state.dirtyFields, state.initialValues, arrayPath, newItems),
464
452
  }
465
453
  },
454
+
455
+ revertToLastSubmit: (state) => {
456
+ if (Option.isNone(state.lastSubmittedValues)) {
457
+ return state
458
+ }
459
+
460
+ if (state.values === state.lastSubmittedValues.value) {
461
+ return state
462
+ }
463
+
464
+ const newDirtyFields = recalculateDirtySubtree(
465
+ state.dirtyFields,
466
+ state.initialValues,
467
+ state.lastSubmittedValues.value,
468
+ "",
469
+ )
470
+
471
+ return {
472
+ ...state,
473
+ values: state.lastSubmittedValues.value,
474
+ dirtyFields: newDirtyFields,
475
+ }
476
+ },
466
477
  }
467
478
 
468
479
  return {
@@ -471,6 +482,9 @@ export const make = <TFields extends Form.FieldsRecord, R>(
471
482
  dirtyFieldsAtom,
472
483
  isDirtyAtom,
473
484
  submitCountAtom,
485
+ lastSubmittedValuesAtom,
486
+ changedSinceSubmitFieldsAtom,
487
+ hasChangedSinceSubmitAtom,
474
488
  onSubmitAtom,
475
489
  decodeAndSubmit,
476
490
  combinedSchema,
package/src/index.ts CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Field definitions for type-safe forms.
3
+ *
4
+ * @since 1.0.0
5
+ */
6
+ export * as Field from "./Field.js"
7
+
1
8
  /**
2
9
  * @since 1.0.0
3
10
  */