From b0fb0a84552e8109b31c802e707fdd64693561f1 Mon Sep 17 00:00:00 2001 From: Stephen Haberman Date: Mon, 11 Sep 2023 09:28:36 -0500 Subject: [PATCH] feat: Add field state adapter (#88) * feat: Add FieldState.adapt for doing feet/inch conversion. * Code golf. * Export it. * Default V2 to V. * Add comment. --- src/fields/valueField.ts | 60 ++++++++++++++++++++++++++++++++++++---- src/formState.test.tsx | 33 ++++++++++++++++++++++ src/index.ts | 6 ++-- src/proxies.ts | 18 ++++++++++++ 4 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 src/proxies.ts diff --git a/src/fields/valueField.ts b/src/fields/valueField.ts index d41ba92..330a5e3 100644 --- a/src/fields/valueField.ts +++ b/src/fields/valueField.ts @@ -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"; /** @@ -40,6 +41,20 @@ export interface FieldState { 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(adapter: ValueAdapter): FieldState; +} + +/** + * 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 { + /** 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. */ @@ -89,7 +104,7 @@ export function newValueFieldState( const _originalValueTick = observable({ value: 1 }); const field = { - key, + key: key as string, touched: false, @@ -210,7 +225,7 @@ export function newValueFieldState( _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. @@ -219,6 +234,10 @@ export function newValueFieldState( } }, + adapt(adapter: ValueAdapter): FieldState { + return adapt(this, adapter); + }, + revertChanges() { if (!computed) { this.set(this.originalValue, { resetting: true }); @@ -235,14 +254,14 @@ export function newValueFieldState( 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++; }, @@ -262,3 +281,34 @@ export function newValueFieldState( 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(field: FieldState, adapter: ValueAdapter): FieldState { + 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(adapter: ValueAdapter) { + return adapt(this, adapter); + }, + get originalValue(): V2 { + return adapter.toValue(field.originalValue); + }, + }); +} diff --git a/src/formState.test.tsx b/src/formState.test.tsx index e2adc19..7dc4449 100644 --- a/src/formState.test.tsx +++ b/src/formState.test.tsx @@ -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( + { + 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); diff --git a/src/index.ts b/src/index.ts index e5ef756..f1d30af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/src/proxies.ts b/src/proxies.ts new file mode 100644 index 0000000..aa97355 --- /dev/null +++ b/src/proxies.ts @@ -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(delegate: T, overrides: O): Omit & 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; +}