@homebound/form-state 2.28.0 → 2.30.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/dist/fields/deepEquals.js +12 -0
- package/dist/fields/listField.js +8 -2
- package/dist/fields/objectField.js +7 -5
- package/dist/fields/valueField.d.ts +7 -1
- package/dist/temporal.d.ts +6 -0
- package/dist/temporal.js +34 -0
- package/dist/temporal.test.d.ts +1 -0
- package/dist/temporal.test.js +97 -0
- package/dist/useFormState.d.ts +14 -3
- package/dist/useFormState.js +14 -0
- package/dist/useFormState.test.js +43 -0
- package/dist/utils.d.ts +4 -1
- package/dist/utils.js +33 -0
- package/package.json +3 -2
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.deepEquals = deepEquals;
|
|
4
|
+
const temporal_1 = require("../temporal");
|
|
4
5
|
/**
|
|
5
6
|
* Our own `deepEquals` because either `fast-deep-equals` or `dequal` or etc actually
|
|
6
7
|
* handle cyclic data structures, despite ChatGTP's assertions/hallucinations.
|
|
@@ -8,6 +9,9 @@ exports.deepEquals = deepEquals;
|
|
|
8
9
|
* Ported from https://github.com/KoryNunn/cyclic-deep-equal which is ISC.
|
|
9
10
|
*/
|
|
10
11
|
function deepEquals(a, b, visited = new Set()) {
|
|
12
|
+
const temporalEquals = (0, temporal_1.areSupportedTemporalValuesEqual)(a, b);
|
|
13
|
+
if (temporalEquals !== undefined)
|
|
14
|
+
return temporalEquals;
|
|
11
15
|
const aType = typeof a;
|
|
12
16
|
if (aType !== typeof b)
|
|
13
17
|
return false;
|
|
@@ -16,6 +20,11 @@ function deepEquals(a, b, visited = new Set()) {
|
|
|
16
20
|
return true;
|
|
17
21
|
return a === b;
|
|
18
22
|
}
|
|
23
|
+
if (hasToJSON(a) || hasToJSON(b)) {
|
|
24
|
+
const a1 = hasToJSON(a) ? a.toJSON() : a;
|
|
25
|
+
const b1 = hasToJSON(b) ? b.toJSON() : b;
|
|
26
|
+
return deepEquals(a1, b1, visited);
|
|
27
|
+
}
|
|
19
28
|
if (Array.isArray(a) !== Array.isArray(b))
|
|
20
29
|
return false;
|
|
21
30
|
const aKeys = Object.keys(a), bKeys = Object.keys(b);
|
|
@@ -39,3 +48,6 @@ function deepEquals(a, b, visited = new Set()) {
|
|
|
39
48
|
}
|
|
40
49
|
return equal;
|
|
41
50
|
}
|
|
51
|
+
function hasToJSON(o) {
|
|
52
|
+
return !!(o && typeof o === "object" && "toJSON" in o);
|
|
53
|
+
}
|
package/dist/fields/listField.js
CHANGED
|
@@ -207,8 +207,11 @@ parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeA
|
|
|
207
207
|
const contentKeys = Object.entries(config.config)
|
|
208
208
|
.filter(([key, cfg]) => key !== idKey && cfg.type === "value")
|
|
209
209
|
.map(([key]) => key);
|
|
210
|
-
const hashByContent = (item) => (0,
|
|
211
|
-
const hashById = (item) =>
|
|
210
|
+
const hashByContent = (item) => hashValue((0, utils_1.normalizeHashValue)(Object.fromEntries(Object.entries(item).filter(([key]) => contentKeys.includes(key)))));
|
|
211
|
+
const hashById = (item) => {
|
|
212
|
+
const idValue = idKey ? item[idKey] : undefined;
|
|
213
|
+
return idValue === undefined ? hashByContent(item) : idValue;
|
|
214
|
+
};
|
|
212
215
|
const currentById = (0, utils_1.groupBy)(currentItems, hashById);
|
|
213
216
|
const incomingById = (0, utils_1.groupBy)(incomingItems, hashById);
|
|
214
217
|
// If our local instance doesn't assign in id, when we get results back from the server, strip
|
|
@@ -347,3 +350,6 @@ parentInstance, parentState, key, rules, listConfig, config, strictOrder, maybeA
|
|
|
347
350
|
(0, mobx_1.reaction)(() => proxy.rows.map((r) => r.value), () => _childTick.value++);
|
|
348
351
|
return proxy;
|
|
349
352
|
}
|
|
353
|
+
function hashValue(value) {
|
|
354
|
+
return (0, object_hash_1.default)(value);
|
|
355
|
+
}
|
|
@@ -178,16 +178,18 @@ function newObjectState(config, parentState, parentInstance, parentListState, in
|
|
|
178
178
|
revertChanges() {
|
|
179
179
|
getFields(this).forEach((f) => f.revertChanges());
|
|
180
180
|
},
|
|
181
|
-
// Saves all current values into _originalValue
|
|
181
|
+
// Saves all current values into _originalValue.
|
|
182
|
+
// Deprecated: use formState.update(ackedServerValue) so saved changes are audited field-by-field.
|
|
182
183
|
commitChanges() {
|
|
183
184
|
if (this._isAutoSaving) {
|
|
184
185
|
// `commitChanges` will mark all WIP changes as committed, which might drop changes if there
|
|
185
186
|
// is an auto-save in-flight, and the user made more changes, that are waiting for their turn
|
|
186
187
|
// on the next auto-save request.
|
|
187
188
|
//
|
|
188
|
-
// Instead of doing a form-wide "all changes are committed",
|
|
189
|
-
// init.map/input to
|
|
190
|
-
// selectively "un-dirty"/
|
|
189
|
+
// Instead of doing a form-wide "all changes are committed", autosave forms should use
|
|
190
|
+
// formState.update(ackedServerValue), or init.map/input, to apply the server's latest results
|
|
191
|
+
// within the form. That selectively "un-dirty"/commits the just-saved data, but keeps the
|
|
192
|
+
// "still-dirty" WIP data alone.
|
|
191
193
|
throw new Error("When using autoSave, you should not manually call commitChanges, instead have init.map/input update the form state");
|
|
192
194
|
}
|
|
193
195
|
// asdf
|
|
@@ -251,7 +253,7 @@ function newObjectState(config, parentState, parentInstance, parentListState, in
|
|
|
251
253
|
const ourIdValue = idField.value;
|
|
252
254
|
const otherIdValue = other[idField.key];
|
|
253
255
|
// If the otherIdValue is undefined, it's a new entity so can't be the same as us
|
|
254
|
-
return otherIdValue !== undefined && otherIdValue
|
|
256
|
+
return otherIdValue !== undefined && (0, utils_1.areEqual)(otherIdValue, ourIdValue);
|
|
255
257
|
},
|
|
256
258
|
isUnset() {
|
|
257
259
|
return !!parentInstance && parentInstance[key] === undefined;
|
|
@@ -37,7 +37,13 @@ export interface FieldState<V> {
|
|
|
37
37
|
set(value: V, opts?: SetOpts): void;
|
|
38
38
|
/** Reverts back to the original value and resets dirty/touched. */
|
|
39
39
|
revertChanges(): void;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Accepts the current changed value (if any) as the original and resets dirty/touched.
|
|
42
|
+
*
|
|
43
|
+
* @deprecated Use `formState.update(ackedServerValue)` instead. `commitChanges` marks all current
|
|
44
|
+
* changes as saved, which risks discarding a user's in-flight edits. Updating from the server ack gives
|
|
45
|
+
* form-state a field-by-field audit of which changes were successfully saved.
|
|
46
|
+
*/
|
|
41
47
|
commitChanges(): void;
|
|
42
48
|
/** Creates a new FieldState with a transformation of the value, i.e. string to int, or feet to inches. */
|
|
43
49
|
adapt<V2>(adapter: ValueAdapter<V, V2>): FieldState<V2>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Temporal } from "temporal-polyfill";
|
|
2
|
+
export type SupportedTemporal = Temporal.PlainDate | Temporal.ZonedDateTime;
|
|
3
|
+
/** Detects the Temporal value types that form-state treats as builtins. */
|
|
4
|
+
export declare function isSupportedTemporal(value: unknown): value is SupportedTemporal;
|
|
5
|
+
/** Compares supported Temporal values with Temporal's `.equals(...)` semantics. */
|
|
6
|
+
export declare function areSupportedTemporalValuesEqual(a: unknown, b: unknown): boolean | undefined;
|
package/dist/temporal.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isSupportedTemporal = isSupportedTemporal;
|
|
4
|
+
exports.areSupportedTemporalValuesEqual = areSupportedTemporalValuesEqual;
|
|
5
|
+
// Use Temporal's well-known tags for runtime detection instead of `instanceof` because this
|
|
6
|
+
// library can receive values from native Temporal, a polyfill, another copy of a polyfill,
|
|
7
|
+
// or another realm. Tag checks are stable across those boundaries, while `instanceof` is not.
|
|
8
|
+
const temporalPlainDateTag = "Temporal.PlainDate";
|
|
9
|
+
const temporalZonedDateTimeTag = "Temporal.ZonedDateTime";
|
|
10
|
+
/** Detects the Temporal value types that form-state treats as builtins. */
|
|
11
|
+
function isSupportedTemporal(value) {
|
|
12
|
+
if (!value || typeof value !== "object") {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const record = value;
|
|
16
|
+
return (isSupportedTemporalTag(record[Symbol.toStringTag]) &&
|
|
17
|
+
typeof record.equals === "function" &&
|
|
18
|
+
typeof record.toJSON === "function");
|
|
19
|
+
}
|
|
20
|
+
/** Compares supported Temporal values with Temporal's `.equals(...)` semantics. */
|
|
21
|
+
function areSupportedTemporalValuesEqual(a, b) {
|
|
22
|
+
const aIsTemporal = isSupportedTemporal(a);
|
|
23
|
+
const bIsTemporal = isSupportedTemporal(b);
|
|
24
|
+
if (!aIsTemporal && !bIsTemporal) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (!aIsTemporal || !bIsTemporal || a[Symbol.toStringTag] !== b[Symbol.toStringTag]) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return a.equals(b);
|
|
31
|
+
}
|
|
32
|
+
function isSupportedTemporalTag(value) {
|
|
33
|
+
return value === temporalPlainDateTag || value === temporalZonedDateTimeTag;
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const temporal_polyfill_1 = require("temporal-polyfill");
|
|
4
|
+
const configBuilders_1 = require("./configBuilders");
|
|
5
|
+
const objectField_1 = require("./fields/objectField");
|
|
6
|
+
describe("Temporal support", () => {
|
|
7
|
+
it("treats equal Temporal value fields as unchanged", () => {
|
|
8
|
+
const form = (0, objectField_1.createObjectState)(temporalValueConfig, {
|
|
9
|
+
publishedOn: temporal_polyfill_1.Temporal.PlainDate.from("2026-04-17"),
|
|
10
|
+
scheduledAt: temporal_polyfill_1.Temporal.ZonedDateTime.from("2026-04-17T09:00:00-04:00[America/New_York]"),
|
|
11
|
+
});
|
|
12
|
+
form.publishedOn.set(temporal_polyfill_1.Temporal.PlainDate.from("2026-04-17"));
|
|
13
|
+
form.scheduledAt.set(temporal_polyfill_1.Temporal.ZonedDateTime.from("2026-04-17T09:00:00-04:00[America/New_York]"));
|
|
14
|
+
expect(form.publishedOn.dirty).toBe(false);
|
|
15
|
+
expect(form.scheduledAt.dirty).toBe(false);
|
|
16
|
+
expect(form.dirty).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
it("tracks dirty nested object fields with Temporal values", () => {
|
|
19
|
+
const form = (0, objectField_1.createObjectState)(temporalObjectConfig, {
|
|
20
|
+
details: {
|
|
21
|
+
dueOn: temporal_polyfill_1.Temporal.PlainDate.from("2026-04-17"),
|
|
22
|
+
startsAt: temporal_polyfill_1.Temporal.ZonedDateTime.from("2026-04-17T09:00:00-04:00[America/New_York]"),
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
form.details.dueOn.set(temporal_polyfill_1.Temporal.PlainDate.from("2026-04-17"));
|
|
26
|
+
form.details.startsAt.set(temporal_polyfill_1.Temporal.ZonedDateTime.from("2026-04-17T09:00:00-04:00[America/New_York]"));
|
|
27
|
+
expect(form.details.dirty).toBe(false);
|
|
28
|
+
expect(form.dirty).toBe(false);
|
|
29
|
+
form.details.startsAt.set(temporal_polyfill_1.Temporal.ZonedDateTime.from("2026-04-18T09:00:00-04:00[America/New_York]"));
|
|
30
|
+
expect(form.details.startsAt.dirty).toBe(true);
|
|
31
|
+
expect(form.details.dirty).toBe(true);
|
|
32
|
+
expect(form.dirty).toBe(true);
|
|
33
|
+
form.details.revertChanges();
|
|
34
|
+
expect(form.details.startsAt.dirty).toBe(false);
|
|
35
|
+
expect(form.details.dirty).toBe(false);
|
|
36
|
+
expect(form.dirty).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it("supports list row hashing for Temporal.ZonedDateTime values", () => {
|
|
39
|
+
var _a;
|
|
40
|
+
const form = (0, objectField_1.createObjectState)(temporalListConfig, { events: [], milestones: [] });
|
|
41
|
+
expect(() => {
|
|
42
|
+
form.events.add({ startsAt: temporal_polyfill_1.Temporal.ZonedDateTime.from("2026-04-17T09:00:00-04:00[America/New_York]") });
|
|
43
|
+
}).not.toThrow();
|
|
44
|
+
expect(form.events.dirty).toBe(true);
|
|
45
|
+
const ackedRow = {
|
|
46
|
+
id: "event:1",
|
|
47
|
+
startsAt: temporal_polyfill_1.Temporal.ZonedDateTime.from("2026-04-17T09:00:00-04:00[America/New_York]"),
|
|
48
|
+
};
|
|
49
|
+
expect(() => form.set({ events: [ackedRow] }, { refreshing: true })).not.toThrow();
|
|
50
|
+
expect(form.events.dirty).toBe(false);
|
|
51
|
+
expect(form.events.value[0].id).toEqual("event:1");
|
|
52
|
+
expect((_a = form.events.rows[0].startsAt.value) === null || _a === void 0 ? void 0 : _a.equals(ackedRow.startsAt)).toBe(true);
|
|
53
|
+
form.events.rows[0].startsAt.set(temporal_polyfill_1.Temporal.ZonedDateTime.from("2026-04-18T09:00:00-04:00[America/New_York]"));
|
|
54
|
+
expect(form.events.rows[0].startsAt.dirty).toBe(true);
|
|
55
|
+
expect(form.events.dirty).toBe(true);
|
|
56
|
+
expect(() => form.events.remove(0)).not.toThrow();
|
|
57
|
+
expect(form.events.value).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
it("supports list row hashing for Temporal.PlainDate values", () => {
|
|
60
|
+
var _a;
|
|
61
|
+
const form = (0, objectField_1.createObjectState)(temporalListConfig, { events: [], milestones: [] });
|
|
62
|
+
expect(() => {
|
|
63
|
+
form.milestones.add({ dueOn: temporal_polyfill_1.Temporal.PlainDate.from("2026-04-17") });
|
|
64
|
+
}).not.toThrow();
|
|
65
|
+
expect(form.milestones.dirty).toBe(true);
|
|
66
|
+
const ackedRow = { id: "milestone:1", dueOn: temporal_polyfill_1.Temporal.PlainDate.from("2026-04-17") };
|
|
67
|
+
expect(() => form.set({ milestones: [ackedRow] }, { refreshing: true })).not.toThrow();
|
|
68
|
+
expect(form.milestones.dirty).toBe(false);
|
|
69
|
+
expect(form.milestones.value[0].id).toEqual("milestone:1");
|
|
70
|
+
expect((_a = form.milestones.rows[0].dueOn.value) === null || _a === void 0 ? void 0 : _a.equals(ackedRow.dueOn)).toBe(true);
|
|
71
|
+
form.milestones.rows[0].dueOn.set(temporal_polyfill_1.Temporal.PlainDate.from("2026-04-18"));
|
|
72
|
+
expect(form.milestones.rows[0].dueOn.dirty).toBe(true);
|
|
73
|
+
expect(form.milestones.dirty).toBe(true);
|
|
74
|
+
expect(() => form.milestones.remove(0)).not.toThrow();
|
|
75
|
+
expect(form.milestones.value).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
const temporalValueConfig = configBuilders_1.f.config({
|
|
79
|
+
publishedOn: configBuilders_1.f.value(),
|
|
80
|
+
scheduledAt: configBuilders_1.f.value(),
|
|
81
|
+
});
|
|
82
|
+
const temporalObjectConfig = configBuilders_1.f.config({
|
|
83
|
+
details: configBuilders_1.f.object({
|
|
84
|
+
dueOn: configBuilders_1.f.value(),
|
|
85
|
+
startsAt: configBuilders_1.f.value(),
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
const temporalListConfig = configBuilders_1.f.config({
|
|
89
|
+
events: configBuilders_1.f.list({
|
|
90
|
+
id: configBuilders_1.f.value(),
|
|
91
|
+
startsAt: configBuilders_1.f.value(),
|
|
92
|
+
}),
|
|
93
|
+
milestones: configBuilders_1.f.list({
|
|
94
|
+
id: configBuilders_1.f.value(),
|
|
95
|
+
dueOn: configBuilders_1.f.value(),
|
|
96
|
+
}),
|
|
97
|
+
});
|
package/dist/useFormState.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ObjectConfig } from "./config";
|
|
2
|
-
import { ObjectState } from "./fields/objectField";
|
|
1
|
+
import { type ObjectConfig } from "./config";
|
|
2
|
+
import { type ObjectState } from "./fields/objectField";
|
|
3
3
|
export type Query<I> = {
|
|
4
4
|
data: I;
|
|
5
5
|
loading: boolean;
|
|
@@ -65,7 +65,18 @@ export type UseFormStateOpts<T, I> = {
|
|
|
65
65
|
*/
|
|
66
66
|
autoSave?: (state: ObjectState<T>) => Promise<unknown>;
|
|
67
67
|
};
|
|
68
|
+
export type FormObjectState<T, I> = ObjectState<T> & {
|
|
69
|
+
/**
|
|
70
|
+
* Refreshes the form with a server-acked value, i.e. `form.update(savedAuthor)`.
|
|
71
|
+
*
|
|
72
|
+
* This is intended for save callbacks that need `form.dirty` to become `false` immediately, before the
|
|
73
|
+
* normal query/cache refresh has a chance to rerender `useFormState`. Pass the value returned by the
|
|
74
|
+
* server so the form can map it through `init.map` and then do a field-by-field refresh of only the
|
|
75
|
+
* changes the server has acknowledged.
|
|
76
|
+
*/
|
|
77
|
+
update(input: Exclude<I, null | undefined>): void;
|
|
78
|
+
};
|
|
68
79
|
/**
|
|
69
80
|
* Creates a formState instance for editing in a form.
|
|
70
81
|
*/
|
|
71
|
-
export declare function useFormState<T, I>(opts: UseFormStateOpts<T, I>):
|
|
82
|
+
export declare function useFormState<T, I>(opts: UseFormStateOpts<T, I>): FormObjectState<T, I>;
|
package/dist/useFormState.js
CHANGED
|
@@ -20,6 +20,9 @@ function useFormState(opts) {
|
|
|
20
20
|
// Use a ref so our memo'ized `onBlur` always see the latest value
|
|
21
21
|
const autoSaveRef = (0, react_1.useRef)(autoSave);
|
|
22
22
|
autoSaveRef.current = autoSave;
|
|
23
|
+
// Use a ref so our memo'ized `update` always sees the latest init/map
|
|
24
|
+
const initRef = (0, react_1.useRef)(init);
|
|
25
|
+
initRef.current = init;
|
|
23
26
|
const firstRunRef = (0, react_1.useRef)(true);
|
|
24
27
|
// This is a little weird, but we need to know ahead of time, before the form useMemo, if we're working with classes/mobx proxies
|
|
25
28
|
const [firstInitValue] = (0, react_1.useState)(() => (0, utils_1.initValue)(config, init));
|
|
@@ -86,6 +89,17 @@ function useFormState(opts) {
|
|
|
86
89
|
}
|
|
87
90
|
const value = firstRunRef.current ? firstInitValue : (0, utils_1.initValue)(config, init);
|
|
88
91
|
const form = (0, objectField_1.createObjectState)(config, value, { maybeAutoSave });
|
|
92
|
+
form.update = function update(input) {
|
|
93
|
+
const init = initRef.current;
|
|
94
|
+
const updateInit = (0, utils_1.isInput)(init)
|
|
95
|
+
? { ...init, input }
|
|
96
|
+
: (0, utils_1.isQuery)(init)
|
|
97
|
+
? { input, map: init.map, ifUndefined: init.ifUndefined }
|
|
98
|
+
: { input };
|
|
99
|
+
form.set((0, utils_1.initValue)(config, updateInit), {
|
|
100
|
+
refreshing: true,
|
|
101
|
+
});
|
|
102
|
+
};
|
|
89
103
|
form.readOnly = readOnly;
|
|
90
104
|
setLoading(form, opts);
|
|
91
105
|
// The identity of `addRules` is not stable, but assume that it is for better UX.
|
|
@@ -243,6 +243,49 @@ describe("useFormState", () => {
|
|
|
243
243
|
// Then we accept it
|
|
244
244
|
expect(r.firstName.textContent).toEqual("F2");
|
|
245
245
|
});
|
|
246
|
+
it("can immediately update from a server ack before init input rerenders", async () => {
|
|
247
|
+
const config = {
|
|
248
|
+
firstName: { type: "value" },
|
|
249
|
+
lastName: { type: "value" },
|
|
250
|
+
};
|
|
251
|
+
const initialAuthor = { legalFirstName: "First", legalLastName: "Last" };
|
|
252
|
+
const savedAuthor = { legalFirstName: "Saved", legalLastName: "Author" };
|
|
253
|
+
function TestComponent() {
|
|
254
|
+
const form = (0, useFormState_1.useFormState)({
|
|
255
|
+
config,
|
|
256
|
+
init: { input: initialAuthor, map: mapServerAuthor },
|
|
257
|
+
});
|
|
258
|
+
function makeLocalChanges() {
|
|
259
|
+
form.firstName.set("Saved");
|
|
260
|
+
form.lastName.set("Author");
|
|
261
|
+
}
|
|
262
|
+
function saveWithoutUpdating() {
|
|
263
|
+
noop(form.changedValue);
|
|
264
|
+
}
|
|
265
|
+
function saveAndUpdate() {
|
|
266
|
+
noop(form.changedValue);
|
|
267
|
+
form.update(savedAuthor);
|
|
268
|
+
}
|
|
269
|
+
return ((0, jsx_runtime_1.jsx)(mobx_react_1.Observer, { children: () => ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { "data-testid": "firstName", children: form.firstName.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "lastName", children: form.lastName.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "dirty", children: String(form.dirty) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "makeLocalChanges", onClick: makeLocalChanges }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "saveWithoutUpdating", onClick: saveWithoutUpdating }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "saveAndUpdate", onClick: saveAndUpdate })] })) }));
|
|
270
|
+
}
|
|
271
|
+
const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
|
|
272
|
+
expect(r.firstName.textContent).toEqual("First");
|
|
273
|
+
expect(r.lastName.textContent).toEqual("Last");
|
|
274
|
+
expect(r.dirty.textContent).toEqual("false");
|
|
275
|
+
(0, rtl_utils_1.click)(r.makeLocalChanges);
|
|
276
|
+
expect(r.firstName.textContent).toEqual("Saved");
|
|
277
|
+
expect(r.lastName.textContent).toEqual("Author");
|
|
278
|
+
expect(r.dirty.textContent).toEqual("true");
|
|
279
|
+
(0, rtl_utils_1.click)(r.saveWithoutUpdating);
|
|
280
|
+
expect(r.dirty.textContent).toEqual("true");
|
|
281
|
+
(0, rtl_utils_1.click)(r.saveAndUpdate);
|
|
282
|
+
expect(r.firstName.textContent).toEqual("Saved");
|
|
283
|
+
expect(r.lastName.textContent).toEqual("Author");
|
|
284
|
+
expect(r.dirty.textContent).toEqual("false");
|
|
285
|
+
function mapServerAuthor(input) {
|
|
286
|
+
return { firstName: input.legalFirstName, lastName: input.legalLastName };
|
|
287
|
+
}
|
|
288
|
+
});
|
|
246
289
|
it("can accept new data while read only", async () => {
|
|
247
290
|
// Given a component
|
|
248
291
|
function TestComponent() {
|
package/dist/utils.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ObjectConfig } from "./config";
|
|
2
|
+
import { type SupportedTemporal } from "./temporal";
|
|
2
3
|
import { InputAndMap, QueryAndMap, UseFormStateOpts } from "./useFormState";
|
|
3
|
-
export type Builtin = Date | Function | Uint8Array | string | number | boolean;
|
|
4
|
+
export type Builtin = Date | Function | SupportedTemporal | Uint8Array | string | number | boolean;
|
|
4
5
|
export type Primitive = undefined | null | boolean | string | number | Function | Date | {
|
|
5
6
|
toJSON(): any;
|
|
6
7
|
};
|
|
@@ -44,6 +45,8 @@ export declare function areEqual<T>(a?: T, b?: T, strictOrder?: boolean): boolea
|
|
|
44
45
|
export declare function hasToJSON(o?: unknown): o is {
|
|
45
46
|
toJSON(): void;
|
|
46
47
|
};
|
|
48
|
+
/** Normalizes values into stable object-hash-safe data using our existing `toJSON` semantics. */
|
|
49
|
+
export declare function normalizeHashValue(value: unknown, seen?: WeakMap<object, unknown>): unknown;
|
|
47
50
|
/** Make a clone of `obj`, but only recurse into POJOs and Arrays...and stores. */
|
|
48
51
|
export declare function deepClone<T>(obj: T, map?: WeakMap<WeakKey, any>): T;
|
|
49
52
|
export declare function groupBy<T, Y = T, K = string>(list: readonly T[], fn: (x: T) => K, valueFn?: (x: T) => Y): Map<K, Y[]>;
|
package/dist/utils.js
CHANGED
|
@@ -10,11 +10,13 @@ exports.isNotUndefined = isNotUndefined;
|
|
|
10
10
|
exports.isEmpty = isEmpty;
|
|
11
11
|
exports.areEqual = areEqual;
|
|
12
12
|
exports.hasToJSON = hasToJSON;
|
|
13
|
+
exports.normalizeHashValue = normalizeHashValue;
|
|
13
14
|
exports.deepClone = deepClone;
|
|
14
15
|
exports.groupBy = groupBy;
|
|
15
16
|
const is_plain_object_1 = require("is-plain-object");
|
|
16
17
|
const mobx_1 = require("mobx");
|
|
17
18
|
const deepEquals_1 = require("./fields/deepEquals");
|
|
19
|
+
const temporal_1 = require("./temporal");
|
|
18
20
|
function fail(message) {
|
|
19
21
|
throw new Error(message || "Failed");
|
|
20
22
|
}
|
|
@@ -110,6 +112,10 @@ function isEmpty(value) {
|
|
|
110
112
|
* - arrays
|
|
111
113
|
*/
|
|
112
114
|
function areEqual(a, b, strictOrder) {
|
|
115
|
+
const temporalEquals = (0, temporal_1.areSupportedTemporalValuesEqual)(a, b);
|
|
116
|
+
if (temporalEquals !== undefined) {
|
|
117
|
+
return temporalEquals;
|
|
118
|
+
}
|
|
113
119
|
if ((0, is_plain_object_1.isPlainObject)(a)) {
|
|
114
120
|
return (0, deepEquals_1.deepEquals)((0, mobx_1.toJS)(a), (0, mobx_1.toJS)(b));
|
|
115
121
|
}
|
|
@@ -131,6 +137,33 @@ function areEqual(a, b, strictOrder) {
|
|
|
131
137
|
function hasToJSON(o) {
|
|
132
138
|
return !!(o && typeof o === "object" && "toJSON" in o);
|
|
133
139
|
}
|
|
140
|
+
/** Normalizes values into stable object-hash-safe data using our existing `toJSON` semantics. */
|
|
141
|
+
function normalizeHashValue(value, seen = new WeakMap()) {
|
|
142
|
+
if (hasToJSON(value)) {
|
|
143
|
+
return normalizeHashValue(value.toJSON(), seen);
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(value)) {
|
|
146
|
+
if (seen.has(value)) {
|
|
147
|
+
return seen.get(value);
|
|
148
|
+
}
|
|
149
|
+
const result = [];
|
|
150
|
+
seen.set(value, result);
|
|
151
|
+
value.forEach((entry) => result.push(normalizeHashValue(entry, seen)));
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
if (value && typeof value === "object" && (0, is_plain_object_1.isPlainObject)(value)) {
|
|
155
|
+
if (seen.has(value)) {
|
|
156
|
+
return seen.get(value);
|
|
157
|
+
}
|
|
158
|
+
const result = {};
|
|
159
|
+
seen.set(value, result);
|
|
160
|
+
Object.entries(value).forEach(([key, entry]) => {
|
|
161
|
+
result[key] = normalizeHashValue(entry, seen);
|
|
162
|
+
});
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
134
167
|
/** Make a clone of `obj`, but only recurse into POJOs and Arrays...and stores. */
|
|
135
168
|
function deepClone(obj, map = new WeakMap()) {
|
|
136
169
|
if (obj && typeof obj === "object" && ((0, is_plain_object_1.isPlainObject)(obj) || Array.isArray(obj) || (0, mobx_1.isObservable)(obj))) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homebound/form-state",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.30.0",
|
|
4
4
|
"author": "Homebound",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@types/object-hash": "^3.0.6",
|
|
34
34
|
"is-plain-object": "^5.0.0",
|
|
35
|
-
"object-hash": "^3.0.0"
|
|
35
|
+
"object-hash": "^3.0.0",
|
|
36
|
+
"temporal-polyfill": "^0.3.2"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
38
39
|
"mobx": ">=6",
|