@goodie-forms/core 1.2.5-alpha → 1.2.6-alpha

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.
Files changed (43) hide show
  1. package/dist/field/FieldPath.d.ts +18 -9
  2. package/dist/field/FieldPath.d.ts.map +1 -1
  3. package/dist/field/FieldPathBuilder.d.ts +2 -2
  4. package/dist/field/FieldPathBuilder.d.ts.map +1 -1
  5. package/dist/field/Reconcile.d.ts +2 -2
  6. package/dist/field/Reconcile.d.ts.map +1 -1
  7. package/dist/form/FormController.d.ts +51 -20
  8. package/dist/form/FormController.d.ts.map +1 -1
  9. package/dist/form/FormField.d.ts +15 -10
  10. package/dist/form/FormField.d.ts.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +523 -476
  14. package/dist/index.js.map +1 -1
  15. package/dist/types/DeepHelpers.d.ts +11 -0
  16. package/dist/types/DeepHelpers.d.ts.map +1 -0
  17. package/dist/types/FormHelpers.d.ts +5 -0
  18. package/dist/types/FormHelpers.d.ts.map +1 -0
  19. package/dist/types/Suppliable.d.ts +3 -0
  20. package/dist/types/Suppliable.d.ts.map +1 -0
  21. package/dist/validation/CustomValidation.d.ts +1 -1
  22. package/package.json +13 -3
  23. package/src/field/FieldPath.spec.ts +204 -0
  24. package/src/field/FieldPath.ts +62 -59
  25. package/src/field/FieldPathBuilder.spec.ts +47 -0
  26. package/src/field/FieldPathBuilder.ts +15 -81
  27. package/src/field/Reconcile.ts +2 -2
  28. package/src/form/FormController.spec.ts +55 -0
  29. package/src/form/FormController.ts +151 -115
  30. package/src/form/FormField.ts +63 -30
  31. package/src/index.ts +2 -2
  32. package/src/types/DeepHelpers.ts +15 -0
  33. package/src/types/FormHelpers.ts +13 -0
  34. package/src/types/Suppliable.ts +7 -0
  35. package/src/validation/CustomValidation.ts +1 -1
  36. package/dist/form/NonullFormField.d.ts +0 -9
  37. package/dist/form/NonullFormField.d.ts.map +0 -1
  38. package/dist/types/DeepPartial.d.ts +0 -6
  39. package/dist/types/DeepPartial.d.ts.map +0 -1
  40. package/src/form/NonullFormField.ts +0 -15
  41. package/src/types/DeepPartial.ts +0 -7
  42. package/tsconfig.json +0 -8
  43. package/vite.config.ts +0 -18
@@ -3,9 +3,10 @@ import { produce } from "immer";
3
3
  import { createNanoEvents } from "nanoevents";
4
4
  import { FieldPath } from "../field/FieldPath";
5
5
  import { FieldPathBuilder } from "../field/FieldPathBuilder";
6
- import { Reconsile } from "../field/Reconcile";
6
+ import { Reconcile } from "../field/Reconcile";
7
7
  import { FormField } from "../form/FormField";
8
- import { DeepPartial } from "../types/DeepPartial";
8
+ import { DeepPartial, DeepReadonly } from "../types/DeepHelpers";
9
+ import { Suppliable, supply } from "../types/Suppliable";
9
10
  import { ensureImmerability } from "../utils/ensureImmerability";
10
11
  import { removeBy } from "../utils/removeBy";
11
12
 
@@ -13,7 +14,7 @@ export namespace FormController {
13
14
  export type Configs<TOutput extends object> = {
14
15
  initialData?: DeepPartial<TOutput>;
15
16
  validationSchema?: StandardSchemaV1<unknown, TOutput>;
16
- equalityComparators?: Record<any, (a: any, b: any) => boolean>;
17
+ equalityComparators?: Map<any, (a: any, b: any) => boolean>;
17
18
  };
18
19
 
19
20
  export interface PreventableEvent {
@@ -22,45 +23,62 @@ export namespace FormController {
22
23
 
23
24
  export type SubmitSuccessHandler<
24
25
  TOutput extends object,
25
- TEvent extends PreventableEvent,
26
- > = (
27
- data: TOutput,
28
- event: TEvent,
29
- abortSignal: AbortSignal,
30
- ) => void | Promise<void>;
31
-
32
- export type SubmitErrorHandler<TEvent extends PreventableEvent> = (
33
- issues: StandardSchemaV1.Issue[],
34
- event: TEvent,
35
- abortSignal: AbortSignal,
36
- ) => void | Promise<void>;
26
+ TEvent extends PreventableEvent | null | undefined,
27
+ > = (data: DeepReadonly<TOutput>, event: TEvent) => void | Promise<void>;
28
+
29
+ export type SubmitErrorHandler<
30
+ TEvent extends PreventableEvent | null | undefined,
31
+ > = (issues: StandardSchemaV1.Issue[], event: TEvent) => void | Promise<void>;
37
32
  }
38
33
 
39
34
  export class FormController<TOutput extends object> {
40
- _isValidating = false;
41
- _isSubmitting = false;
35
+ private _isValidating = false;
36
+ private _isSubmitting = false;
37
+ private _triedSubmitting = false;
38
+
39
+ private pathBuilder = new FieldPathBuilder<TOutput>();
42
40
 
41
+ /** @internal use `this.getFields` instead */
43
42
  _fields = new Map<string, FormField<TOutput, any>>();
43
+ /** @internal use `this.initialData` instead */
44
44
  _initialData: DeepPartial<TOutput>;
45
+ /** @internal use `this.data` instead */
45
46
  _data: DeepPartial<TOutput>;
47
+ /** @internal use `this.issues` instead */
46
48
  _issues: StandardSchemaV1.Issue[] = [];
47
49
 
48
- equalityComparators?: Record<any, (a: any, b: any) => boolean>;
50
+ equalityComparators?: Map<any, (a: any, b: any) => boolean>;
49
51
  validationSchema?: StandardSchemaV1<unknown, TOutput>;
50
52
 
51
53
  public readonly events = createNanoEvents<{
54
+ /** Emitted when `this.isSubmitting` changes */
52
55
  submissionStatusChange(isSubmitting: boolean): void;
56
+ /** Emitted when `this.isValidating` changes */
53
57
  validationStatusChange(isValidating: boolean): void;
54
58
 
55
- fieldBound(fieldPath: FieldPath.Segments): void;
56
- fieldUnbound(fieldPath: FieldPath.Segments): void;
59
+ /** Emitted when a field is registered */
60
+ fieldRegistered(fieldPath: FieldPath.Segments): void;
61
+ /** Emitted when a field is unregistered */
62
+ fieldUnregistered(fieldPath: FieldPath.Segments): void;
63
+ /** Emitted when a field's `isTouched` changes */
57
64
  fieldTouchUpdated(path: FieldPath.Segments): void;
65
+ /** Emitted when a field's `isDirty` changes */
58
66
  fieldDirtyUpdated(path: FieldPath.Segments): void;
67
+ /** Emitted when a field's `issues` changes */
59
68
  fieldIssuesUpdated(fieldPath: FieldPath.Segments): void;
69
+ /** Emitted when a field's `reset()` is invoked */
70
+ fieldReset(fieldPath: FieldPath.Segments): void;
71
+
72
+ /** Emitted when a HTMLELement is bound to a field */
60
73
  elementBound(fieldPath: FieldPath.Segments, el: HTMLElement): void;
74
+ /** Emitted when a HTMLELement is unbound from a field */
61
75
  elementUnbound(fieldPath: FieldPath.Segments): void;
62
- validationTriggered(fieldPath: FieldPath.Segments): void;
63
- valueChanged(
76
+
77
+ /** Emitted when validation is triggered on a field */
78
+ fieldValidationTriggered(fieldPath: FieldPath.Segments): void;
79
+
80
+ /** Emitted when a field's `value` is updated */
81
+ fieldValueChanged(
64
82
  fieldPath: FieldPath.Segments,
65
83
  newValue: {} | undefined,
66
84
  oldValue: {} | undefined,
@@ -74,6 +92,22 @@ export class FormController<TOutput extends object> {
74
92
  this._data = produce(this._initialData, () => {});
75
93
  }
76
94
 
95
+ get data(): DeepReadonly<DeepPartial<TOutput>> {
96
+ return this._data;
97
+ }
98
+
99
+ get initialData(): DeepReadonly<DeepPartial<TOutput>> {
100
+ return this._initialData;
101
+ }
102
+
103
+ get issues(): readonly StandardSchemaV1.Issue[] {
104
+ return this._issues;
105
+ }
106
+
107
+ get path() {
108
+ return this.pathBuilder;
109
+ }
110
+
77
111
  get isDirty() {
78
112
  for (const field of this._fields.values()) {
79
113
  if (field.isDirty) return true;
@@ -94,6 +128,10 @@ export class FormController<TOutput extends object> {
94
128
  return this._isSubmitting;
95
129
  }
96
130
 
131
+ get triedSubmitting() {
132
+ return this._triedSubmitting;
133
+ }
134
+
97
135
  protected setValidating(newStatus: boolean) {
98
136
  if (this._isValidating === newStatus) return;
99
137
  this._isValidating = newStatus;
@@ -106,85 +144,106 @@ export class FormController<TOutput extends object> {
106
144
  this.events.emit("submissionStatusChange", newStatus);
107
145
  }
108
146
 
109
- _unsafeSetFieldValue<TPath extends FieldPath.Segments>(
110
- path: TPath,
111
- value: FieldPath.Resolve<TOutput, TPath>,
112
- config?: { updateInitialValue?: boolean },
113
- ) {
114
- ensureImmerability(value);
115
-
116
- if (config?.updateInitialValue === true) {
117
- this._initialData = produce(this._initialData, (draft) => {
118
- FieldPath.setValue(draft as TOutput, path, value);
119
- });
120
- }
121
-
122
- this._data = produce(this._data, (draft) => {
123
- FieldPath.setValue(draft as TOutput, path, value);
124
- });
125
- }
126
-
127
- // TODO: Rename to "register" ??
128
- bindField<TPath extends FieldPath.Segments>(
147
+ registerField<TPath extends FieldPath.Segments>(
129
148
  path: TPath,
130
149
  config?: {
131
- defaultValue?: FieldPath.Resolve<TOutput, TPath>;
132
- domElement?: HTMLElement;
150
+ /**
151
+ * Used to set value at **path** in **data**, if it's missing.
152
+ */
153
+ defaultValue?: Suppliable<FieldPath.Resolve<TOutput, TPath>>;
154
+ /**
155
+ * Whether value in **initialData** should also be changed, if **defaultValue** is used or not
156
+ *
157
+ * If this is set to `true` and **defaultValue** is used; **initialData** will be modified
158
+ */
133
159
  overrideInitialValue?: boolean;
134
160
  },
135
161
  ) {
136
162
  let currentValue = FieldPath.getValue(this._data as TOutput, path);
137
163
 
138
164
  if (currentValue == null && config?.defaultValue != null) {
139
- this._unsafeSetFieldValue(path, config.defaultValue, {
140
- updateInitialValue: config.overrideInitialValue,
165
+ const defaultValue = supply(config.defaultValue);
166
+
167
+ ensureImmerability(defaultValue);
168
+
169
+ if (config?.overrideInitialValue === true) {
170
+ this._initialData = produce(this._initialData, (draft) => {
171
+ FieldPath.setValue(draft as TOutput, path, defaultValue);
172
+ });
173
+ }
174
+
175
+ this._data = produce(this._data, (draft) => {
176
+ FieldPath.setValue(draft as TOutput, path, defaultValue);
141
177
  });
178
+
142
179
  currentValue = FieldPath.getValue(this._data as TOutput, path);
143
180
  }
144
181
 
145
182
  const initialValue = FieldPath.getValue(this._initialData as TOutput, path);
146
183
 
147
- const valueChanged = !Reconsile.deepEqual(currentValue, initialValue);
184
+ const valueChanged = !Reconcile.deepEqual(currentValue, initialValue);
148
185
 
149
- const field = new FormField(this, path, {
186
+ const field = new FormField(this, path, config?.defaultValue, {
150
187
  isDirty: valueChanged,
151
188
  });
152
189
 
153
- if (config?.domElement != null) {
154
- field.bindElement(config.domElement);
155
- }
156
-
157
190
  this._fields.set(field.stringPath, field);
158
- this.events.emit("fieldBound", field.path);
191
+ this.events.emit("fieldRegistered", field.path);
159
192
 
160
193
  if (valueChanged) {
161
- this.events.emit("valueChanged", field.path, currentValue, initialValue);
194
+ this.events.emit(
195
+ "fieldValueChanged",
196
+ field.path,
197
+ currentValue,
198
+ initialValue,
199
+ );
162
200
  }
163
201
 
164
202
  return field as FormField<TOutput, FieldPath.Resolve<TOutput, TPath>>;
165
203
  }
166
204
 
167
- unbindField(path: FieldPath.Segments) {
205
+ unregisterField(path: FieldPath.Segments) {
168
206
  const stringPath = FieldPath.toStringPath(path);
207
+
208
+ if (!this._fields.has(stringPath)) return false;
209
+
169
210
  this._fields.delete(stringPath);
170
- this.events.emit("fieldUnbound", path);
211
+ this.events.emit("fieldUnregistered", path);
212
+ return true;
171
213
  }
172
214
 
173
- // TODO: Add an option to keep dirty/touched fields as they are
174
215
  reset(newInitialData?: DeepPartial<TOutput>) {
175
- this._data = this._initialData;
176
- this._issues = [];
216
+ const newData = newInitialData ?? this._initialData;
217
+
218
+ const fieldUpdates = [] as [FieldPath.Segments, any, any][];
177
219
 
178
220
  for (const field of this._fields.values()) {
221
+ const currentValue = FieldPath.getValue(this._data, field.path);
222
+ const nextValue = FieldPath.getValue(newData, field.path);
223
+
224
+ if (!Reconcile.deepEqual(currentValue, nextValue)) {
225
+ fieldUpdates.push([field.path, nextValue, currentValue]);
226
+ }
227
+
179
228
  field.reset();
180
229
  }
181
230
 
182
231
  if (newInitialData != null) {
183
- this._initialData = newInitialData;
184
- this._data = produce(this._initialData, () => {});
232
+ this._initialData = produce(newInitialData, () => {});
185
233
  }
234
+
235
+ this._data = produce(newData, () => {});
236
+ this._issues = [];
237
+ this._triedSubmitting = false;
238
+
239
+ fieldUpdates.forEach(([path, newValue, oldValue]) => {
240
+ this.events.emit("fieldValueChanged", path, newValue, oldValue);
241
+ });
186
242
  }
187
243
 
244
+ // TODO: resetGracefully(newInitialData?: DeepPartial<TOutput>)
245
+ // TODO: ^ Keeps dirty/touched fields as they are
246
+
188
247
  getAscendantFields<TPath extends FieldPath.Segments>(path: TPath) {
189
248
  const paths = path.map((_, i) => {
190
249
  return path.slice(0, i + 1);
@@ -200,6 +259,10 @@ export class FormController<TOutput extends object> {
200
259
  | undefined;
201
260
  }
202
261
 
262
+ getFields() {
263
+ return this._fields.values();
264
+ }
265
+
203
266
  clearFieldIssues<TPath extends FieldPath.Segments>(path: TPath) {
204
267
  this._issues = this._issues.filter((issue) => {
205
268
  return !FieldPath.equals(FieldPath.normalize(issue.path), path);
@@ -210,10 +273,10 @@ export class FormController<TOutput extends object> {
210
273
  _result: StandardSchemaV1.Result<TOutput>,
211
274
  path: TPath,
212
275
  ) {
213
- const diff = Reconsile.diff(
276
+ const diff = Reconcile.arrayDiff(
214
277
  this._issues,
215
278
  _result.issues ?? [],
216
- Reconsile.deepEqual,
279
+ Reconcile.deepEqual,
217
280
  (issue) => {
218
281
  if (issue.path == null) return false;
219
282
  const issuePath = FieldPath.normalize(issue.path);
@@ -233,54 +296,56 @@ export class FormController<TOutput extends object> {
233
296
  }
234
297
  }
235
298
 
236
- async validateField<TPath extends FieldPath.Segments>(path: TPath) {
299
+ async validateForm() {
237
300
  if (this._isValidating) return;
238
301
 
239
302
  if (this.validationSchema == null) return;
240
303
 
241
304
  this.setValidating(true);
242
305
 
243
- if (this.getField(path) == null) this.bindField(path);
244
-
245
306
  const result = await this.validationSchema["~standard"].validate(
246
307
  this._data,
247
308
  );
248
309
 
249
- this.events.emit("validationTriggered", path);
250
- this.applyValidation(result, path);
310
+ for (const stringPath of this._fields.keys()) {
311
+ const path = FieldPath.fromStringPath(stringPath);
312
+ this.events.emit("fieldValidationTriggered", path);
313
+ this.applyValidation(result, path);
314
+ }
315
+
316
+ // Append non-registered issues too
317
+ const diff = Reconcile.arrayDiff(
318
+ this._issues,
319
+ result.issues ?? [],
320
+ Reconcile.deepEqual,
321
+ );
322
+ diff.added.forEach((issue) => this._issues.push(issue));
251
323
 
252
324
  this.setValidating(false);
253
325
  }
254
326
 
255
- async validateForm() {
327
+ async validateField<TPath extends FieldPath.Segments>(path: TPath) {
256
328
  if (this._isValidating) return;
257
329
 
258
330
  if (this.validationSchema == null) return;
259
331
 
260
332
  this.setValidating(true);
261
333
 
334
+ if (this.getField(path) == null) this.registerField(path);
335
+
262
336
  const result = await this.validationSchema["~standard"].validate(
263
337
  this._data,
264
338
  );
265
339
 
266
- for (const stringPath of this._fields.keys()) {
267
- const path = FieldPath.fromStringPath(stringPath);
268
- this.events.emit("validationTriggered", path);
269
- this.applyValidation(result, path);
270
- }
271
-
272
- // Append non-registered issues too
273
- const diff = Reconsile.diff(
274
- this._issues,
275
- result.issues ?? [],
276
- Reconsile.deepEqual,
277
- );
278
- diff.added.forEach((issue) => this._issues.push(issue));
340
+ this.events.emit("fieldValidationTriggered", path);
341
+ this.applyValidation(result, path);
279
342
 
280
343
  this.setValidating(false);
281
344
  }
282
345
 
283
- createSubmitHandler<TEvent extends FormController.PreventableEvent>(
346
+ createSubmitHandler<
347
+ TEvent extends FormController.PreventableEvent | null | undefined,
348
+ >(
284
349
  onSuccess?: FormController.SubmitSuccessHandler<TOutput, TEvent>,
285
350
  onError?: FormController.SubmitErrorHandler<TEvent>,
286
351
  ) {
@@ -292,14 +357,13 @@ export class FormController<TOutput extends object> {
292
357
  if (this._isValidating) return;
293
358
  if (this._isSubmitting) return;
294
359
 
295
- // TODO? impl or cancel
296
- const abortController = new AbortController();
360
+ this._triedSubmitting = true;
297
361
 
298
362
  await this.validateForm();
299
363
 
300
364
  if (this._issues.length === 0) {
301
365
  this.setSubmitting(true);
302
- await onSuccess?.(this._data as TOutput, event, abortController.signal);
366
+ await onSuccess?.(this._data as any as DeepReadonly<TOutput>, event);
303
367
  this.setSubmitting(false);
304
368
  return;
305
369
  }
@@ -313,36 +377,8 @@ export class FormController<TOutput extends object> {
313
377
  field.focus();
314
378
  break;
315
379
  }
316
- await onError?.(this._issues, event, abortController.signal);
380
+ await onError?.(this._issues, event);
317
381
  this.setSubmitting(false);
318
382
  };
319
383
  }
320
384
  }
321
-
322
- /* ---- TESTS ---------------- */
323
-
324
- // interface User {
325
- // name: string;
326
- // address: {
327
- // city: string;
328
- // street: string;
329
- // };
330
- // friends: {
331
- // name: string;
332
- // tags: string[];
333
- // }[];
334
- // coords: [100, 200];
335
- // }
336
-
337
- // const formController = new FormController<User>({});
338
- // const fieldPathBuilder = new FieldPathBuilder<User>();
339
-
340
- // formController.events.on("valueChanged", (fieldPath, value) => {
341
- // // ^?
342
- // });
343
-
344
- // const path1 = fieldPathBuilder.fromProxy((data) => data.friends[0].tags[99]);
345
- // const field1 = formController.getField(path1);
346
-
347
- // const path2 = fieldPathBuilder.fromStringPath("coords[1]");
348
- // const field2 = formController.getField(path2);
@@ -1,7 +1,9 @@
1
- import { produce } from "immer";
1
+ import { Draft, produce } from "immer";
2
2
  import { FieldPath } from "../field/FieldPath";
3
- import { Reconsile } from "../field/Reconcile";
3
+ import { Reconcile } from "../field/Reconcile";
4
4
  import { FormController } from "../form/FormController";
5
+ import { DeepReadonly } from "../types/DeepHelpers";
6
+ import { Suppliable, supply } from "../types/Suppliable";
5
7
  import { ensureImmerability } from "../utils/ensureImmerability";
6
8
  import { getId } from "../utils/getId";
7
9
 
@@ -13,9 +15,11 @@ export class FormField<TOutput extends object, TValue> {
13
15
  protected _isTouched = false;
14
16
  protected _isDirty = false;
15
17
 
18
+ /** @internal register via `FormController::registerField` instead */
16
19
  constructor(
17
20
  public readonly controller: FormController<TOutput>,
18
21
  public readonly path: FieldPath.Segments,
22
+ public readonly defaultValue?: Suppliable<TValue>,
19
23
  initialState?: {
20
24
  isTouched?: boolean;
21
25
  isDirty?: boolean;
@@ -25,20 +29,16 @@ export class FormField<TOutput extends object, TValue> {
25
29
  if (initialState?.isDirty) this._setDirty(true);
26
30
  }
27
31
 
28
- get canonicalPath() {
29
- return FieldPath.toCanonicalPath(this.path);
30
- }
31
-
32
32
  get stringPath() {
33
33
  return FieldPath.toStringPath(this.path);
34
34
  }
35
35
 
36
- get value(): TValue | undefined {
37
- return FieldPath.getValue(this.controller._data, this.path);
36
+ get value(): DeepReadonly<TValue> | undefined {
37
+ return FieldPath.getValue(this.controller.data, this.path);
38
38
  }
39
39
 
40
- get initialValue(): TValue | undefined {
41
- return FieldPath.getValue(this.controller._initialData, this.path);
40
+ get initialValue(): DeepReadonly<TValue> | undefined {
41
+ return FieldPath.getValue(this.controller.initialData, this.path);
42
42
  }
43
43
 
44
44
  get boundElement() {
@@ -63,6 +63,16 @@ export class FormField<TOutput extends object, TValue> {
63
63
  return this.issues.length === 0;
64
64
  }
65
65
 
66
+ ensureDefault(opts?: Parameters<typeof this.modifyData>[1]) {
67
+ if (this.value == null && this.defaultValue != null) {
68
+ const defaultValue = supply(this.defaultValue);
69
+
70
+ if (defaultValue != null) {
71
+ this.setValue(defaultValue, opts);
72
+ }
73
+ }
74
+ }
75
+
66
76
  protected _setTouched(isTouched: boolean) {
67
77
  const changed = this._isTouched !== isTouched;
68
78
  this._isTouched = isTouched;
@@ -92,26 +102,33 @@ export class FormField<TOutput extends object, TValue> {
92
102
  }
93
103
 
94
104
  bindElement(el: HTMLElement | undefined) {
95
- this.target = el;
96
105
  if (el != null) {
106
+ this.target = el;
97
107
  this.controller.events.emit("elementBound", this.path, el);
98
108
  } else {
109
+ this.unbindElement();
110
+ }
111
+ }
112
+
113
+ unbindElement() {
114
+ if (this.target != null) {
115
+ this.target = undefined;
99
116
  this.controller.events.emit("elementUnbound", this.path);
100
117
  }
101
118
  }
102
119
 
103
- setValue(value: TValue, opts?: Parameters<typeof this.modifyValue>[1]) {
104
- return this.modifyValue(() => value, opts);
120
+ clearIssues() {
121
+ return this.controller.clearFieldIssues(this.path);
105
122
  }
106
123
 
107
- modifyValue(
108
- modifier: (currentValue: TValue | undefined) => TValue | void,
124
+ private modifyData(
125
+ draftConsumer: (draft: Draft<typeof this.controller.data>) => void,
109
126
  opts?: {
110
127
  shouldTouch?: boolean;
111
128
  shouldMarkDirty?: boolean;
112
129
  },
113
- ): void {
114
- if (opts?.shouldTouch == null || opts?.shouldTouch) {
130
+ ) {
131
+ if (opts?.shouldTouch == null || opts?.shouldTouch === true) {
115
132
  this.touch();
116
133
  }
117
134
 
@@ -123,11 +140,7 @@ export class FormField<TOutput extends object, TValue> {
123
140
  const oldValues = ascendantFields.map((field) => field?.value);
124
141
  oldValues.forEach((v) => ensureImmerability(v));
125
142
 
126
- this.controller._data = produce(this.controller._data, (draft) => {
127
- FieldPath.modifyValue(draft as TOutput, this.path, (oldValue) => {
128
- return modifier(oldValue);
129
- });
130
- });
143
+ this.controller._data = produce(this.controller._data, draftConsumer);
131
144
 
132
145
  const newValues = ascendantFields.map((field) => field?.value);
133
146
  newValues.forEach((v) => ensureImmerability(v));
@@ -138,10 +151,10 @@ export class FormField<TOutput extends object, TValue> {
138
151
  const ctorA = a.constructor;
139
152
  const ctorB = b.constructor;
140
153
  if (ctorA !== ctorB) return;
141
- return this.controller.equalityComparators?.[ctorA]?.(a, b);
154
+ return this.controller.equalityComparators?.get(ctorA)?.(a, b);
142
155
  };
143
156
 
144
- const valueChanged = !Reconsile.deepEqual(
157
+ const valueChanged = !Reconcile.deepEqual(
145
158
  oldValues[oldValues.length - 1],
146
159
  newValues[newValues.length - 1],
147
160
  compareCustom,
@@ -151,7 +164,7 @@ export class FormField<TOutput extends object, TValue> {
151
164
  for (let i = ascendantFields.length - 1; i >= 0; i--) {
152
165
  const field = ascendantFields[i];
153
166
  this.controller.events.emit(
154
- "valueChanged",
167
+ "fieldValueChanged",
155
168
  field.path,
156
169
  newValues[i],
157
170
  oldValues[i],
@@ -160,7 +173,7 @@ export class FormField<TOutput extends object, TValue> {
160
173
  }
161
174
 
162
175
  if (opts?.shouldMarkDirty == null || opts?.shouldMarkDirty) {
163
- const gotDirty = !Reconsile.deepEqual(
176
+ const gotDirty = !Reconcile.deepEqual(
164
177
  initialValues[initialValues.length - 1],
165
178
  newValues[newValues.length - 1],
166
179
  compareCustom,
@@ -169,9 +182,27 @@ export class FormField<TOutput extends object, TValue> {
169
182
  }
170
183
  }
171
184
 
185
+ setValue(value: TValue, opts?: Parameters<typeof this.modifyData>[1]) {
186
+ return this.modifyData((data) => {
187
+ FieldPath.setValue(data as TOutput, this.path, value as never);
188
+ }, opts);
189
+ }
190
+
191
+ modifyValue(
192
+ modifier: (currentValue: TValue) => undefined,
193
+ opts?: Parameters<typeof this.modifyData>[1],
194
+ ): void {
195
+ return this.modifyData((data) => {
196
+ FieldPath.modifyValue(data as TOutput, this.path, (oldValue) => {
197
+ modifier(oldValue as TValue);
198
+ });
199
+ }, opts);
200
+ }
201
+
172
202
  reset() {
173
203
  this._setTouched(false);
174
204
  this._setDirty(false);
205
+ this.controller.events.emit("fieldReset", this.path);
175
206
  }
176
207
 
177
208
  touch() {
@@ -179,22 +210,24 @@ export class FormField<TOutput extends object, TValue> {
179
210
  }
180
211
 
181
212
  markDirty() {
182
- this.touch();
183
213
  this._setDirty(true);
184
214
  }
185
215
 
186
- triggerValidation() {
216
+ validate() {
187
217
  this.controller.validateField(this.path);
188
218
  }
189
219
 
190
- focus(opts?: { shouldTouch?: boolean }) {
220
+ focus(opts?: {
221
+ shouldTouch?: boolean;
222
+ scrollOptions?: ScrollIntoViewOptions;
223
+ }) {
191
224
  if (opts?.shouldTouch == null || opts.shouldTouch) {
192
225
  this.target?.addEventListener("focus", () => this.touch(), {
193
226
  once: true,
194
227
  });
195
228
  }
196
229
 
197
- this.target?.scrollIntoView();
230
+ this.target?.scrollIntoView(opts?.scrollOptions);
198
231
  this.target?.focus();
199
232
  }
200
233
  }
package/src/index.ts CHANGED
@@ -4,9 +4,9 @@ export * from "./field/Reconcile";
4
4
 
5
5
  export * from "./form/FormController";
6
6
  export * from "./form/FormField";
7
- export * from "./form/NonullFormField";
8
7
 
9
8
  export * from "./validation/CustomValidation";
10
9
 
11
- export * from "./types/DeepPartial";
10
+ export * from "./types/DeepHelpers";
11
+ export * from "./types/Suppliable";
12
12
  export * from "./types/Mixin";
@@ -0,0 +1,15 @@
1
+ export type DeepPartial<T> = {
2
+ [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K];
3
+ } & {
4
+ [K in keyof T as T[K] extends (...args: any[]) => any
5
+ ? never
6
+ : K]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
7
+ };
8
+
9
+ export type DeepReadonly<T> = {
10
+ [K in keyof T as T[K] extends (...args: any[]) => any ? K : never]: T[K];
11
+ } & {
12
+ readonly [K in keyof T as T[K] extends (...args: any[]) => any
13
+ ? never
14
+ : K]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
15
+ };
@@ -0,0 +1,13 @@
1
+ import { FormField } from "packages/core/src/form/FormField";
2
+
3
+ // TODO: Consider
4
+ export type FieldValue<T extends FormField<any, any>> =
5
+ NonNullable<T> extends { value: infer V } ? NonNullable<V> : never;
6
+
7
+ // TODO: Move to a proper test file
8
+ // declare const x: FormField<{ a: 1; b: 2 }, number>;
9
+
10
+ // type X0 = FieldValue<typeof x>;
11
+ // // ^?
12
+ // type X1 = (typeof x)["value"];
13
+ // // ^?
@@ -0,0 +1,7 @@
1
+ export type Suppliable<T> = T | (() => T);
2
+
3
+ export function supply<T>(suppliable: T | (() => T)): T {
4
+ return typeof suppliable === "function"
5
+ ? (suppliable as () => T)()
6
+ : suppliable;
7
+ }