Skip to content

Commit

Permalink
feat: Add field state adapter (#88)
Browse files Browse the repository at this point in the history
* feat: Add FieldState.adapt for doing feet/inch conversion.

* Code golf.

* Export it.

* Default V2 to V.

* Add comment.
  • Loading branch information
stephenh authored Sep 11, 2023
1 parent f535107 commit b0fb0a8
Show file tree
Hide file tree
Showing 4 changed files with 109 additions and 8 deletions.
60 changes: 55 additions & 5 deletions src/fields/valueField.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { isPlainObject } from "is-plain-object";
import { observable, toJS } from "mobx";
import { ObjectState } from "src/fields/objectField";
import { required, Rule } from "src/rules";
import { newDelegateProxy } from "src/proxies";
import { Rule, required } from "src/rules";
import { areEqual, fail, isEmpty, isNotUndefined } from "src/utils";

/**
Expand Down Expand Up @@ -40,6 +41,20 @@ export interface FieldState<V> {
revertChanges(): void;
/** Accepts the current changed value (if any) as the original and resets dirty/touched. */
commitChanges(): void;
/** Creates a new FieldState with a transformation of the value, i.e. string to int, or feet to inches. */
adapt<V2>(adapter: ValueAdapter<V, V2>): FieldState<V2>;
}

/**
* Allows changing a type in the formState (like a string) to a different type in the UI (like a number).
*
* Or doing unit of measure conversions within the same type, like from meters to feet.
*/
export interface ValueAdapter<V, V2 = V> {
/** Converts the original FieldState's value `V` into new `V2` type. */
toValue(value: V): V2;
/** Converts the adapted FieldState's value `V2` back into the original `V` type. */
fromValue(value: V2): V;
}

/** Public options for our `set` command. */
Expand Down Expand Up @@ -89,7 +104,7 @@ export function newValueFieldState<T, K extends keyof T>(
const _originalValueTick = observable({ value: 1 });

const field = {
key,
key: key as string,

touched: false,

Expand Down Expand Up @@ -210,7 +225,7 @@ export function newValueFieldState<T, K extends keyof T>(
_tick.value++;

if (opts.refreshing) {
this.originalValue = newValue;
this.originalValue = newValue as any;
}
// If we're being set programmatically, i.e. we don't currently have focus,
// call blur to trigger any auto-saves.
Expand All @@ -219,6 +234,10 @@ export function newValueFieldState<T, K extends keyof T>(
}
},

adapt<V2>(adapter: ValueAdapter<V, V2>): FieldState<V2> {
return adapt(this, adapter);
},

revertChanges() {
if (!computed) {
this.set(this.originalValue, { resetting: true });
Expand All @@ -235,14 +254,14 @@ export function newValueFieldState<T, K extends keyof T>(
this.touched = false;
},

get originalValue(): V | null | undefined {
get originalValue(): V {
// A dummy check to for reactivity around our non-proxy value
const value = _originalValueTick.value > -1 ? _originalValue : _originalValue;
// Re-create the `keepNull` logic so that `.value` === `.originalValue`
return value === null ? (undefined as any) : value;
},

set originalValue(v: V | null | undefined) {
set originalValue(v: V) {
_originalValue = v;
_originalValueTick.value++;
},
Expand All @@ -262,3 +281,34 @@ export function newValueFieldState<T, K extends keyof T>(

return field as any;
}

/**
* Returns a proxy that looks exactly like the original `field`, in terms of valid/touched/errors/etc., but
* has any methods that use `V` overridden to use be `V2`.
*
* Note that `V2` can be a new type, like string -> number, or just a transformation on the same
* type, i.e. feet -> inches where both are `number`s.
*/
function adapt<V, V2>(field: FieldState<V>, adapter: ValueAdapter<V, V2>): FieldState<V2> {
return newDelegateProxy(field, {
rules: [],
get value(): V2 {
return adapter.toValue(field.value);
},
set value(v: V2) {
field.value = adapter.fromValue(v);
},
set: (v: V2) => {
field.value = adapter.fromValue(v);
},
get changedValue(): V2 {
return this.value;
},
adapt<V3>(adapter: ValueAdapter<V2, V3>) {
return adapt(this, adapter);
},
get originalValue(): V2 {
return adapter.toValue(field.originalValue);
},
});
}
33 changes: 33 additions & 0 deletions src/formState.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,39 @@ describe("formState", () => {
expect(a.address.city.value).toEqual("b1");
});

it("can adapt values", () => {
// Given an author where `delete` is normally a boolean
const a = createObjectState<BookInput>(
{
delete: { type: "value" },
},
{ delete: true },
);
const boolField = a.delete;
// But we adapt it to a string
const stringField = a.delete.adapt({
toValue: (b) => String(b),
fromValue: (s) => Boolean(s),
});
// Then we can read it as a string
expect(stringField.value).toEqual("true");
// And we can set it as a string
stringField.value = "";
expect(boolField.value).toBe(false);
// And the originalValue is maintained
expect(boolField.originalValue).toBe(true);
expect(stringField.originalValue).toBe("true");
// As well as the dirty.
expect(boolField.dirty).toBe(true);
expect(stringField.dirty).toBe(true);
// And reverting works
stringField.revertChanges();
expect(boolField.dirty).toBe(false);
expect(stringField.dirty).toBe(false);
expect(boolField.value).toBe(true);
expect(stringField.value).toBe("true");
});

it("maintains object identity", () => {
const a1: AuthorInput = { firstName: "a1" };
const state = createAuthorInputState(a1);
Expand Down
6 changes: 3 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export { ObjectConfig } from "src/config";
export { ListFieldState } from "src/fields/listField";
export { createObjectState, ObjectState } from "src/fields/objectField";
export { FieldState } from "src/fields/valueField";
export { required, Rule } from "src/rules";
export { ObjectState, createObjectState } from "src/fields/objectField";
export { FieldState, ValueAdapter } from "src/fields/valueField";
export { Rule, required } from "src/rules";
export { useFormState } from "src/useFormState";
export { useFormStates } from "src/useFormStates";
18 changes: 18 additions & 0 deletions src/proxies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Creates a new combined object with keys in `overrides` taking precedence, and then
* any other keys falling back to `delegate`.
*/
export function newDelegateProxy<T extends object, O extends object>(delegate: T, overrides: O): Omit<T, keyof O> & O {
function pickTarget(key: keyof any) {
return Reflect.has(overrides, key) ? overrides : delegate;
}
return new Proxy(delegate, {
get(object, key) {
return Reflect.get(pickTarget(key), key);
},

set(object, key, value) {
return Reflect.set(pickTarget(key), key, value);
},
}) as any;
}

0 comments on commit b0fb0a8

Please sign in to comment.