@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.
- package/dist/field/FieldPath.d.ts +18 -9
- package/dist/field/FieldPath.d.ts.map +1 -1
- package/dist/field/FieldPathBuilder.d.ts +2 -2
- package/dist/field/FieldPathBuilder.d.ts.map +1 -1
- package/dist/field/Reconcile.d.ts +2 -2
- package/dist/field/Reconcile.d.ts.map +1 -1
- package/dist/form/FormController.d.ts +51 -20
- package/dist/form/FormController.d.ts.map +1 -1
- package/dist/form/FormField.d.ts +15 -10
- package/dist/form/FormField.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +523 -476
- package/dist/index.js.map +1 -1
- package/dist/types/DeepHelpers.d.ts +11 -0
- package/dist/types/DeepHelpers.d.ts.map +1 -0
- package/dist/types/FormHelpers.d.ts +5 -0
- package/dist/types/FormHelpers.d.ts.map +1 -0
- package/dist/types/Suppliable.d.ts +3 -0
- package/dist/types/Suppliable.d.ts.map +1 -0
- package/dist/validation/CustomValidation.d.ts +1 -1
- package/package.json +13 -3
- package/src/field/FieldPath.spec.ts +204 -0
- package/src/field/FieldPath.ts +62 -59
- package/src/field/FieldPathBuilder.spec.ts +47 -0
- package/src/field/FieldPathBuilder.ts +15 -81
- package/src/field/Reconcile.ts +2 -2
- package/src/form/FormController.spec.ts +55 -0
- package/src/form/FormController.ts +151 -115
- package/src/form/FormField.ts +63 -30
- package/src/index.ts +2 -2
- package/src/types/DeepHelpers.ts +15 -0
- package/src/types/FormHelpers.ts +13 -0
- package/src/types/Suppliable.ts +7 -0
- package/src/validation/CustomValidation.ts +1 -1
- package/dist/form/NonullFormField.d.ts +0 -9
- package/dist/form/NonullFormField.d.ts.map +0 -1
- package/dist/types/DeepPartial.d.ts +0 -6
- package/dist/types/DeepPartial.d.ts.map +0 -1
- package/src/form/NonullFormField.ts +0 -15
- package/src/types/DeepPartial.ts +0 -7
- package/tsconfig.json +0 -8
- 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 {
|
|
6
|
+
import { Reconcile } from "../field/Reconcile";
|
|
7
7
|
import { FormField } from "../form/FormField";
|
|
8
|
-
import { DeepPartial } from "../types/
|
|
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?:
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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?:
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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 = !
|
|
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("
|
|
191
|
+
this.events.emit("fieldRegistered", field.path);
|
|
159
192
|
|
|
160
193
|
if (valueChanged) {
|
|
161
|
-
this.events.emit(
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
176
|
-
|
|
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 =
|
|
276
|
+
const diff = Reconcile.arrayDiff(
|
|
214
277
|
this._issues,
|
|
215
278
|
_result.issues ?? [],
|
|
216
|
-
|
|
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
|
|
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.
|
|
250
|
-
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
|
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);
|
package/src/form/FormField.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { produce } from "immer";
|
|
1
|
+
import { Draft, produce } from "immer";
|
|
2
2
|
import { FieldPath } from "../field/FieldPath";
|
|
3
|
-
import {
|
|
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.
|
|
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.
|
|
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
|
-
|
|
104
|
-
return this.
|
|
120
|
+
clearIssues() {
|
|
121
|
+
return this.controller.clearFieldIssues(this.path);
|
|
105
122
|
}
|
|
106
123
|
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
)
|
|
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,
|
|
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?.
|
|
154
|
+
return this.controller.equalityComparators?.get(ctorA)?.(a, b);
|
|
142
155
|
};
|
|
143
156
|
|
|
144
|
-
const valueChanged = !
|
|
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
|
-
"
|
|
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 = !
|
|
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
|
-
|
|
216
|
+
validate() {
|
|
187
217
|
this.controller.validateField(this.path);
|
|
188
218
|
}
|
|
189
219
|
|
|
190
|
-
focus(opts?: {
|
|
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/
|
|
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
|
+
// // ^?
|