@homebound/form-state 2.29.0 → 2.30.1
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.
|
@@ -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
|
|
@@ -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>;
|
|
@@ -127,7 +127,10 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
|
|
|
127
127
|
// is exactly acking our change, or
|
|
128
128
|
// - the user hasn't made any changes since `.changedValue` was put on the wire, i.e. the
|
|
129
129
|
// server received our value, but wanted to change it.
|
|
130
|
-
|
|
130
|
+
// Compare by content (not `!==`) so array/list-of-primitive values are treated as acked when
|
|
131
|
+
// the server returns the same contents in a new array instance (otherwise the field would
|
|
132
|
+
// stay dirty forever, since reference equality never holds for a fresh array).
|
|
133
|
+
const keepLocalWipChange = !(0, utils_1.areEqual)(this.value, value, strictOrder) && !isAckingUnset && !acceptServerAck;
|
|
131
134
|
if (keepLocalWipChange)
|
|
132
135
|
return;
|
|
133
136
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,6 @@ export { type ListFieldState } from "./fields/listField";
|
|
|
4
4
|
export { createObjectState, type Fragment, type ObjectState, fragment } from "./fields/objectField";
|
|
5
5
|
export { type FieldState, type ValueAdapter } from "./fields/valueField";
|
|
6
6
|
export { required, type Rule } from "./rules";
|
|
7
|
-
export { useFormState } from "./useFormState";
|
|
7
|
+
export { type FormObjectState, useFormState } from "./useFormState";
|
|
8
8
|
export { useFormStates } from "./useFormStates";
|
|
9
9
|
export { type ObjectConfig } from "./config";
|
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,71 @@ 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
|
+
});
|
|
289
|
+
it("becomes clean when update acks an array value with the same contents", async () => {
|
|
290
|
+
const config = { colors: { type: "value" } };
|
|
291
|
+
function TestComponent() {
|
|
292
|
+
const form = (0, useFormState_1.useFormState)({ config, init: { input: { colors: ["red", "green", "blue"] } } });
|
|
293
|
+
// Note: we deliberately do not read `form.changedValue` (the way an auto-save would), so the only
|
|
294
|
+
// thing that can let `update` accept the server value is content equality, not the changedValue hint.
|
|
295
|
+
return ((0, jsx_runtime_1.jsx)(mobx_react_1.Observer, { children: () => {
|
|
296
|
+
var _a;
|
|
297
|
+
return ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("div", { "data-testid": "colors", children: ((_a = form.colors.value) !== null && _a !== void 0 ? _a : []).join(",") }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "dirty", children: String(form.dirty) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "edit", onClick: () => form.colors.set(["red", "green"]) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "saveAndUpdate", onClick: () => form.update({ colors: ["red", "green"] }) })] }));
|
|
298
|
+
} }));
|
|
299
|
+
}
|
|
300
|
+
const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
|
|
301
|
+
expect(r.dirty.textContent).toEqual("false");
|
|
302
|
+
// When the user edits the array
|
|
303
|
+
(0, rtl_utils_1.click)(r.edit);
|
|
304
|
+
expect(r.dirty.textContent).toEqual("true");
|
|
305
|
+
// And the server acks the same contents in a brand-new array instance
|
|
306
|
+
(0, rtl_utils_1.click)(r.saveAndUpdate);
|
|
307
|
+
// Then the form is clean (we don't get stuck dirty on array reference inequality)
|
|
308
|
+
expect(r.colors.textContent).toEqual("red,green");
|
|
309
|
+
expect(r.dirty.textContent).toEqual("false");
|
|
310
|
+
});
|
|
246
311
|
it("can accept new data while read only", async () => {
|
|
247
312
|
// Given a component
|
|
248
313
|
function TestComponent() {
|