Skip to content

Commit

Permalink
fix: Trigger auto-saves when observables are mutated directly.
Browse files Browse the repository at this point in the history
With a check to make sure it's not us ourselves doing the mutation.
  • Loading branch information
stephenh committed Oct 10, 2023
1 parent 1fd5755 commit c59da24
Show file tree
Hide file tree
Showing 4 changed files with 59 additions and 3 deletions.
4 changes: 4 additions & 0 deletions src/fields/listField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ export function newListFieldState<T, K extends keyof T, U>(
return this.rows.some((r) => r.dirty) || this.hasChanged();
},

get focused(): boolean {
return this.rows.some((r) => r.focused);
},

get required(): boolean {
return this.rules.some((rule) => rule === required);
},
Expand Down
4 changes: 4 additions & 0 deletions src/fields/objectField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ export function newObjectState<T, P = any>(
return !!readOnlyField?.value;
},

get focused(): boolean {
return getFields(this).some((f) => f.focused);
},

get touched(): boolean {
return getFields(this).some((f) => f.touched);
},
Expand Down
23 changes: 20 additions & 3 deletions src/fields/valueField.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isPlainObject } from "is-plain-object";
import { observable, toJS } from "mobx";
import { isObservable, observable, reaction, toJS } from "mobx";
import { ObjectState } from "src/fields/objectField";
import { newDelegateProxy } from "src/proxies";
import { Rule, required } from "src/rules";
Expand Down Expand Up @@ -27,6 +27,7 @@ export interface FieldState<V> {
readonly required: boolean;
readonly dirty: boolean;
readonly valid: boolean;
readonly focused: boolean;
readonly isNewEntity: boolean;
rules: Rule<V>[];
readonly errors: string[];
Expand Down Expand Up @@ -78,7 +79,6 @@ export interface FieldStateInternal<T, V> extends FieldState<V> {
_isIdKey: boolean;
_isDeleteKey: boolean;
_isReadOnlyKey: boolean;
_focused: boolean;
}

export function newValueFieldState<T, K extends keyof T>(
Expand Down Expand Up @@ -110,9 +110,9 @@ export function newValueFieldState<T, K extends keyof T>(
/** Current readOnly value. */
_readOnly: readOnly || false,
_loading: false,
_focused: false,
// Expose so computed can be skipped in changedValue
_computed: computed,
_focused: false,

_isIdKey: isIdKey,
_isDeleteKey: isDeleteKey,
Expand All @@ -133,6 +133,10 @@ export function newValueFieldState<T, K extends keyof T>(
this.set(v);
},

get focused(): boolean {
return this._focused;
},

get dirty(): boolean {
return !areEqual(this.originalValue, this.value, strictOrder);
},
Expand Down Expand Up @@ -286,6 +290,19 @@ export function newValueFieldState<T, K extends keyof T>(
},
};

// If we're wrapping a mobx observer, watching for external mutations, i.e. from callers not
// going through our FieldState.set/FieldState.value setters.
if (isObservable(parentInstance)) {
reaction(
() => parentInstance[key],
() => {
// Don't auto-save on maybe-external mutation if our field, or another other field (potentially a computed),
// is currently focused, because then it's probably just us doing the writes through `Bound...Field` components.
if (!parentState().focused) maybeAutoSave();
},
);
}

return field as any;
}

Expand Down
31 changes: 31 additions & 0 deletions src/useFormState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,37 @@ describe("useFormState", () => {
expect(r.booksDirty.textContent).toEqual("false");
});

it("can trigger auto-save when underlying observable is changed", async () => {
// Given a component
// And it's using a class/mobx proxy as the basis for the data
class AuthorRow {
constructor(public firstName: string | undefined) {
makeAutoObservable(this);
}
}
const autoSave = jest.fn();
function TestComponent() {
// And the author starts out with f1
const author = useMemo(() => new AuthorRow("f1"), []);
const config: ObjectConfig<AuthorRow> = useMemo(() => ({ firstName: { type: "value" } }), []);
const form = useFormState({ config, init: { input: author, map: (a) => a }, autoSave });
return (
<div>
<button data-testid="changeFirstName" onClick={() => (author.firstName = "f2")} />
<button data-testid="clearFirstName" onClick={() => (author.firstName = undefined)} />
</div>
);
}
// When we change the underlying observable
const r = await render(<TestComponent />);
expect(autoSave).toBeCalledTimes(0);
await clickAndWait(r.changeFirstName);
// Then auto save is triggered
expect(autoSave).toBeCalledTimes(1);
await clickAndWait(r.clearFirstName);
expect(autoSave).toBeCalledTimes(2);
});

it("can trigger auto save for fields in list that were initially undefined", async () => {
const autoSave = jest.fn();
// Given a component
Expand Down

0 comments on commit c59da24

Please sign in to comment.