@goodie-forms/core 1.2.5-alpha → 1.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.
Files changed (44) hide show
  1. package/README.md +61 -0
  2. package/dist/field/FieldPath.d.ts +18 -9
  3. package/dist/field/FieldPath.d.ts.map +1 -1
  4. package/dist/field/FieldPathBuilder.d.ts +2 -2
  5. package/dist/field/FieldPathBuilder.d.ts.map +1 -1
  6. package/dist/field/Reconcile.d.ts +5 -2
  7. package/dist/field/Reconcile.d.ts.map +1 -1
  8. package/dist/form/FormController.d.ts +51 -20
  9. package/dist/form/FormController.d.ts.map +1 -1
  10. package/dist/form/FormField.d.ts +15 -10
  11. package/dist/form/FormField.d.ts.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +550 -505
  15. package/dist/index.js.map +1 -1
  16. package/dist/types/DeepHelpers.d.ts +11 -0
  17. package/dist/types/DeepHelpers.d.ts.map +1 -0
  18. package/dist/types/FormHelpers.d.ts +5 -0
  19. package/dist/types/FormHelpers.d.ts.map +1 -0
  20. package/dist/types/Suppliable.d.ts +3 -0
  21. package/dist/types/Suppliable.d.ts.map +1 -0
  22. package/dist/validation/CustomValidation.d.ts +1 -1
  23. package/package.json +23 -3
  24. package/src/field/FieldPath.spec.ts +204 -0
  25. package/src/field/FieldPath.ts +63 -59
  26. package/src/field/FieldPathBuilder.spec.ts +47 -0
  27. package/src/field/FieldPathBuilder.ts +15 -81
  28. package/src/field/Reconcile.ts +12 -7
  29. package/src/form/FormController.spec.ts +55 -0
  30. package/src/form/FormController.ts +152 -121
  31. package/src/form/FormField.ts +66 -30
  32. package/src/index.ts +2 -2
  33. package/src/types/DeepHelpers.ts +15 -0
  34. package/src/types/FormHelpers.ts +13 -0
  35. package/src/types/Suppliable.ts +7 -0
  36. package/src/validation/CustomValidation.ts +1 -1
  37. package/dist/form/NonullFormField.d.ts +0 -9
  38. package/dist/form/NonullFormField.d.ts.map +0 -1
  39. package/dist/types/DeepPartial.d.ts +0 -6
  40. package/dist/types/DeepPartial.d.ts.map +0 -1
  41. package/src/form/NonullFormField.ts +0 -15
  42. package/src/types/DeepPartial.ts +0 -7
  43. package/tsconfig.json +0 -8
  44. package/vite.config.ts +0 -18
@@ -0,0 +1,55 @@
1
+ import { expect, test } from "vitest";
2
+ import { FormController } from "../form/FormController";
3
+
4
+ interface User {
5
+ name: string;
6
+ address: {
7
+ city: string;
8
+ street: string;
9
+ };
10
+ friends: {
11
+ name: string;
12
+ tags: string[];
13
+ }[];
14
+ coords: [100, 200 | 201];
15
+ }
16
+
17
+ test("registers fields", () => {
18
+ const formController = new FormController<User>({});
19
+
20
+ let changeInvoked = 0;
21
+
22
+ formController.events.on("fieldValueChanged", () => changeInvoked++);
23
+
24
+ const path1 = formController.path.of((data) => data.friends[0].tags[99]);
25
+ expect(formController.getField(path1)).toBeUndefined();
26
+ const field1 = formController.registerField(path1, { defaultValue: "Tag99" });
27
+ expect(field1.value).toBe("Tag99");
28
+ expect(field1.initialValue).toBeUndefined();
29
+
30
+ const path2 = formController.path.of("coords[1]");
31
+ expect(formController.getField(path2)).toBeUndefined();
32
+ const field2 = formController.registerField(path2);
33
+ expect(field2.value).toBeUndefined();
34
+ expect(field2.initialValue).toBeUndefined();
35
+
36
+ field1.setValue("Tag100");
37
+ field2.setValue(200);
38
+
39
+ const path3 = formController.path.of("coords");
40
+ expect(formController.getField(path3)).toBeUndefined();
41
+ const field3 = formController.registerField(path3, {
42
+ defaultValue: [100, 200],
43
+ overrideInitialValue: true,
44
+ });
45
+ expect(field3.value).toEqual([undefined, 200]);
46
+ expect(field3.initialValue).toBeUndefined();
47
+
48
+ field3.modifyValue((val) => {
49
+ val[0] = 100;
50
+ val[1]++;
51
+ });
52
+
53
+ expect(field3.value).toStrictEqual([100, 201]);
54
+ expect(changeInvoked).toBe(5);
55
+ });
@@ -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;
@@ -82,8 +116,7 @@ export class FormController<TOutput extends object> {
82
116
  }
83
117
 
84
118
  get isValid() {
85
- // TODO: Does it still count valid while validating?
86
- return this._issues.length === 0;
119
+ return this.isValidating || this._issues.length === 0;
87
120
  }
88
121
 
89
122
  get isValidating() {
@@ -94,6 +127,10 @@ export class FormController<TOutput extends object> {
94
127
  return this._isSubmitting;
95
128
  }
96
129
 
130
+ get triedSubmitting() {
131
+ return this._triedSubmitting;
132
+ }
133
+
97
134
  protected setValidating(newStatus: boolean) {
98
135
  if (this._isValidating === newStatus) return;
99
136
  this._isValidating = newStatus;
@@ -106,85 +143,106 @@ export class FormController<TOutput extends object> {
106
143
  this.events.emit("submissionStatusChange", newStatus);
107
144
  }
108
145
 
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>(
146
+ registerField<TPath extends FieldPath.Segments>(
129
147
  path: TPath,
130
148
  config?: {
131
- defaultValue?: FieldPath.Resolve<TOutput, TPath>;
132
- domElement?: HTMLElement;
149
+ /**
150
+ * Used to set value at **path** in **data**, if it's missing.
151
+ */
152
+ defaultValue?: Suppliable<FieldPath.Resolve<TOutput, TPath>>;
153
+ /**
154
+ * Whether value in **initialData** should also be changed, if **defaultValue** is used or not
155
+ *
156
+ * If this is set to `true` and **defaultValue** is used; **initialData** will be modified
157
+ */
133
158
  overrideInitialValue?: boolean;
134
159
  },
135
160
  ) {
136
161
  let currentValue = FieldPath.getValue(this._data as TOutput, path);
137
162
 
138
163
  if (currentValue == null && config?.defaultValue != null) {
139
- this._unsafeSetFieldValue(path, config.defaultValue, {
140
- updateInitialValue: config.overrideInitialValue,
164
+ const defaultValue = supply(config.defaultValue);
165
+
166
+ ensureImmerability(defaultValue);
167
+
168
+ if (config?.overrideInitialValue === true) {
169
+ this._initialData = produce(this._initialData, (draft) => {
170
+ FieldPath.setValue(draft as TOutput, path, defaultValue);
171
+ });
172
+ }
173
+
174
+ this._data = produce(this._data, (draft) => {
175
+ FieldPath.setValue(draft as TOutput, path, defaultValue);
141
176
  });
177
+
142
178
  currentValue = FieldPath.getValue(this._data as TOutput, path);
143
179
  }
144
180
 
145
181
  const initialValue = FieldPath.getValue(this._initialData as TOutput, path);
146
182
 
147
- const valueChanged = !Reconsile.deepEqual(currentValue, initialValue);
183
+ const valueChanged = !Reconcile.deepEqual(currentValue, initialValue);
148
184
 
149
- const field = new FormField(this, path, {
185
+ const field = new FormField(this, path, config?.defaultValue, {
150
186
  isDirty: valueChanged,
151
187
  });
152
188
 
153
- if (config?.domElement != null) {
154
- field.bindElement(config.domElement);
155
- }
156
-
157
189
  this._fields.set(field.stringPath, field);
158
- this.events.emit("fieldBound", field.path);
190
+ this.events.emit("fieldRegistered", field.path);
159
191
 
160
192
  if (valueChanged) {
161
- this.events.emit("valueChanged", field.path, currentValue, initialValue);
193
+ this.events.emit(
194
+ "fieldValueChanged",
195
+ field.path,
196
+ currentValue,
197
+ initialValue,
198
+ );
162
199
  }
163
200
 
164
201
  return field as FormField<TOutput, FieldPath.Resolve<TOutput, TPath>>;
165
202
  }
166
203
 
167
- unbindField(path: FieldPath.Segments) {
204
+ unregisterField(path: FieldPath.Segments) {
168
205
  const stringPath = FieldPath.toStringPath(path);
206
+
207
+ if (!this._fields.has(stringPath)) return false;
208
+
169
209
  this._fields.delete(stringPath);
170
- this.events.emit("fieldUnbound", path);
210
+ this.events.emit("fieldUnregistered", path);
211
+ return true;
171
212
  }
172
213
 
173
- // TODO: Add an option to keep dirty/touched fields as they are
174
214
  reset(newInitialData?: DeepPartial<TOutput>) {
175
- this._data = this._initialData;
176
- this._issues = [];
215
+ const newData = newInitialData ?? this._initialData;
216
+
217
+ const fieldUpdates = [] as [FieldPath.Segments, any, any][];
177
218
 
178
219
  for (const field of this._fields.values()) {
220
+ const currentValue = FieldPath.getValue(this._data, field.path);
221
+ const nextValue = FieldPath.getValue(newData, field.path);
222
+
223
+ if (!Reconcile.deepEqual(currentValue, nextValue)) {
224
+ fieldUpdates.push([field.path, nextValue, currentValue]);
225
+ }
226
+
179
227
  field.reset();
180
228
  }
181
229
 
182
230
  if (newInitialData != null) {
183
- this._initialData = newInitialData;
184
- this._data = produce(this._initialData, () => {});
231
+ this._initialData = produce(newInitialData, () => {});
185
232
  }
233
+
234
+ this._data = produce(newData, () => {});
235
+ this._issues = [];
236
+ this._triedSubmitting = false;
237
+
238
+ fieldUpdates.forEach(([path, newValue, oldValue]) => {
239
+ this.events.emit("fieldValueChanged", path, newValue, oldValue);
240
+ });
186
241
  }
187
242
 
243
+ // TODO: resetGracefully(newInitialData?: DeepPartial<TOutput>)
244
+ // TODO: ^ Keeps dirty/touched fields as they are
245
+
188
246
  getAscendantFields<TPath extends FieldPath.Segments>(path: TPath) {
189
247
  const paths = path.map((_, i) => {
190
248
  return path.slice(0, i + 1);
@@ -200,6 +258,10 @@ export class FormController<TOutput extends object> {
200
258
  | undefined;
201
259
  }
202
260
 
261
+ getFields() {
262
+ return this._fields.values();
263
+ }
264
+
203
265
  clearFieldIssues<TPath extends FieldPath.Segments>(path: TPath) {
204
266
  this._issues = this._issues.filter((issue) => {
205
267
  return !FieldPath.equals(FieldPath.normalize(issue.path), path);
@@ -210,11 +272,9 @@ export class FormController<TOutput extends object> {
210
272
  _result: StandardSchemaV1.Result<TOutput>,
211
273
  path: TPath,
212
274
  ) {
213
- const diff = Reconsile.diff(
214
- this._issues,
215
- _result.issues ?? [],
216
- Reconsile.deepEqual,
217
- (issue) => {
275
+ const diff = Reconcile.arrayDiff(this._issues, _result.issues ?? [], {
276
+ equals: Reconcile.deepEqual,
277
+ include: (issue) => {
218
278
  if (issue.path == null) return false;
219
279
  const issuePath = FieldPath.normalize(issue.path);
220
280
  return (
@@ -222,7 +282,7 @@ export class FormController<TOutput extends object> {
222
282
  FieldPath.isDescendant(path, issuePath)
223
283
  );
224
284
  },
225
- );
285
+ });
226
286
 
227
287
  removeBy(this._issues, (issue) => diff.removed.includes(issue));
228
288
 
@@ -233,54 +293,54 @@ export class FormController<TOutput extends object> {
233
293
  }
234
294
  }
235
295
 
236
- async validateField<TPath extends FieldPath.Segments>(path: TPath) {
296
+ async validateForm() {
237
297
  if (this._isValidating) return;
238
298
 
239
299
  if (this.validationSchema == null) return;
240
300
 
241
301
  this.setValidating(true);
242
302
 
243
- if (this.getField(path) == null) this.bindField(path);
244
-
245
303
  const result = await this.validationSchema["~standard"].validate(
246
304
  this._data,
247
305
  );
248
306
 
249
- this.events.emit("validationTriggered", path);
250
- this.applyValidation(result, path);
307
+ for (const stringPath of this._fields.keys()) {
308
+ const path = FieldPath.fromStringPath(stringPath);
309
+ this.events.emit("fieldValidationTriggered", path);
310
+ this.applyValidation(result, path);
311
+ }
312
+
313
+ // Append non-registered issues too
314
+ const diff = Reconcile.arrayDiff(this._issues, result.issues ?? [], {
315
+ equals: Reconcile.deepEqual,
316
+ });
317
+ diff.added.forEach((issue) => this._issues.push(issue));
251
318
 
252
319
  this.setValidating(false);
253
320
  }
254
321
 
255
- async validateForm() {
322
+ async validateField<TPath extends FieldPath.Segments>(path: TPath) {
256
323
  if (this._isValidating) return;
257
324
 
258
325
  if (this.validationSchema == null) return;
259
326
 
260
327
  this.setValidating(true);
261
328
 
329
+ if (this.getField(path) == null) this.registerField(path);
330
+
262
331
  const result = await this.validationSchema["~standard"].validate(
263
332
  this._data,
264
333
  );
265
334
 
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));
335
+ this.events.emit("fieldValidationTriggered", path);
336
+ this.applyValidation(result, path);
279
337
 
280
338
  this.setValidating(false);
281
339
  }
282
340
 
283
- createSubmitHandler<TEvent extends FormController.PreventableEvent>(
341
+ createSubmitHandler<
342
+ TEvent extends FormController.PreventableEvent | null | undefined,
343
+ >(
284
344
  onSuccess?: FormController.SubmitSuccessHandler<TOutput, TEvent>,
285
345
  onError?: FormController.SubmitErrorHandler<TEvent>,
286
346
  ) {
@@ -292,14 +352,13 @@ export class FormController<TOutput extends object> {
292
352
  if (this._isValidating) return;
293
353
  if (this._isSubmitting) return;
294
354
 
295
- // TODO? impl or cancel
296
- const abortController = new AbortController();
355
+ this._triedSubmitting = true;
297
356
 
298
357
  await this.validateForm();
299
358
 
300
359
  if (this._issues.length === 0) {
301
360
  this.setSubmitting(true);
302
- await onSuccess?.(this._data as TOutput, event, abortController.signal);
361
+ await onSuccess?.(this._data as any as DeepReadonly<TOutput>, event);
303
362
  this.setSubmitting(false);
304
363
  return;
305
364
  }
@@ -313,36 +372,8 @@ export class FormController<TOutput extends object> {
313
372
  field.focus();
314
373
  break;
315
374
  }
316
- await onError?.(this._issues, event, abortController.signal);
375
+ await onError?.(this._issues, event);
317
376
  this.setSubmitting(false);
318
377
  };
319
378
  }
320
379
  }
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);