@homebound/form-state 2.27.0 → 2.27.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.
@@ -11,6 +11,13 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
11
11
  // which itself is not a proxy, we use this as our "value changed" trigger.
12
12
  const _tick = (0, mobx_1.observable)({ value: 1 });
13
13
  const _originalValueTick = (0, mobx_1.observable)({ value: 1 });
14
+ // Track "this is probably what we put into a mutation to the server" to allow
15
+ // use to better accept server acks/responses that change our initial submission.
16
+ //
17
+ // I.e. we submit `firstName: bob` and the server acks `firstName: Bob`, we should
18
+ // accept it if we can tell we haven't changed `bob` since our initial submission.
19
+ let hasInflightChangedValue = false;
20
+ let lastChangedValue = undefined;
14
21
  const field = {
15
22
  key: key,
16
23
  touched: false,
@@ -60,6 +67,10 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
60
67
  },
61
68
  // For primitive fields, the changed value is just the value.
62
69
  get changedValue() {
70
+ // Add some hints to let `set(..., { refreshing: true })` accept server-driven changes to our value,
71
+ // if the user hasn't changed it yet-again while the changedValue was in in-flight.
72
+ hasInflightChangedValue = true;
73
+ lastChangedValue = this.value;
63
74
  // Usually if we see a field being unset, we set `parent[key] = null`, but if we're wrapping
64
75
  // a mobx observable object, it might have changed to `undefined` without us being able to
65
76
  // tell it to be `null` (and potentially break the type contract of `string | undefined`).
@@ -100,12 +111,18 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
100
111
  if (this.readOnly && !opts.resetting && !opts.refreshing) {
101
112
  throw new Error(`${String(key)} is currently readOnly`);
102
113
  }
103
- const isAckingUnset = this.value === null && (value === null || value === undefined);
104
- if (opts.refreshing && this.dirty && this.value !== value && !isAckingUnset) {
105
- // Ignore incoming values if we have changes (this.dirty) unless our latest change (this.value)
106
- // matches the incoming value (value), b/c if it does we should accept it and reset originalValue
107
- // so that we're no longer dirty.
108
- return;
114
+ if (opts.refreshing && this.dirty) {
115
+ // See if we should ignore the incoming server-side value, to avoid dropping local WIP changes
116
+ const isAckingUnset = this.value === null && (value === null || value === undefined);
117
+ const acceptServerAck = hasInflightChangedValue && this.value === lastChangedValue;
118
+ // Ignore incoming values if we have changes (this.dirty) unless:
119
+ // - our latest change (this.value) matches the incoming value (value), i.e. the server
120
+ // is exactly acking our change, or
121
+ // - the user hasn't made any changes since `.changedValue` was put on the wire, i.e. the
122
+ // server received our value, but wanted to to change it.
123
+ const keepLocalWipChange = this.value !== value && !isAckingUnset && !acceptServerAck;
124
+ if (keepLocalWipChange)
125
+ return;
109
126
  }
110
127
  else if (computed && (opts.resetting || opts.refreshing)) {
111
128
  // Computeds can't be either reset or refreshed
@@ -117,6 +134,7 @@ function newValueFieldState(parentCopy, parentInstance, parentState, key, rules,
117
134
  // If a list of primitives was originally undefined, coerce `[]` to `undefined`
118
135
  const coerceEmptyList = value && value instanceof Array && value.length === 0 && (0, utils_1.isEmpty)(this.originalValue);
119
136
  const newValue = keepNull ? null : (0, utils_1.isEmpty)(value) || coerceEmptyList ? undefined : value;
137
+ hasInflightChangedValue = false;
120
138
  // Set the value on our parent object
121
139
  const changed = !(0, utils_1.areEqual)(newValue, this.value, strictOrder);
122
140
  parentInstance[key] = newValue;
@@ -1561,7 +1561,7 @@ describe("formState", () => {
1561
1561
  formState.books.add({ title: "b2" });
1562
1562
  expect(ticks).toEqual(2);
1563
1563
  });
1564
- it("does not override a focused, changed field", () => {
1564
+ it("keeps WIP changes for changed field", () => {
1565
1565
  const formState = (0, objectField_1.createObjectState)(authorWithBooksConfig, { firstName: "f", lastName: "l" });
1566
1566
  // Given first name is focused
1567
1567
  formState.firstName.focus();
@@ -1576,6 +1576,36 @@ describe("formState", () => {
1576
1576
  formState.firstName.set("fff");
1577
1577
  expect(formState.firstName.value).toEqual("fff");
1578
1578
  });
1579
+ it("accepts server result if we have no further changes", () => {
1580
+ const formState = (0, objectField_1.createObjectState)(authorWithBooksConfig, { id: "a:1", firstName: "f" });
1581
+ // Given first name has wip edits
1582
+ formState.firstName.set("ff");
1583
+ // When a mutation result sets both firstName and lastName
1584
+ formState.set({ firstName: "f2" }, { refreshing: true });
1585
+ // Then we don't overwrite the user's WIP work
1586
+ expect(formState.firstName.value).toEqual("ff");
1587
+ // But if we call changedValue, insinuating we're sending the value off to the server
1588
+ expect(formState.changedValue).toEqual({ id: "a:1", firstName: "ff" });
1589
+ // And now the server acks something different
1590
+ formState.set({ firstName: "FF" }, { refreshing: true });
1591
+ // Then we accept the value
1592
+ expect(formState.firstName.value).toEqual("FF");
1593
+ expect(formState.firstName.dirty).toEqual(false);
1594
+ });
1595
+ it("rejects server result if we have further changes", () => {
1596
+ const formState = (0, objectField_1.createObjectState)(authorWithBooksConfig, { id: "a:1", firstName: "f" });
1597
+ // Given first name has wip edits
1598
+ formState.firstName.set("ff");
1599
+ // And we call changedValue, insinuating we're sending the value off to the server
1600
+ expect(formState.changedValue).toEqual({ id: "a:1", firstName: "ff" });
1601
+ // And the user changes the name yet again
1602
+ formState.firstName.set("fff");
1603
+ // And now the server acks something different
1604
+ formState.set({ firstName: "FF" }, { refreshing: true });
1605
+ // Then we keep the "post-changedValue" value
1606
+ expect(formState.firstName.value).toEqual("fff");
1607
+ expect(formState.firstName.dirty).toEqual(true);
1608
+ });
1579
1609
  it("does update a focused, unchanged field", () => {
1580
1610
  const formState = (0, objectField_1.createObjectState)(authorWithBooksConfig, { firstName: "f", lastName: "l" });
1581
1611
  // Given first name is focused, but has not changed
@@ -175,7 +175,7 @@ describe("useFormState", () => {
175
175
  form.address.street.value = "local";
176
176
  form.books.rows[0].title.value = "local";
177
177
  }
178
- 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": "street", children: form.address.street.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "city", children: form.address.city.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "title1", children: form.books.rows[0].title.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "title2", children: form.books.rows[1].title.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "booksLength", children: form.books.rows.length }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "makeLocalChanges", onClick: makeLocalChanges }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "refreshData", onClick: () => setData(data2) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "saveData", onClick: () => setData(data3) }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "changedValue", children: JSON.stringify(form.changedValue) })] })) }));
178
+ 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": "street", children: form.address.street.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "city", children: form.address.city.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "title1", children: form.books.rows[0].title.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "title2", children: form.books.rows[1].title.value }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "booksLength", children: form.books.rows.length }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "makeLocalChanges", onClick: makeLocalChanges }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "refreshData", onClick: () => setData(data2) }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "saveData", onClick: () => setData(data3) }), (0, jsx_runtime_1.jsx)("div", { "data-testid": "dirty", children: String(form.dirty) })] })) }));
179
179
  }
180
180
  // And we start out with the initial query data
181
181
  const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
@@ -188,12 +188,7 @@ describe("useFormState", () => {
188
188
  expect(r.firstName.textContent).toEqual("local");
189
189
  expect(r.street.textContent).toEqual("local");
190
190
  expect(r.title1.textContent).toEqual("local");
191
- expect(JSON.parse(r.changedValue.textContent)).toEqual({
192
- id: "a:1",
193
- address: { id: "address:1", street: "local" },
194
- books: [{ id: "b:1", title: "local" }, { id: "b:2" }],
195
- firstName: "local",
196
- });
191
+ expect(r.dirty.textContent).toEqual("true");
197
192
  // And when the new query is ran i.e. due to a cache refresh
198
193
  (0, rtl_utils_1.click)(r.refreshData);
199
194
  // Then we kept our local changes
@@ -205,18 +200,41 @@ describe("useFormState", () => {
205
200
  expect(r.city.textContent).toEqual("c2");
206
201
  expect(r.title2.textContent).toEqual("b2");
207
202
  expect(r.booksLength.textContent).toEqual("3");
208
- expect(JSON.parse(r.changedValue.textContent)).toEqual({
209
- id: "a:1",
210
- address: { id: "address:1", street: "local" },
211
- books: [{ id: "b:1", title: "local" }, { id: "b:2" }, { id: "b:3" }],
212
- firstName: "local",
213
- });
203
+ expect(r.dirty.textContent).toEqual("true");
214
204
  // And then when our mutation results come back
215
205
  (0, rtl_utils_1.click)(r.saveData);
216
- // Then changedValue doesn't show our local changes anymore
217
- expect(JSON.parse(r.changedValue.textContent)).toEqual({
218
- id: "a:1",
219
- });
206
+ // And we're not dirty anymore
207
+ expect(r.dirty.textContent).toEqual("false");
208
+ });
209
+ it("accepts server values if client has no further changes", async () => {
210
+ function TestComponent() {
211
+ const config = authorWithAddressAndBooksConfig;
212
+ // The initial resposne
213
+ const data1 = { id: "a:1", firstName: "F1" };
214
+ // The acked response, which uppercases our submission
215
+ const data2 = { id: "a:1", firstName: "F2" };
216
+ // And we start out with data1
217
+ const [data, setData] = (0, react_2.useState)(data1);
218
+ const form = (0, useFormState_1.useFormState)({ config, init: { input: data } });
219
+ function makeLocalChanges() {
220
+ form.firstName.value = "f2";
221
+ }
222
+ 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)("button", { "data-testid": "makeLocalChanges", onClick: makeLocalChanges }), (0, jsx_runtime_1.jsx)("button", { "data-testid": "refreshData", onClick: () => {
223
+ noop(form.changedValue); // pretend we put this on the wire
224
+ setData(data2);
225
+ } })] })) }));
226
+ }
227
+ // And we start out with the initial query data
228
+ const r = await (0, rtl_utils_1.render)((0, jsx_runtime_1.jsx)(TestComponent, {}));
229
+ expect(r.firstName.textContent).toEqual("F1");
230
+ // When we make some local changes
231
+ (0, rtl_utils_1.click)(r.makeLocalChanges);
232
+ // Then we see them
233
+ expect(r.firstName.textContent).toEqual("f2");
234
+ // And when the server acks a slightly different result
235
+ (0, rtl_utils_1.click)(r.refreshData);
236
+ // Then we accept it
237
+ expect(r.firstName.textContent).toEqual("F2");
220
238
  });
221
239
  it("can accept new data while read only", async () => {
222
240
  // Given a component
@@ -694,3 +712,4 @@ function focusAndBlur(state) {
694
712
  state.focus();
695
713
  state.blur();
696
714
  }
715
+ function noop(value) { }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homebound/form-state",
3
- "version": "2.27.0",
3
+ "version": "2.27.1",
4
4
  "author": "Homebound",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",